Fix handling homekit thermostat states (#34905)

This commit is contained in:
J. Nick Koston 2020-04-30 02:09:33 -05:00 committed by GitHub
parent fcd58b7c9b
commit e01ceb1a57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 319 additions and 58 deletions

View file

@ -117,12 +117,15 @@ class Thermostat(HomeAccessory):
"""Initialize a Thermostat accessory object."""
super().__init__(*args, category=CATEGORY_THERMOSTAT)
self._unit = self.hass.config.units.temperature_unit
self._state_updates = 0
self.hc_homekit_to_hass = None
self.hc_hass_to_homekit = None
min_temp, max_temp = self.get_temperature_range()
# Homekit only supports 10-38, overwriting
# the max to appears to work, but less than 10 causes
# the max to appears to work, but less than 0 causes
# a crash on the home app
hc_min_temp = max(min_temp, HC_MIN_TEMP)
hc_min_temp = max(min_temp, 0)
hc_max_temp = max_temp
min_humidity = self.hass.states.get(self.entity_id).attributes.get(
@ -149,48 +152,17 @@ class Thermostat(HomeAccessory):
CHAR_CURRENT_HEATING_COOLING, value=0
)
# Target mode characteristics
hc_modes = state.attributes.get(ATTR_HVAC_MODES)
if hc_modes is None:
_LOGGER.error(
"%s: HVAC modes not yet available. Please disable auto start for homekit.",
self.entity_id,
)
hc_modes = (
HVAC_MODE_HEAT,
HVAC_MODE_COOL,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF,
)
# Determine available modes for this entity,
# Prefer HEAT_COOL over AUTO and COOL over FAN_ONLY, DRY
#
# HEAT_COOL is preferred over auto because HomeKit Accessory Protocol describes
# heating or cooling comes on to maintain a target temp which is closest to
# the Home Assistant spec
#
# HVAC_MODE_HEAT_COOL: The device supports heating/cooling to a range
self.hc_homekit_to_hass = {
c: s
for s, c in HC_HASS_TO_HOMEKIT.items()
if (
s in hc_modes
and not (
(s == HVAC_MODE_AUTO and HVAC_MODE_HEAT_COOL in hc_modes)
or (
s in (HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY)
and HVAC_MODE_COOL in hc_modes
)
)
)
}
hc_valid_values = {k: v for v, k in self.hc_homekit_to_hass.items()}
self._configure_hvac_modes(state)
# Must set the value first as setting
# valid_values happens before setting
# the value and if 0 is not a valid
# value this will throw
self.char_target_heat_cool = serv_thermostat.configure_char(
CHAR_TARGET_HEATING_COOLING, valid_values=hc_valid_values,
CHAR_TARGET_HEATING_COOLING, value=list(self.hc_homekit_to_hass)[0]
)
self.char_target_heat_cool.override_properties(
valid_values=self.hc_hass_to_homekit
)
# Current and target temperature characteristics
self.char_current_temp = serv_thermostat.configure_char(
@ -249,7 +221,7 @@ class Thermostat(HomeAccessory):
CHAR_CURRENT_HUMIDITY, value=50
)
self.update_state(state)
self._update_state(state)
serv_thermostat.setter_callback = self._set_chars
@ -356,6 +328,46 @@ class Thermostat(HomeAccessory):
if CHAR_TARGET_HUMIDITY in char_values:
self.set_target_humidity(char_values[CHAR_TARGET_HUMIDITY])
def _configure_hvac_modes(self, state):
"""Configure target mode characteristics."""
hc_modes = state.attributes.get(ATTR_HVAC_MODES)
if not hc_modes:
# This cannot be none OR an empty list
_LOGGER.error(
"%s: HVAC modes not yet available. Please disable auto start for homekit.",
self.entity_id,
)
hc_modes = (
HVAC_MODE_HEAT,
HVAC_MODE_COOL,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF,
)
# Determine available modes for this entity,
# Prefer HEAT_COOL over AUTO and COOL over FAN_ONLY, DRY
#
# HEAT_COOL is preferred over auto because HomeKit Accessory Protocol describes
# heating or cooling comes on to maintain a target temp which is closest to
# the Home Assistant spec
#
# HVAC_MODE_HEAT_COOL: The device supports heating/cooling to a range
self.hc_homekit_to_hass = {
c: s
for s, c in HC_HASS_TO_HOMEKIT.items()
if (
s in hc_modes
and not (
(s == HVAC_MODE_AUTO and HVAC_MODE_HEAT_COOL in hc_modes)
or (
s in (HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY)
and HVAC_MODE_COOL in hc_modes
)
)
)
}
self.hc_hass_to_homekit = {k: v for v, k in self.hc_homekit_to_hass.items()}
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)
@ -382,14 +394,46 @@ class Thermostat(HomeAccessory):
def update_state(self, new_state):
"""Update thermostat state after state changed."""
if self._state_updates < 3:
# When we get the first state updates
# we recheck valid hvac modes as the entity
# may not have been fully setup when we saw it the
# first time
original_hc_hass_to_homekit = self.hc_hass_to_homekit
self._configure_hvac_modes(new_state)
if self.hc_hass_to_homekit != original_hc_hass_to_homekit:
if self.char_target_heat_cool.value not in self.hc_homekit_to_hass:
# We must make sure the char value is
# in the new valid values before
# setting the new valid values or
# changing them with throw
self.char_target_heat_cool.set_value(
list(self.hc_homekit_to_hass)[0], should_notify=False
)
self.char_target_heat_cool.override_properties(
valid_values=self.hc_hass_to_homekit
)
self._state_updates += 1
self._update_state(new_state)
def _update_state(self, new_state):
"""Update state without rechecking the device features."""
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)
if homekit_hvac_mode in self.hc_homekit_to_hass:
if self.char_target_heat_cool.value != homekit_hvac_mode:
self.char_target_heat_cool.set_value(homekit_hvac_mode)
else:
_LOGGER.error(
"Cannot map hvac target mode: %s to homekit as only %s modes are supported",
hvac_mode,
self.hc_homekit_to_hass,
)
# Set current operation mode for supported thermostats
hvac_action = new_state.attributes.get(ATTR_HVAC_ACTION)
@ -444,13 +488,13 @@ class Thermostat(HomeAccessory):
# 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)
)
temp_low = new_state.attributes.get(ATTR_TARGET_TEMP_LOW)
if isinstance(temp_low, (int, float)):
target_temp = self._temperature_to_homekit(temp_low)
elif hc_hvac_mode == HC_HEAT_COOL_COOL:
target_temp = self._temperature_to_homekit(
new_state.attributes.get(ATTR_TARGET_TEMP_HIGH)
)
temp_high = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH)
if isinstance(temp_high, (int, float)):
target_temp = self._temperature_to_homekit(temp_high)
if target_temp and self.char_target_temp.value != target_temp:
self.char_target_temp.set_value(target_temp)

View file

@ -43,7 +43,6 @@ 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,
@ -116,7 +115,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] == HC_MIN_TEMP
assert acc.char_target_temp.properties[PROP_MIN_VALUE] == 7.0
assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.1
hass.states.async_set(
@ -126,6 +125,14 @@ async def test_thermostat(hass, hk_driver, cls, events):
ATTR_TEMPERATURE: 22.2,
ATTR_CURRENT_TEMPERATURE: 17.8,
ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -142,6 +149,14 @@ async def test_thermostat(hass, hk_driver, cls, events):
ATTR_TEMPERATURE: 22.0,
ATTR_CURRENT_TEMPERATURE: 23.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -158,6 +173,14 @@ async def test_thermostat(hass, hk_driver, cls, events):
ATTR_TEMPERATURE: 20.0,
ATTR_CURRENT_TEMPERATURE: 25.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_COOL,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -202,6 +225,14 @@ async def test_thermostat(hass, hk_driver, cls, events):
ATTR_TEMPERATURE: 22.0,
ATTR_CURRENT_TEMPERATURE: 18.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -218,6 +249,14 @@ async def test_thermostat(hass, hk_driver, cls, events):
ATTR_TEMPERATURE: 22.0,
ATTR_CURRENT_TEMPERATURE: 25.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_COOL,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -234,6 +273,14 @@ async def test_thermostat(hass, hk_driver, cls, events):
ATTR_TEMPERATURE: 22.0,
ATTR_CURRENT_TEMPERATURE: 22.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -250,6 +297,14 @@ async def test_thermostat(hass, hk_driver, cls, events):
ATTR_TEMPERATURE: 22.0,
ATTR_CURRENT_TEMPERATURE: 22.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_FAN,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -369,7 +424,15 @@ async def test_thermostat_auto(hass, hk_driver, cls, events):
HVAC_MODE_OFF,
{
ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE
| SUPPORT_TARGET_TEMPERATURE_RANGE
| SUPPORT_TARGET_TEMPERATURE_RANGE,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -383,10 +446,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] == HC_MIN_TEMP
assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0
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_VALUE] == 7.0
assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1
hass.states.async_set(
@ -397,6 +460,14 @@ async def test_thermostat_auto(hass, hk_driver, cls, events):
ATTR_TARGET_TEMP_LOW: 20.0,
ATTR_CURRENT_TEMPERATURE: 18.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -415,6 +486,14 @@ async def test_thermostat_auto(hass, hk_driver, cls, events):
ATTR_TARGET_TEMP_LOW: 19.0,
ATTR_CURRENT_TEMPERATURE: 24.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_COOL,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -433,6 +512,14 @@ async def test_thermostat_auto(hass, hk_driver, cls, events):
ATTR_TARGET_TEMP_LOW: 19.0,
ATTR_CURRENT_TEMPERATURE: 21.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -1094,10 +1181,10 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, e
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_VALUE] == 7.0
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_VALUE] == 7.0
assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1
hass.states.async_set(
@ -1109,6 +1196,14 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, e
ATTR_CURRENT_TEMPERATURE: 18.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT,
ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -1128,6 +1223,14 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, e
ATTR_CURRENT_TEMPERATURE: 24.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_COOL,
ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -1147,6 +1250,14 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, e
ATTR_CURRENT_TEMPERATURE: 21.0,
ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE,
ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE,
ATTR_HVAC_MODES: [
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_COOL,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
],
},
)
await hass.async_block_till_done()
@ -1400,3 +1511,109 @@ async def test_water_heater_restore(hass, hk_driver, cls, events):
"Heat",
"Off",
}
async def test_thermostat_with_no_modes_when_we_first_see(hass, hk_driver, cls, events):
"""Test if a thermostat that is not ready when we first see it."""
entity_id = "climate.test"
# support_auto = True
hass.states.async_set(
entity_id,
HVAC_MODE_OFF,
{
ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE
| SUPPORT_TARGET_TEMPERATURE_RANGE,
ATTR_HVAC_MODES: [],
},
)
await hass.async_block_till_done()
acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None)
hk_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] == 7.0
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] == 7.0
assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1
assert acc.char_target_heat_cool.value == 0
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_HVAC_MODES: [HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, HVAC_MODE_AUTO],
},
)
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
async def test_thermostat_with_no_off_after_recheck(hass, hk_driver, cls, events):
"""Test if a thermostat that is not ready when we first see it that actually does not have off."""
entity_id = "climate.test"
# support_auto = True
hass.states.async_set(
entity_id,
HVAC_MODE_COOL,
{
ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE
| SUPPORT_TARGET_TEMPERATURE_RANGE,
ATTR_HVAC_MODES: [],
},
)
await hass.async_block_till_done()
acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None)
hk_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] == 7.0
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] == 7.0
assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1
assert acc.char_target_heat_cool.value == 2
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_HVAC_MODES: [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO],
},
)
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