diff --git a/CODEOWNERS b/CODEOWNERS index cdb61c59104..e13573aa774 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -162,6 +162,7 @@ homeassistant/components/hisense_aehw4a1/* @bannhead homeassistant/components/history/* @home-assistant/core homeassistant/components/hive/* @Rendili @KJonline homeassistant/components/homeassistant/* @home-assistant/core +homeassistant/components/homekit/* @bdraco homeassistant/components/homekit_controller/* @Jc2k homeassistant/components/homematic/* @pvizeli @danielperna84 homeassistant/components/homematicip_cloud/* @SukramJ diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index d80fd3c5338..1e4c39f3ed9 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,5 +3,5 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": ["HAP-python==2.8.2"], - "codeowners": [] + "codeowners": ["@bdraco"] } diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 62f374c8888..8458c8351da 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -54,9 +54,9 @@ class Light(HomeAccessory): super().__init__(*args, category=CATEGORY_LIGHTBULB) self.chars = [] - self._features = self.hass.states.get(self.entity_id).attributes.get( - ATTR_SUPPORTED_FEATURES - ) + state = self.hass.states.get(self.entity_id) + + self._features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if self._features & SUPPORT_BRIGHTNESS: self.chars.append(CHAR_BRIGHTNESS) @@ -101,10 +101,12 @@ class Light(HomeAccessory): if CHAR_SATURATION in self.chars: self.char_saturation = serv_light.configure_char(CHAR_SATURATION, value=75) + self.update_state(state) + serv_light.setter_callback = self._set_chars def _set_chars(self, char_values): - _LOGGER.debug("_set_chars: %s", char_values) + _LOGGER.debug("Light _set_chars: %s", char_values) events = [] service = SERVICE_TURN_ON params = {ATTR_ENTITY_ID: self.entity_id} diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 3a7211ab2ad..0d2a19ef089 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -35,7 +35,7 @@ class Lock(HomeAccessory): """Initialize a Lock accessory object.""" super().__init__(*args, category=CATEGORY_DOOR_LOCK) self._code = self.config.get(ATTR_CODE) - self._flag_state = False + state = self.hass.states.get(self.entity_id) serv_lock_mechanism = self.add_preload_service(SERV_LOCK) self.char_current_state = serv_lock_mechanism.configure_char( @@ -46,15 +46,18 @@ class Lock(HomeAccessory): value=HASS_TO_HOMEKIT[STATE_LOCKED], setter_callback=self.set_state, ) + self.update_state(state) def set_state(self, value): """Set lock state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) - self._flag_state = True hass_value = HOMEKIT_TO_HASS.get(value) service = STATE_TO_SERVICE[hass_value] + if self.char_current_state.value != value: + self.char_current_state.set_value(value) + params = {ATTR_ENTITY_ID: self.entity_id} if self._code: params[ATTR_CODE] = self._code @@ -65,16 +68,21 @@ class Lock(HomeAccessory): hass_state = new_state.state if hass_state in HASS_TO_HOMEKIT: current_lock_state = HASS_TO_HOMEKIT[hass_state] - self.char_current_state.set_value(current_lock_state) _LOGGER.debug( "%s: Updated current state to %s (%d)", self.entity_id, hass_state, current_lock_state, ) - # LockTargetState only supports locked and unlocked + # Must set lock target state before current state + # or there will be no notification if hass_state in (STATE_LOCKED, STATE_UNLOCKED): - if not self._flag_state: + if self.char_target_state.value != current_lock_state: self.char_target_state.set_value(current_lock_state) - self._flag_state = False + + # Set lock current state ONLY after ensuring that + # target state is correct or there will be no + # notification + if self.char_current_state.value != current_lock_state: + self.char_current_state.set_value(current_lock_state) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index d1bcb600d84..8691dc51c05 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -36,6 +36,7 @@ from homeassistant.components.climate.const import ( SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT, SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT, SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) from homeassistant.components.water_heater import ( @@ -52,7 +53,7 @@ from homeassistant.const import ( ) from . import TYPES -from .accessories import HomeAccessory, debounce +from .accessories import HomeAccessory from .const import ( CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_CURRENT_HEATING_COOLING, @@ -76,26 +77,37 @@ _LOGGER = logging.getLogger(__name__) HC_HOMEKIT_VALID_MODES_WATER_HEATER = {"Heat": 1} UNIT_HASS_TO_HOMEKIT = {TEMP_CELSIUS: 0, TEMP_FAHRENHEIT: 1} +HC_HEAT_COOL_OFF = 0 +HC_HEAT_COOL_HEAT = 1 +HC_HEAT_COOL_COOL = 2 +HC_HEAT_COOL_AUTO = 3 + +HC_MIN_TEMP = 10 +HC_MAX_TEMP = 38 + UNIT_HOMEKIT_TO_HASS = {c: s for s, c in UNIT_HASS_TO_HOMEKIT.items()} HC_HASS_TO_HOMEKIT = { - HVAC_MODE_OFF: 0, - HVAC_MODE_HEAT: 1, - HVAC_MODE_COOL: 2, - HVAC_MODE_AUTO: 3, - HVAC_MODE_HEAT_COOL: 3, - HVAC_MODE_FAN_ONLY: 2, + HVAC_MODE_OFF: HC_HEAT_COOL_OFF, + HVAC_MODE_HEAT: HC_HEAT_COOL_HEAT, + HVAC_MODE_COOL: HC_HEAT_COOL_COOL, + HVAC_MODE_AUTO: HC_HEAT_COOL_AUTO, + HVAC_MODE_HEAT_COOL: HC_HEAT_COOL_AUTO, + HVAC_MODE_DRY: HC_HEAT_COOL_COOL, + HVAC_MODE_FAN_ONLY: HC_HEAT_COOL_COOL, } HC_HOMEKIT_TO_HASS = {c: s for s, c in HC_HASS_TO_HOMEKIT.items()} HC_HASS_TO_HOMEKIT_ACTION = { - CURRENT_HVAC_OFF: 0, - CURRENT_HVAC_IDLE: 0, - CURRENT_HVAC_HEAT: 1, - CURRENT_HVAC_COOL: 2, - CURRENT_HVAC_DRY: 2, - CURRENT_HVAC_FAN: 2, + CURRENT_HVAC_OFF: HC_HEAT_COOL_OFF, + CURRENT_HVAC_IDLE: HC_HEAT_COOL_OFF, + CURRENT_HVAC_HEAT: HC_HEAT_COOL_HEAT, + CURRENT_HVAC_COOL: HC_HEAT_COOL_COOL, + CURRENT_HVAC_DRY: HC_HEAT_COOL_COOL, + CURRENT_HVAC_FAN: HC_HEAT_COOL_COOL, } +HEAT_COOL_DEADBAND = 5 + @TYPES.register("Thermostat") class Thermostat(HomeAccessory): @@ -105,12 +117,12 @@ class Thermostat(HomeAccessory): """Initialize a Thermostat accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) self._unit = self.hass.config.units.temperature_unit - self._flag_heat_cool = False - self._flag_temperature = False - self._flag_coolingthresh = False - self._flag_heatingthresh = False min_temp, max_temp = self.get_temperature_range() + # Homekit only supports 10-38 + hc_min_temp = max(min_temp, HC_MIN_TEMP) + hc_max_temp = min(max_temp, HC_MAX_TEMP) + min_humidity = self.hass.states.get(self.entity_id).attributes.get( ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY ) @@ -174,24 +186,22 @@ class Thermostat(HomeAccessory): hc_valid_values = {k: v for v, k in self.hc_homekit_to_hass.items()} self.char_target_heat_cool = serv_thermostat.configure_char( - CHAR_TARGET_HEATING_COOLING, - value=list(hc_valid_values.values())[0], - setter_callback=self.set_heat_cool, - valid_values=hc_valid_values, + CHAR_TARGET_HEATING_COOLING, valid_values=hc_valid_values, ) # Current and target temperature characteristics + self.char_current_temp = serv_thermostat.configure_char( CHAR_CURRENT_TEMPERATURE, value=21.0 ) + self.char_target_temp = serv_thermostat.configure_char( CHAR_TARGET_TEMPERATURE, value=21.0, # We do not set PROP_MIN_STEP here and instead use the HomeKit # default of 0.1 in order to have enough precision to convert # temperature units and avoid setting to 73F will result in 74F - properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp}, - setter_callback=self.set_target_temperature, + properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp}, ) # Display units characteristic @@ -209,8 +219,7 @@ class Thermostat(HomeAccessory): # We do not set PROP_MIN_STEP here and instead use the HomeKit # default of 0.1 in order to have enough precision to convert # temperature units and avoid setting to 73F will result in 74F - properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp}, - setter_callback=self.set_cooling_threshold, + properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp}, ) if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars: self.char_heating_thresh_temp = serv_thermostat.configure_char( @@ -219,8 +228,7 @@ class Thermostat(HomeAccessory): # We do not set PROP_MIN_STEP here and instead use the HomeKit # default of 0.1 in order to have enough precision to convert # temperature units and avoid setting to 73F will result in 74F - properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp}, - setter_callback=self.set_heating_threshold, + properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp}, ) self.char_target_humidity = None self.char_current_humidity = None @@ -234,43 +242,134 @@ class Thermostat(HomeAccessory): # of 80% homekit will give you the options 20%-100% instead # of 0-80% properties={PROP_MIN_VALUE: min_humidity}, - setter_callback=self.set_target_humidity, ) self.char_current_humidity = serv_thermostat.configure_char( CHAR_CURRENT_HUMIDITY, value=50 ) + self.update_state(state) + + serv_thermostat.setter_callback = self._set_chars + + def _temperature_to_homekit(self, temp): + return temperature_to_homekit(temp, self._unit) + + def _temperature_to_states(self, temp): + return temperature_to_states(temp, self._unit) + + def _set_chars(self, char_values): + _LOGGER.debug("Thermostat _set_chars: %s", char_values) + events = [] + params = {} + service = None + state = self.hass.states.get(self.entity_id) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + hvac_mode = self.hass.states.get(self.entity_id).state + homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] + + if CHAR_TARGET_HEATING_COOLING in char_values: + # Homekit will reset the mode when VIEWING the temp + # Ignore it if its the same mode + if char_values[CHAR_TARGET_HEATING_COOLING] != homekit_hvac_mode: + service = SERVICE_SET_HVAC_MODE_THERMOSTAT + hass_value = self.hc_homekit_to_hass[ + char_values[CHAR_TARGET_HEATING_COOLING] + ] + params = {ATTR_HVAC_MODE: hass_value} + events.append( + f"{CHAR_TARGET_HEATING_COOLING} to {char_values[CHAR_TARGET_HEATING_COOLING]}" + ) + + if CHAR_TARGET_TEMPERATURE in char_values: + hc_target_temp = char_values[CHAR_TARGET_TEMPERATURE] + if features & SUPPORT_TARGET_TEMPERATURE: + service = SERVICE_SET_TEMPERATURE_THERMOSTAT + temperature = self._temperature_to_states(hc_target_temp) + events.append( + f"{CHAR_TARGET_TEMPERATURE} to {char_values[CHAR_TARGET_TEMPERATURE]}°C" + ) + params[ATTR_TEMPERATURE] = temperature + elif features & SUPPORT_TARGET_TEMPERATURE_RANGE: + # Homekit will send us a target temperature + # even if the device does not support it + _LOGGER.debug( + "Homekit requested target temp: %s and the device does not support", + hc_target_temp, + ) + if ( + homekit_hvac_mode == HC_HEAT_COOL_HEAT + and CHAR_HEATING_THRESHOLD_TEMPERATURE not in char_values + ): + char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE] = hc_target_temp + if ( + homekit_hvac_mode == HC_HEAT_COOL_COOL + and CHAR_COOLING_THRESHOLD_TEMPERATURE not in char_values + ): + char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE] = hc_target_temp + + if ( + CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values + or CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values + ): + service = SERVICE_SET_TEMPERATURE_THERMOSTAT + high = self.char_cooling_thresh_temp.value + low = self.char_heating_thresh_temp.value + min_temp, max_temp = self.get_temperature_range() + if CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values: + events.append( + f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to {char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE]}°C" + ) + high = char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE] + # If the device doesn't support TARGET_TEMPATURE + # this can happen + if high < low: + low = high - HEAT_COOL_DEADBAND + if CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values: + events.append( + f"{CHAR_HEATING_THRESHOLD_TEMPERATURE} to {char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE]}°C" + ) + low = char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE] + # If the device doesn't support TARGET_TEMPATURE + # this can happen + if low > high: + high = low + HEAT_COOL_DEADBAND + + high = min(high, max_temp) + low = max(low, min_temp) + + params.update( + { + ATTR_TARGET_TEMP_HIGH: self._temperature_to_states(high), + ATTR_TARGET_TEMP_LOW: self._temperature_to_states(low), + } + ) + + if service: + params[ATTR_ENTITY_ID] = self.entity_id + self.call_service( + DOMAIN_CLIMATE, service, params, ", ".join(events), + ) + + if CHAR_TARGET_HUMIDITY in char_values: + self.set_target_humidity(char_values[CHAR_TARGET_HUMIDITY]) + def get_temperature_range(self): """Return min and max temperature range.""" max_temp = self.hass.states.get(self.entity_id).attributes.get(ATTR_MAX_TEMP) max_temp = ( - temperature_to_homekit(max_temp, self._unit) - if max_temp - else DEFAULT_MAX_TEMP + self._temperature_to_homekit(max_temp) if max_temp else DEFAULT_MAX_TEMP ) max_temp = round(max_temp * 2) / 2 min_temp = self.hass.states.get(self.entity_id).attributes.get(ATTR_MIN_TEMP) min_temp = ( - temperature_to_homekit(min_temp, self._unit) - if min_temp - else DEFAULT_MIN_TEMP + self._temperature_to_homekit(min_temp) if min_temp else DEFAULT_MIN_TEMP ) min_temp = round(min_temp * 2) / 2 return min_temp, max_temp - def set_heat_cool(self, value): - """Change operation mode to value if call came from HomeKit.""" - _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) - self._flag_heat_cool = True - hass_value = self.hc_homekit_to_hass[value] - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HVAC_MODE: hass_value} - self.call_service( - DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE_THERMOSTAT, params, hass_value - ) - - @debounce def set_target_humidity(self, value): """Set target humidity to value if call came from HomeKit.""" _LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value) @@ -279,126 +378,85 @@ class Thermostat(HomeAccessory): DOMAIN_CLIMATE, SERVICE_SET_HUMIDITY, params, f"{value}{UNIT_PERCENTAGE}" ) - @debounce - def set_cooling_threshold(self, value): - """Set cooling threshold temp to value if call came from HomeKit.""" - _LOGGER.debug( - "%s: Set cooling threshold temperature to %.1f°C", self.entity_id, value - ) - self._flag_coolingthresh = True - low = self.char_heating_thresh_temp.value - temperature = temperature_to_states(value, self._unit) - params = { - ATTR_ENTITY_ID: self.entity_id, - ATTR_TARGET_TEMP_HIGH: temperature, - ATTR_TARGET_TEMP_LOW: temperature_to_states(low, self._unit), - } - self.call_service( - DOMAIN_CLIMATE, - SERVICE_SET_TEMPERATURE_THERMOSTAT, - params, - f"cooling threshold {temperature}{self._unit}", - ) - - @debounce - def set_heating_threshold(self, value): - """Set heating threshold temp to value if call came from HomeKit.""" - _LOGGER.debug( - "%s: Set heating threshold temperature to %.1f°C", self.entity_id, value - ) - self._flag_heatingthresh = True - high = self.char_cooling_thresh_temp.value - temperature = temperature_to_states(value, self._unit) - params = { - ATTR_ENTITY_ID: self.entity_id, - ATTR_TARGET_TEMP_HIGH: temperature_to_states(high, self._unit), - ATTR_TARGET_TEMP_LOW: temperature, - } - self.call_service( - DOMAIN_CLIMATE, - SERVICE_SET_TEMPERATURE_THERMOSTAT, - params, - f"heating threshold {temperature}{self._unit}", - ) - - @debounce - def set_target_temperature(self, value): - """Set target temperature to value if call came from HomeKit.""" - _LOGGER.debug("%s: Set target temperature to %.1f°C", self.entity_id, value) - self._flag_temperature = True - temperature = temperature_to_states(value, self._unit) - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TEMPERATURE: temperature} - self.call_service( - DOMAIN_CLIMATE, - SERVICE_SET_TEMPERATURE_THERMOSTAT, - params, - f"{temperature}{self._unit}", - ) - def update_state(self, new_state): """Update thermostat state after state changed.""" + features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # Update target operation mode FIRST + hvac_mode = new_state.state + if hvac_mode and hvac_mode in HC_HASS_TO_HOMEKIT: + homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] + if self.char_target_heat_cool.value != homekit_hvac_mode: + self.char_target_heat_cool.set_value(homekit_hvac_mode) + + # Set current operation mode for supported thermostats + hvac_action = new_state.attributes.get(ATTR_HVAC_ACTION) + if hvac_action: + homekit_hvac_action = HC_HASS_TO_HOMEKIT_ACTION[hvac_action] + if self.char_current_heat_cool.value != homekit_hvac_action: + self.char_current_heat_cool.set_value(homekit_hvac_action) + # Update current temperature current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE) if isinstance(current_temp, (int, float)): - current_temp = temperature_to_homekit(current_temp, self._unit) - self.char_current_temp.set_value(current_temp) + current_temp = self._temperature_to_homekit(current_temp) + if self.char_current_temp.value != current_temp: + self.char_current_temp.set_value(current_temp) # Update current humidity if CHAR_CURRENT_HUMIDITY in self.chars: current_humdity = new_state.attributes.get(ATTR_CURRENT_HUMIDITY) if isinstance(current_humdity, (int, float)): - self.char_current_humidity.set_value(current_humdity) - - # Update target temperature - target_temp = new_state.attributes.get(ATTR_TEMPERATURE) - if isinstance(target_temp, (int, float)): - target_temp = temperature_to_homekit(target_temp, self._unit) - if not self._flag_temperature: - self.char_target_temp.set_value(target_temp) - self._flag_temperature = False + if self.char_current_humidity.value != current_humdity: + self.char_current_humidity.set_value(current_humdity) # Update target humidity if CHAR_TARGET_HUMIDITY in self.chars: target_humdity = new_state.attributes.get(ATTR_HUMIDITY) if isinstance(target_humdity, (int, float)): - self.char_target_humidity.set_value(target_humdity) + if self.char_target_humidity.value != target_humdity: + self.char_target_humidity.set_value(target_humdity) # Update cooling threshold temperature if characteristic exists if self.char_cooling_thresh_temp: cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) if isinstance(cooling_thresh, (int, float)): - cooling_thresh = temperature_to_homekit(cooling_thresh, self._unit) - if not self._flag_coolingthresh: + cooling_thresh = self._temperature_to_homekit(cooling_thresh) + if self.char_heating_thresh_temp.value != cooling_thresh: self.char_cooling_thresh_temp.set_value(cooling_thresh) - self._flag_coolingthresh = False # Update heating threshold temperature if characteristic exists if self.char_heating_thresh_temp: heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) if isinstance(heating_thresh, (int, float)): - heating_thresh = temperature_to_homekit(heating_thresh, self._unit) - if not self._flag_heatingthresh: + heating_thresh = self._temperature_to_homekit(heating_thresh) + if self.char_heating_thresh_temp.value != heating_thresh: self.char_heating_thresh_temp.set_value(heating_thresh) - self._flag_heatingthresh = False + + # Update target temperature + target_temp = new_state.attributes.get(ATTR_TEMPERATURE) + if isinstance(target_temp, (int, float)): + target_temp = self._temperature_to_homekit(target_temp) + elif features & SUPPORT_TARGET_TEMPERATURE_RANGE: + # Homekit expects a target temperature + # even if the device does not support it + hc_hvac_mode = self.char_target_heat_cool.value + if hc_hvac_mode == HC_HEAT_COOL_HEAT: + target_temp = self._temperature_to_homekit( + new_state.attributes.get(ATTR_TARGET_TEMP_LOW) + ) + elif hc_hvac_mode == HC_HEAT_COOL_COOL: + target_temp = self._temperature_to_homekit( + new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) + ) + if target_temp and self.char_target_temp.value != target_temp: + self.char_target_temp.set_value(target_temp) # Update display units if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: - self.char_display_units.set_value(UNIT_HASS_TO_HOMEKIT[self._unit]) - - # Update target operation mode - hvac_mode = new_state.state - if hvac_mode and hvac_mode in HC_HASS_TO_HOMEKIT: - if not self._flag_heat_cool: - self.char_target_heat_cool.set_value(HC_HASS_TO_HOMEKIT[hvac_mode]) - self._flag_heat_cool = False - - # Set current operation mode for supported thermostats - hvac_action = new_state.attributes.get(ATTR_HVAC_ACTION) - if hvac_action: - - self.char_current_heat_cool.set_value( - HC_HASS_TO_HOMEKIT_ACTION[hvac_action] - ) + unit = UNIT_HASS_TO_HOMEKIT[self._unit] + if self.char_display_units.value != unit: + self.char_display_units.set_value(unit) @TYPES.register("WaterHeater") @@ -409,8 +467,6 @@ class WaterHeater(HomeAccessory): """Initialize a WaterHeater accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) self._unit = self.hass.config.units.temperature_unit - self._flag_heat_cool = False - self._flag_temperature = False min_temp, max_temp = self.get_temperature_range() serv_thermostat = self.add_preload_service(SERV_THERMOSTAT) @@ -442,6 +498,9 @@ class WaterHeater(HomeAccessory): CHAR_TEMP_DISPLAY_UNITS, value=0 ) + state = self.hass.states.get(self.entity_id) + self.update_state(state) + def get_temperature_range(self): """Return min and max temperature range.""" max_temp = self.hass.states.get(self.entity_id).attributes.get(ATTR_MAX_TEMP) @@ -465,16 +524,14 @@ class WaterHeater(HomeAccessory): def set_heat_cool(self, value): """Change operation mode to value if call came from HomeKit.""" _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) - self._flag_heat_cool = True hass_value = HC_HOMEKIT_TO_HASS[value] if hass_value != HVAC_MODE_HEAT: - self.char_target_heat_cool.set_value(1) # Heat + if self.char_target_heat_cool.value != 1: + self.char_target_heat_cool.set_value(1) # Heat - @debounce def set_target_temperature(self, value): """Set target temperature to value if call came from HomeKit.""" _LOGGER.debug("%s: Set target temperature to %.1f°C", self.entity_id, value) - self._flag_temperature = True temperature = temperature_to_states(value, self._unit) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TEMPERATURE: temperature} self.call_service( @@ -490,17 +547,16 @@ class WaterHeater(HomeAccessory): temperature = new_state.attributes.get(ATTR_TEMPERATURE) if isinstance(temperature, (int, float)): temperature = temperature_to_homekit(temperature, self._unit) - self.char_current_temp.set_value(temperature) - if not self._flag_temperature: + if temperature != self.char_current_temp.value: self.char_target_temp.set_value(temperature) - self._flag_temperature = False # Update display units if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: - self.char_display_units.set_value(UNIT_HASS_TO_HOMEKIT[self._unit]) + unit = UNIT_HASS_TO_HOMEKIT[self._unit] + if self.char_display_units.value != unit: + self.char_display_units.set_value(unit) # Update target operation mode operation_mode = new_state.state - if operation_mode and not self._flag_heat_cool: + if operation_mode and self.char_target_heat_cool.value != 1: self.char_target_heat_cool.set_value(1) # Heat - self._flag_heat_cool = False diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index ecfa2edbe54..5b5dcf8f3a2 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -66,7 +66,7 @@ async def test_light_basic(hass, hk_driver, cls, events, driver): assert acc.aid == 1 assert acc.category == 5 # Lightbulb - assert acc.char_on.value == 0 + assert acc.char_on.value await acc.run_handler() await hass.async_block_till_done() @@ -260,7 +260,7 @@ async def test_light_color_temperature(hass, hk_driver, cls, events, driver): acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) driver.add_accessory(acc) - assert acc.char_color_temperature.value == 153 + assert acc.char_color_temperature.value == 190 await acc.run_handler() await hass.async_block_till_done() @@ -326,8 +326,8 @@ async def test_light_rgb_color(hass, hk_driver, cls, events, driver): acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) driver.add_accessory(acc) - assert acc.char_hue.value == 0 - assert acc.char_saturation.value == 75 + assert acc.char_hue.value == 260 + assert acc.char_saturation.value == 90 await acc.run_handler() await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index d463231ba59..93b781170b4 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -2,6 +2,8 @@ from collections import namedtuple from unittest.mock import patch +from pyhap.accessory_driver import AccessoryDriver +from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest from homeassistant.components.climate.const import ( @@ -23,7 +25,6 @@ from homeassistant.components.climate.const import ( CURRENT_HVAC_IDLE, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, - DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, HVAC_MODE_AUTO, HVAC_MODE_COOL, @@ -32,6 +33,8 @@ from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, ) from homeassistant.components.homekit.const import ( ATTR_VALUE, @@ -41,6 +44,7 @@ from homeassistant.components.homekit.const import ( PROP_MIN_STEP, PROP_MIN_VALUE, ) +from homeassistant.components.homekit.type_thermostats import HC_MIN_TEMP from homeassistant.components.water_heater import DOMAIN as DOMAIN_WATER_HEATER from homeassistant.const import ( ATTR_ENTITY_ID, @@ -58,6 +62,15 @@ from tests.common import async_mock_service from tests.components.homekit.common import patch_debounce +@pytest.fixture +def driver(): + """Patch AccessoryDriver without zeroconf or HAPServer.""" + with patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.Zeroconf" + ), patch("pyhap.accessory_driver.AccessoryDriver.persist"): + yield AccessoryDriver() + + @pytest.fixture(scope="module") def cls(): """Patch debounce decorator during import of type_thermostats.""" @@ -65,14 +78,14 @@ def cls(): patcher.start() _import = __import__( "homeassistant.components.homekit.type_thermostats", - fromlist=["Thermostat", "WaterHeater"], + fromlist=["WaterHeater", "Thermostat"], ) - patcher_tuple = namedtuple("Cls", ["thermostat", "water_heater"]) + patcher_tuple = namedtuple("Cls", ["water_heater", "thermostat"]) yield patcher_tuple(thermostat=_import.Thermostat, water_heater=_import.WaterHeater) patcher.stop() -async def test_thermostat(hass, hk_driver, cls, events): +async def test_thermostat(hass, hk_driver, cls, events, driver): """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" @@ -80,6 +93,7 @@ async def test_thermostat(hass, hk_driver, cls, events): entity_id, HVAC_MODE_OFF, { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE, ATTR_HVAC_MODES: [ HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, @@ -87,15 +101,17 @@ async def test_thermostat(hass, hk_driver, cls, events): HVAC_MODE_COOL, HVAC_MODE_OFF, HVAC_MODE_AUTO, - ] + ], }, ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + await acc.run_handler() await hass.async_block_till_done() - assert acc.aid == 2 + assert acc.aid == 1 assert acc.category == 9 # Thermostat assert acc.get_temperature_range() == (7.0, 35.0) @@ -110,7 +126,7 @@ async def test_thermostat(hass, hk_driver, cls, events): assert acc.char_current_humidity is None assert acc.char_target_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP - assert acc.char_target_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP + assert acc.char_target_temp.properties[PROP_MIN_VALUE] == HC_MIN_TEMP assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.1 hass.states.async_set( @@ -257,6 +273,7 @@ async def test_thermostat(hass, hk_driver, cls, events): entity_id, HVAC_MODE_DRY, { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: CURRENT_HVAC_DRY, @@ -273,42 +290,102 @@ async def test_thermostat(hass, hk_driver, cls, events): call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE, "set_temperature") call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") - await hass.async_add_executor_job(acc.char_target_temp.client_update_value, 19.0) + char_target_temp_iid = acc.char_target_temp.to_HAP()[HAP_REPR_IID] + char_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_temp_iid, + HAP_REPR_VALUE: 19.0, + }, + ] + }, + "mock_addr", + ) await hass.async_block_till_done() assert call_set_temperature assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 19.0 assert acc.char_target_temp.value == 19.0 assert len(events) == 1 - assert events[-1].data[ATTR_VALUE] == "19.0°C" + assert events[-1].data[ATTR_VALUE] == "TargetTemperature to 19.0°C" - await hass.async_add_executor_job(acc.char_target_heat_cool.client_update_value, 2) + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_heat_cool_iid, + HAP_REPR_VALUE: 2, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert not call_set_hvac_mode + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_heat_cool_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) await hass.async_block_till_done() assert call_set_hvac_mode assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id - assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_COOL - assert acc.char_target_heat_cool.value == 2 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT + assert acc.char_target_heat_cool.value == 1 assert len(events) == 2 - assert events[-1].data[ATTR_VALUE] == HVAC_MODE_COOL + assert events[-1].data[ATTR_VALUE] == "TargetHeatingCoolingState to 1" - await hass.async_add_executor_job(acc.char_target_heat_cool.client_update_value, 3) + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_heat_cool_iid, + HAP_REPR_VALUE: 3, + }, + ] + }, + "mock_addr", + ) await hass.async_block_till_done() assert call_set_hvac_mode assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT_COOL assert acc.char_target_heat_cool.value == 3 assert len(events) == 3 - assert events[-1].data[ATTR_VALUE] == HVAC_MODE_HEAT_COOL + assert events[-1].data[ATTR_VALUE] == "TargetHeatingCoolingState to 3" -async def test_thermostat_auto(hass, hk_driver, cls, events): +async def test_thermostat_auto(hass, hk_driver, cls, events, driver): """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" # support_auto = True - hass.states.async_set(entity_id, HVAC_MODE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) + hass.states.async_set( + entity_id, + HVAC_MODE_OFF, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + }, + ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + await acc.run_handler() await hass.async_block_till_done() @@ -316,10 +393,10 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): assert acc.char_heating_thresh_temp.value == 19.0 assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP - assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == HC_MIN_TEMP assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP - assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == HC_MIN_TEMP assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 hass.states.async_set( @@ -379,37 +456,51 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): # Set from HomeKit call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE, "set_temperature") - await hass.async_add_executor_job( - acc.char_heating_thresh_temp.client_update_value, 20.0 + char_heating_thresh_temp_iid = acc.char_heating_thresh_temp.to_HAP()[HAP_REPR_IID] + char_cooling_thresh_temp_iid = acc.char_cooling_thresh_temp.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_heating_thresh_temp_iid, + HAP_REPR_VALUE: 20.0, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_cooling_thresh_temp_iid, + HAP_REPR_VALUE: 25.0, + }, + ] + }, + "mock_addr", ) + await hass.async_block_till_done() assert call_set_temperature[0] assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 20.0 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 25.0 assert acc.char_heating_thresh_temp.value == 20.0 - assert len(events) == 1 - assert events[-1].data[ATTR_VALUE] == "heating threshold 20.0°C" - - await hass.async_add_executor_job( - acc.char_cooling_thresh_temp.client_update_value, 25.0 - ) - await hass.async_block_till_done() - assert call_set_temperature[1] - assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 25.0 assert acc.char_cooling_thresh_temp.value == 25.0 - assert len(events) == 2 - assert events[-1].data[ATTR_VALUE] == "cooling threshold 25.0°C" + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] + == "CoolingThresholdTemperature to 25.0°C, HeatingThresholdTemperature to 20.0°C" + ) -async def test_thermostat_humidity(hass, hk_driver, cls, events): +async def test_thermostat_humidity(hass, hk_driver, cls, events, driver): """Test if accessory and HA are updated accordingly with humidity.""" entity_id = "climate.test" # support_auto = True hass.states.async_set(entity_id, HVAC_MODE_OFF, {ATTR_SUPPORTED_FEATURES: 4}) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + await acc.run_handler() await hass.async_block_till_done() @@ -435,7 +526,21 @@ async def test_thermostat_humidity(hass, hk_driver, cls, events): # Set from HomeKit call_set_humidity = async_mock_service(hass, DOMAIN_CLIMATE, "set_humidity") - await hass.async_add_executor_job(acc.char_target_humidity.client_update_value, 35) + char_target_humidity_iid = acc.char_target_humidity.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_humidity_iid, + HAP_REPR_VALUE: 35, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() assert call_set_humidity[0] assert call_set_humidity[0].data[ATTR_ENTITY_ID] == entity_id @@ -445,7 +550,7 @@ async def test_thermostat_humidity(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == "35%" -async def test_thermostat_power_state(hass, hk_driver, cls, events): +async def test_thermostat_power_state(hass, hk_driver, cls, events, driver): """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" @@ -458,10 +563,19 @@ async def test_thermostat_power_state(hass, hk_driver, cls, events): ATTR_TEMPERATURE: 23.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT_COOL, + HVAC_MODE_COOL, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + ], }, ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + await acc.run_handler() await hass.async_block_till_done() @@ -475,6 +589,13 @@ async def test_thermostat_power_state(hass, hk_driver, cls, events): ATTR_TEMPERATURE: 23.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT_COOL, + HVAC_MODE_COOL, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + ], }, ) await hass.async_block_till_done() @@ -488,6 +609,13 @@ async def test_thermostat_power_state(hass, hk_driver, cls, events): ATTR_TEMPERATURE: 23.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT_COOL, + HVAC_MODE_COOL, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + ], }, ) await hass.async_block_till_done() @@ -497,31 +625,68 @@ async def test_thermostat_power_state(hass, hk_driver, cls, events): # Set from HomeKit call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") - await hass.async_add_executor_job(acc.char_target_heat_cool.client_update_value, 1) + char_target_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() assert call_set_hvac_mode assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT assert acc.char_target_heat_cool.value == 1 assert len(events) == 1 - assert events[-1].data[ATTR_VALUE] == HVAC_MODE_HEAT + assert events[-1].data[ATTR_VALUE] == "TargetHeatingCoolingState to 1" + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: 2, + }, + ] + }, + "mock_addr", + ) - await hass.async_add_executor_job(acc.char_target_heat_cool.client_update_value, 0) await hass.async_block_till_done() - assert acc.char_target_heat_cool.value == 0 + assert call_set_hvac_mode + assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVAC_MODE_COOL assert len(events) == 2 - assert events[-1].data[ATTR_VALUE] == HVAC_MODE_OFF + assert events[-1].data[ATTR_VALUE] == "TargetHeatingCoolingState to 2" + assert acc.char_target_heat_cool.value == 2 -async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): +async def test_thermostat_fahrenheit(hass, hk_driver, cls, events, driver): """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" # support_ = True - hass.states.async_set(entity_id, HVAC_MODE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) + hass.states.async_set( + entity_id, + HVAC_MODE_OFF, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + }, + ) await hass.async_block_till_done() with patch.object(hass.config.units, CONF_TEMPERATURE_UNIT, new=TEMP_FAHRENHEIT): - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) await acc.run_handler() await hass.async_block_till_done() @@ -533,6 +698,8 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): ATTR_TARGET_TEMP_LOW: 68.1, ATTR_TEMPERATURE: 71.6, ATTR_CURRENT_TEMPERATURE: 73.4, + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE, }, ) await hass.async_block_till_done() @@ -546,38 +713,73 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): # Set from HomeKit call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE, "set_temperature") - await hass.async_add_executor_job( - acc.char_cooling_thresh_temp.client_update_value, 23 + char_cooling_thresh_temp_iid = acc.char_cooling_thresh_temp.to_HAP()[HAP_REPR_IID] + char_heating_thresh_temp_iid = acc.char_heating_thresh_temp.to_HAP()[HAP_REPR_IID] + char_target_temp_iid = acc.char_target_temp.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_cooling_thresh_temp_iid, + HAP_REPR_VALUE: 23, + }, + ] + }, + "mock_addr", ) + await hass.async_block_till_done() assert call_set_temperature[0] assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.5 assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68 assert len(events) == 1 - assert events[-1].data[ATTR_VALUE] == "cooling threshold 73.5°F" + assert events[-1].data[ATTR_VALUE] == "CoolingThresholdTemperature to 23°C" - await hass.async_add_executor_job( - acc.char_heating_thresh_temp.client_update_value, 22 + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_heating_thresh_temp_iid, + HAP_REPR_VALUE: 22, + }, + ] + }, + "mock_addr", ) + await hass.async_block_till_done() assert call_set_temperature[1] assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.5 assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.5 assert len(events) == 2 - assert events[-1].data[ATTR_VALUE] == "heating threshold 71.5°F" + assert events[-1].data[ATTR_VALUE] == "HeatingThresholdTemperature to 22°C" - await hass.async_add_executor_job(acc.char_target_temp.client_update_value, 24.0) + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_temp_iid, + HAP_REPR_VALUE: 24.0, + }, + ] + }, + "mock_addr", + ) await hass.async_block_till_done() assert call_set_temperature[2] assert call_set_temperature[2].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.0 assert len(events) == 3 - assert events[-1].data[ATTR_VALUE] == "75.0°F" + assert events[-1].data[ATTR_VALUE] == "TargetTemperature to 24.0°C" -async def test_thermostat_get_temperature_range(hass, hk_driver, cls): +async def test_thermostat_get_temperature_range(hass, hk_driver, cls, driver): """Test if temperature range is evaluated correctly.""" entity_id = "climate.test" @@ -599,13 +801,15 @@ async def test_thermostat_get_temperature_range(hass, hk_driver, cls): assert acc.get_temperature_range() == (15.5, 21.0) -async def test_thermostat_temperature_step_whole(hass, hk_driver, cls): +async def test_thermostat_temperature_step_whole(hass, hk_driver, cls, driver): """Test climate device with single digit precision.""" entity_id = "climate.test" hass.states.async_set(entity_id, HVAC_MODE_OFF, {ATTR_TARGET_TEMP_STEP: 1}) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + await acc.run_handler() await hass.async_block_till_done() @@ -657,7 +861,7 @@ async def test_thermostat_restore(hass, hk_driver, cls, events): } -async def test_thermostat_hvac_modes(hass, hk_driver, cls): +async def test_thermostat_hvac_modes(hass, hk_driver, cls, driver): """Test if unsupported HVAC modes are deactivated in HomeKit.""" entity_id = "climate.test" @@ -666,7 +870,9 @@ async def test_thermostat_hvac_modes(hass, hk_driver, cls): ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + await acc.run_handler() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() @@ -688,13 +894,13 @@ async def test_thermostat_hvac_modes(hass, hk_driver, cls): assert acc.char_target_heat_cool.value == 1 -async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver, cls): +async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver, cls, driver): """Test we get heat cool over auto.""" entity_id = "climate.test" hass.states.async_set( entity_id, - HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, { ATTR_HVAC_MODES: [ HVAC_MODE_HEAT_COOL, @@ -707,12 +913,14 @@ async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver, cls): call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + await acc.run_handler() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [0, 1, 3] - assert acc.char_target_heat_cool.value == 3 + assert acc.char_target_heat_cool.value == 0 await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 3) await hass.async_block_till_done() @@ -727,7 +935,21 @@ async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver, cls): await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 - await hass.async_add_executor_job(acc.char_target_heat_cool.client_update_value, 3) + char_target_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: 3, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() assert call_set_hvac_mode assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id @@ -735,24 +957,28 @@ async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver, cls): assert acc.char_target_heat_cool.value == 3 -async def test_thermostat_hvac_modes_with_auto_no_heat_cool(hass, hk_driver, cls): +async def test_thermostat_hvac_modes_with_auto_no_heat_cool( + hass, hk_driver, cls, driver +): """Test we get auto when there is no heat cool.""" entity_id = "climate.test" hass.states.async_set( entity_id, - HVAC_MODE_HEAT_COOL, + HVAC_MODE_HEAT, {ATTR_HVAC_MODES: [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF]}, ) call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + await acc.run_handler() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [0, 1, 3] - assert acc.char_target_heat_cool.value == 3 + assert acc.char_target_heat_cool.value == 1 await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 3) await hass.async_block_till_done() @@ -767,7 +993,21 @@ async def test_thermostat_hvac_modes_with_auto_no_heat_cool(hass, hk_driver, cls await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 - await hass.async_add_executor_job(acc.char_target_heat_cool.client_update_value, 3) + char_target_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: 3, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() assert call_set_hvac_mode assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id @@ -775,7 +1015,7 @@ async def test_thermostat_hvac_modes_with_auto_no_heat_cool(hass, hk_driver, cls assert acc.char_target_heat_cool.value == 3 -async def test_thermostat_hvac_modes_with_auto_only(hass, hk_driver, cls): +async def test_thermostat_hvac_modes_with_auto_only(hass, hk_driver, cls, driver): """Test if unsupported HVAC modes are deactivated in HomeKit.""" entity_id = "climate.test" @@ -784,7 +1024,9 @@ async def test_thermostat_hvac_modes_with_auto_only(hass, hk_driver, cls): ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + await acc.run_handler() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() @@ -806,7 +1048,7 @@ async def test_thermostat_hvac_modes_with_auto_only(hass, hk_driver, cls): assert acc.char_target_heat_cool.value == 3 -async def test_thermostat_hvac_modes_without_off(hass, hk_driver, cls): +async def test_thermostat_hvac_modes_without_off(hass, hk_driver, cls, driver): """Test a thermostat that has no off.""" entity_id = "climate.test" @@ -815,7 +1057,9 @@ async def test_thermostat_hvac_modes_without_off(hass, hk_driver, cls): ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + await acc.run_handler() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() @@ -841,6 +1085,166 @@ async def test_thermostat_hvac_modes_without_off(hass, hk_driver, cls): assert acc.char_target_heat_cool.value == 1 +async def test_thermostat_without_target_temp_only_range( + hass, hk_driver, cls, events, driver +): + """Test a thermostat that only supports a range.""" + entity_id = "climate.test" + + # support_auto = True + hass.states.async_set( + entity_id, + HVAC_MODE_OFF, + {ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE}, + ) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == HC_MIN_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == HC_MIN_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + + hass.states.async_set( + entity_id, + HVAC_MODE_HEAT_COOL, + { + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT, + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE, + }, + ) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 20.0 + assert acc.char_cooling_thresh_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set( + entity_id, + HVAC_MODE_COOL, + { + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 24.0, + ATTR_HVAC_ACTION: CURRENT_HVAC_COOL, + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE, + }, + ) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_current_heat_cool.value == 2 + assert acc.char_target_heat_cool.value == 2 + assert acc.char_current_temp.value == 24.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set( + entity_id, + HVAC_MODE_COOL, + { + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE, + }, + ) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 2 + assert acc.char_current_temp.value == 21.0 + assert acc.char_display_units.value == 0 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE, "set_temperature") + + char_target_temp_iid = acc.char_target_temp.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_temp_iid, + HAP_REPR_VALUE: 17.0, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert call_set_temperature[0] + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 12.0 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 17.0 + assert acc.char_target_temp.value == 17.0 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == "CoolingThresholdTemperature to 17.0°C" + + hass.states.async_set( + entity_id, + HVAC_MODE_HEAT, + { + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE, + }, + ) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 1 + assert acc.char_current_temp.value == 21.0 + assert acc.char_display_units.value == 0 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE, "set_temperature") + + char_target_temp_iid = acc.char_target_temp.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_temp_iid, + HAP_REPR_VALUE: 27.0, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert call_set_temperature[0] + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 27.0 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 32.0 + assert acc.char_target_temp.value == 27.0 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == "HeatingThresholdTemperature to 27.0°C" + + async def test_water_heater(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = "water_heater.test" @@ -875,7 +1279,7 @@ async def test_water_heater(hass, hk_driver, cls, events): ) await hass.async_block_till_done() assert acc.char_target_temp.value == 56.0 - assert acc.char_current_temp.value == 56.0 + assert acc.char_current_temp.value == 50.0 assert acc.char_target_heat_cool.value == 1 assert acc.char_current_heat_cool.value == 1 assert acc.char_display_units.value == 0 @@ -929,7 +1333,7 @@ async def test_water_heater_fahrenheit(hass, hk_driver, cls, events): hass.states.async_set(entity_id, HVAC_MODE_HEAT, {ATTR_TEMPERATURE: 131}) await hass.async_block_till_done() assert acc.char_target_temp.value == 55.0 - assert acc.char_current_temp.value == 55.0 + assert acc.char_current_temp.value == 50 assert acc.char_display_units.value == 1 # Set from HomeKit