Prevent HomeKit from going unavailable when min/max is reversed (#135892)

This commit is contained in:
J. Nick Koston 2025-01-17 14:08:17 -10:00 committed by GitHub
parent fc1b6292cd
commit 43fe4ebbbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 225 additions and 11 deletions

View File

@ -52,6 +52,7 @@ from .const import (
PROP_MIN_VALUE,
SERV_LIGHTBULB,
)
from .util import get_min_max
_LOGGER = logging.getLogger(__name__)
@ -120,12 +121,14 @@ class Light(HomeAccessory):
self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100)
if CHAR_COLOR_TEMPERATURE in self.chars:
self.min_mireds = color_temperature_kelvin_to_mired(
min_mireds = color_temperature_kelvin_to_mired(
attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, DEFAULT_MAX_COLOR_TEMP)
)
self.max_mireds = color_temperature_kelvin_to_mired(
max_mireds = color_temperature_kelvin_to_mired(
attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, DEFAULT_MIN_COLOR_TEMP)
)
# Ensure min is less than max
self.min_mireds, self.max_mireds = get_min_max(min_mireds, max_mireds)
if not self.color_temp_supported and not self.rgbww_supported:
self.max_mireds = self.min_mireds
self.char_color_temp = serv_light.configure_char(

View File

@ -14,6 +14,7 @@ from homeassistant.components.climate import (
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
ATTR_HVAC_MODES,
ATTR_MAX_HUMIDITY,
ATTR_MAX_TEMP,
ATTR_MIN_HUMIDITY,
ATTR_MIN_TEMP,
@ -21,6 +22,7 @@ from homeassistant.components.climate import (
ATTR_SWING_MODES,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
DEFAULT_MAX_HUMIDITY,
DEFAULT_MAX_TEMP,
DEFAULT_MIN_HUMIDITY,
DEFAULT_MIN_TEMP,
@ -90,7 +92,7 @@ from .const import (
SERV_FANV2,
SERV_THERMOSTAT,
)
from .util import temperature_to_homekit, temperature_to_states
from .util import get_min_max, temperature_to_homekit, temperature_to_states
_LOGGER = logging.getLogger(__name__)
@ -208,7 +210,10 @@ class Thermostat(HomeAccessory):
self.fan_chars: list[str] = []
attributes = state.attributes
min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY)
min_humidity, _ = get_min_max(
attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY),
attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY),
)
features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
@ -839,6 +844,9 @@ def _get_temperature_range_from_state(
else:
max_temp = default_max
# Handle reversed temperature range
min_temp, max_temp = get_min_max(min_temp, max_temp)
# Homekit only supports 10-38, overwriting
# the max to appears to work, but less than 0 causes
# a crash on the home app

View File

@ -656,3 +656,14 @@ def state_changed_event_is_same_state(event: Event[EventStateChangedData]) -> bo
old_state = event_data["old_state"]
new_state = event_data["new_state"]
return bool(new_state and old_state and new_state.state == old_state.state)
def get_min_max(value1: float, value2: float) -> tuple[float, float]:
"""Return the minimum and maximum of two values.
HomeKit will go unavailable if the min and max are reversed
so we make sure the min is always the min and the max is always the max
as any mistakes made in integrations will cause the entire
bridge to go unavailable.
"""
return min(value1, value2), max(value1, value2)

View File

@ -807,6 +807,156 @@ async def test_light_invalid_values(
assert acc.char_saturation.value == 95
async def test_light_out_of_range_color_temp(hass: HomeAssistant, hk_driver) -> None:
"""Test light with an out of range color temp."""
entity_id = "light.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "hs",
ATTR_COLOR_TEMP_KELVIN: 2000,
ATTR_MAX_COLOR_TEMP_KELVIN: 4000,
ATTR_MIN_COLOR_TEMP_KELVIN: 3000,
ATTR_HS_COLOR: (-1, -1),
},
)
await hass.async_block_till_done()
acc = Light(hass, hk_driver, "Light", entity_id, 1, None)
hk_driver.add_accessory(acc)
assert acc.char_color_temp.value == 333
assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333
assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250
assert acc.char_hue.value == 31
assert acc.char_saturation.value == 95
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_MAX_COLOR_TEMP_KELVIN: 4000,
ATTR_MIN_COLOR_TEMP_KELVIN: 3000,
ATTR_COLOR_TEMP_KELVIN: -1,
},
)
await hass.async_block_till_done()
acc.run()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 16
assert acc.char_saturation.value == 100
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_MAX_COLOR_TEMP_KELVIN: 4000,
ATTR_MIN_COLOR_TEMP_KELVIN: 3000,
ATTR_COLOR_TEMP_KELVIN: sys.maxsize,
},
)
await hass.async_block_till_done()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 220
assert acc.char_saturation.value == 41
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_COLOR_TEMP_KELVIN: 2000,
},
)
await hass.async_block_till_done()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 220
assert acc.char_saturation.value == 41
async def test_reversed_color_temp_min_max(hass: HomeAssistant, hk_driver) -> None:
"""Test light with a reversed color temp min max."""
entity_id = "light.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "hs",
ATTR_COLOR_TEMP_KELVIN: 2000,
ATTR_MAX_COLOR_TEMP_KELVIN: 3000,
ATTR_MIN_COLOR_TEMP_KELVIN: 4000,
ATTR_HS_COLOR: (-1, -1),
},
)
await hass.async_block_till_done()
acc = Light(hass, hk_driver, "Light", entity_id, 1, None)
hk_driver.add_accessory(acc)
assert acc.char_color_temp.value == 333
assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333
assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250
assert acc.char_hue.value == 31
assert acc.char_saturation.value == 95
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_MAX_COLOR_TEMP_KELVIN: 4000,
ATTR_MIN_COLOR_TEMP_KELVIN: 3000,
ATTR_COLOR_TEMP_KELVIN: -1,
},
)
await hass.async_block_till_done()
acc.run()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 16
assert acc.char_saturation.value == 100
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_MAX_COLOR_TEMP_KELVIN: 4000,
ATTR_MIN_COLOR_TEMP_KELVIN: 3000,
ATTR_COLOR_TEMP_KELVIN: sys.maxsize,
},
)
await hass.async_block_till_done()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 220
assert acc.char_saturation.value == 41
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_COLOR_TEMP_KELVIN: 2000,
},
)
await hass.async_block_till_done()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 220
assert acc.char_saturation.value == 41
@pytest.mark.parametrize(
"supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]]
)

View File

@ -26,6 +26,7 @@ from homeassistant.components.climate import (
ATTR_TARGET_TEMP_STEP,
DEFAULT_MAX_TEMP,
DEFAULT_MIN_HUMIDITY,
DEFAULT_MIN_TEMP,
DOMAIN as DOMAIN_CLIMATE,
FAN_AUTO,
FAN_HIGH,
@ -2009,8 +2010,8 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No
ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE,
ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO],
ATTR_MAX_TEMP: 50,
ATTR_MIN_TEMP: 100,
ATTR_MAX_TEMP: 100,
ATTR_MIN_TEMP: 50,
}
hass.states.async_set(
entity_id,
@ -2024,14 +2025,14 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No
acc.run()
await hass.async_block_till_done()
assert acc.char_cooling_thresh_temp.value == 100
assert acc.char_heating_thresh_temp.value == 100
assert acc.char_cooling_thresh_temp.value == 50
assert acc.char_heating_thresh_temp.value == 50
assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == 100
assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 100
assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 50
assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1
assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == 100
assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100
assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 50
assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1
assert acc.char_target_heat_cool.value == 3
@ -2048,7 +2049,7 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No
},
)
await hass.async_block_till_done()
assert acc.char_heating_thresh_temp.value == 100.0
assert acc.char_heating_thresh_temp.value == 50.0
assert acc.char_cooling_thresh_temp.value == 100.0
assert acc.char_current_heat_cool.value == 1
assert acc.char_target_heat_cool.value == 3
@ -2633,3 +2634,44 @@ async def test_thermostat_handles_unknown_state(hass: HomeAssistant, hk_driver)
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] == HVACMode.HEAT
async def test_thermostat_reversed_min_max(hass: HomeAssistant, hk_driver) -> None:
"""Test reversed min/max temperatures."""
entity_id = "climate.test"
base_attrs = {
ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE,
ATTR_HVAC_MODES: [
HVACMode.HEAT,
HVACMode.HEAT_COOL,
HVACMode.FAN_ONLY,
HVACMode.COOL,
HVACMode.OFF,
HVACMode.AUTO,
],
ATTR_MAX_TEMP: DEFAULT_MAX_TEMP,
ATTR_MIN_TEMP: DEFAULT_MIN_TEMP,
}
# support_auto = True
hass.states.async_set(
entity_id,
HVACMode.OFF,
base_attrs,
)
await hass.async_block_till_done()
acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None)
hk_driver.add_accessory(acc)
acc.run()
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