Fix fan support in nest, removing FAN_ONLY which isn't supported (#73422)

* Fix fan support in nest, removing FAN_ONLY which isn't supported

* Revert change to make supported features dynamic
This commit is contained in:
Allen Porter 2022-06-14 06:19:22 -07:00 committed by GitHub
parent e08465fe8c
commit 0b7a030bd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 119 additions and 113 deletions

View File

@ -70,6 +70,7 @@ FAN_MODE_MAP = {
"OFF": FAN_OFF, "OFF": FAN_OFF,
} }
FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()}
FAN_INV_MODES = list(FAN_INV_MODE_MAP)
MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API
MIN_TEMP = 10 MIN_TEMP = 10
@ -99,7 +100,7 @@ class ThermostatEntity(ClimateEntity):
"""Initialize ThermostatEntity.""" """Initialize ThermostatEntity."""
self._device = device self._device = device
self._device_info = NestDeviceInfo(device) self._device_info = NestDeviceInfo(device)
self._supported_features = 0 self._attr_supported_features = 0
@property @property
def should_poll(self) -> bool: def should_poll(self) -> bool:
@ -124,7 +125,7 @@ class ThermostatEntity(ClimateEntity):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Run when entity is added to register update signal handler.""" """Run when entity is added to register update signal handler."""
self._supported_features = self._get_supported_features() self._attr_supported_features = self._get_supported_features()
self.async_on_remove( self.async_on_remove(
self._device.add_update_listener(self.async_write_ha_state) self._device.add_update_listener(self.async_write_ha_state)
) )
@ -198,8 +199,6 @@ class ThermostatEntity(ClimateEntity):
trait = self._device.traits[ThermostatModeTrait.NAME] trait = self._device.traits[ThermostatModeTrait.NAME]
if trait.mode in THERMOSTAT_MODE_MAP: if trait.mode in THERMOSTAT_MODE_MAP:
hvac_mode = THERMOSTAT_MODE_MAP[trait.mode] hvac_mode = THERMOSTAT_MODE_MAP[trait.mode]
if hvac_mode == HVACMode.OFF and self.fan_mode == FAN_ON:
hvac_mode = HVACMode.FAN_ONLY
return hvac_mode return hvac_mode
@property @property
@ -209,8 +208,6 @@ class ThermostatEntity(ClimateEntity):
for mode in self._get_device_hvac_modes: for mode in self._get_device_hvac_modes:
if mode in THERMOSTAT_MODE_MAP: if mode in THERMOSTAT_MODE_MAP:
supported_modes.append(THERMOSTAT_MODE_MAP[mode]) supported_modes.append(THERMOSTAT_MODE_MAP[mode])
if self.supported_features & ClimateEntityFeature.FAN_MODE:
supported_modes.append(HVACMode.FAN_ONLY)
return supported_modes return supported_modes
@property @property
@ -252,7 +249,10 @@ class ThermostatEntity(ClimateEntity):
@property @property
def fan_mode(self) -> str: def fan_mode(self) -> str:
"""Return the current fan mode.""" """Return the current fan mode."""
if FanTrait.NAME in self._device.traits: if (
self.supported_features & ClimateEntityFeature.FAN_MODE
and FanTrait.NAME in self._device.traits
):
trait = self._device.traits[FanTrait.NAME] trait = self._device.traits[FanTrait.NAME]
return FAN_MODE_MAP.get(trait.timer_mode, FAN_OFF) return FAN_MODE_MAP.get(trait.timer_mode, FAN_OFF)
return FAN_OFF return FAN_OFF
@ -260,15 +260,12 @@ class ThermostatEntity(ClimateEntity):
@property @property
def fan_modes(self) -> list[str]: def fan_modes(self) -> list[str]:
"""Return the list of available fan modes.""" """Return the list of available fan modes."""
modes = [] if (
if FanTrait.NAME in self._device.traits: self.supported_features & ClimateEntityFeature.FAN_MODE
modes = list(FAN_INV_MODE_MAP) and FanTrait.NAME in self._device.traits
return modes ):
return FAN_INV_MODES
@property return []
def supported_features(self) -> int:
"""Bitmap of supported features."""
return self._supported_features
def _get_supported_features(self) -> int: def _get_supported_features(self) -> int:
"""Compute the bitmap of supported features from the current state.""" """Compute the bitmap of supported features from the current state."""
@ -290,10 +287,6 @@ class ThermostatEntity(ClimateEntity):
"""Set new target hvac mode.""" """Set new target hvac mode."""
if hvac_mode not in self.hvac_modes: if hvac_mode not in self.hvac_modes:
raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'") raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'")
if hvac_mode == HVACMode.FAN_ONLY:
# Turn the fan on but also turn off the hvac if it is on
await self.async_set_fan_mode(FAN_ON)
hvac_mode = HVACMode.OFF
api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode]
trait = self._device.traits[ThermostatModeTrait.NAME] trait = self._device.traits[ThermostatModeTrait.NAME]
try: try:
@ -338,6 +331,10 @@ class ThermostatEntity(ClimateEntity):
"""Set new target fan mode.""" """Set new target fan mode."""
if fan_mode not in self.fan_modes: if fan_mode not in self.fan_modes:
raise ValueError(f"Unsupported fan_mode '{fan_mode}'") raise ValueError(f"Unsupported fan_mode '{fan_mode}'")
if fan_mode == FAN_ON and self.hvac_mode == HVACMode.OFF:
raise ValueError(
"Cannot turn on fan, please set an HVAC mode (e.g. heat/cool) first"
)
trait = self._device.traits[FanTrait.NAME] trait = self._device.traits[FanTrait.NAME]
duration = None duration = None
if fan_mode != FAN_OFF: if fan_mode != FAN_OFF:

View File

@ -33,15 +33,15 @@ from homeassistant.components.climate.const import (
FAN_ON, FAN_ON,
HVAC_MODE_COOL, HVAC_MODE_COOL,
HVAC_MODE_DRY, HVAC_MODE_DRY,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_HEAT, HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL, HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF, HVAC_MODE_OFF,
PRESET_ECO, PRESET_ECO,
PRESET_NONE, PRESET_NONE,
PRESET_SLEEP, PRESET_SLEEP,
ClimateEntityFeature,
) )
from homeassistant.const import ATTR_TEMPERATURE from homeassistant.const import ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@ -794,7 +794,7 @@ async def test_thermostat_fan_off(
"sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatHvac": {"status": "OFF"},
"sdm.devices.traits.ThermostatMode": { "sdm.devices.traits.ThermostatMode": {
"availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"],
"mode": "OFF", "mode": "COOL",
}, },
"sdm.devices.traits.Temperature": { "sdm.devices.traits.Temperature": {
"ambientTemperatureCelsius": 16.2, "ambientTemperatureCelsius": 16.2,
@ -806,18 +806,22 @@ async def test_thermostat_fan_off(
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 1
thermostat = hass.states.get("climate.my_thermostat") thermostat = hass.states.get("climate.my_thermostat")
assert thermostat is not None assert thermostat is not None
assert thermostat.state == HVAC_MODE_OFF assert thermostat.state == HVAC_MODE_COOL
assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2
assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { assert set(thermostat.attributes[ATTR_HVAC_MODES]) == {
HVAC_MODE_HEAT, HVAC_MODE_HEAT,
HVAC_MODE_COOL, HVAC_MODE_COOL,
HVAC_MODE_HEAT_COOL, HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_OFF, HVAC_MODE_OFF,
} }
assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF
assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF]
assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.FAN_MODE
)
async def test_thermostat_fan_on( async def test_thermostat_fan_on(
@ -837,7 +841,7 @@ async def test_thermostat_fan_on(
}, },
"sdm.devices.traits.ThermostatMode": { "sdm.devices.traits.ThermostatMode": {
"availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"],
"mode": "OFF", "mode": "COOL",
}, },
"sdm.devices.traits.Temperature": { "sdm.devices.traits.Temperature": {
"ambientTemperatureCelsius": 16.2, "ambientTemperatureCelsius": 16.2,
@ -849,18 +853,22 @@ async def test_thermostat_fan_on(
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 1
thermostat = hass.states.get("climate.my_thermostat") thermostat = hass.states.get("climate.my_thermostat")
assert thermostat is not None assert thermostat is not None
assert thermostat.state == HVAC_MODE_FAN_ONLY assert thermostat.state == HVAC_MODE_COOL
assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2
assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { assert set(thermostat.attributes[ATTR_HVAC_MODES]) == {
HVAC_MODE_HEAT, HVAC_MODE_HEAT,
HVAC_MODE_COOL, HVAC_MODE_COOL,
HVAC_MODE_HEAT_COOL, HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_OFF, HVAC_MODE_OFF,
} }
assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON
assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF]
assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.FAN_MODE
)
async def test_thermostat_cool_with_fan( async def test_thermostat_cool_with_fan(
@ -895,11 +903,15 @@ async def test_thermostat_cool_with_fan(
HVAC_MODE_HEAT, HVAC_MODE_HEAT,
HVAC_MODE_COOL, HVAC_MODE_COOL,
HVAC_MODE_HEAT_COOL, HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_OFF, HVAC_MODE_OFF,
} }
assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON
assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF]
assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.FAN_MODE
)
async def test_thermostat_set_fan( async def test_thermostat_set_fan(
@ -907,6 +919,68 @@ async def test_thermostat_set_fan(
setup_platform: PlatformSetup, setup_platform: PlatformSetup,
auth: FakeAuth, auth: FakeAuth,
create_device: CreateDevice, create_device: CreateDevice,
) -> None:
"""Test a thermostat enabling the fan."""
create_device.create(
{
"sdm.devices.traits.Fan": {
"timerMode": "ON",
"timerTimeout": "2019-05-10T03:22:54Z",
},
"sdm.devices.traits.ThermostatHvac": {
"status": "OFF",
},
"sdm.devices.traits.ThermostatMode": {
"availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"],
"mode": "HEAT",
},
}
)
await setup_platform()
assert len(hass.states.async_all()) == 1
thermostat = hass.states.get("climate.my_thermostat")
assert thermostat is not None
assert thermostat.state == HVAC_MODE_HEAT
assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON
assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF]
assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.FAN_MODE
)
# Turn off fan mode
await common.async_set_fan_mode(hass, FAN_OFF)
await hass.async_block_till_done()
assert auth.method == "post"
assert auth.url == DEVICE_COMMAND
assert auth.json == {
"command": "sdm.devices.commands.Fan.SetTimer",
"params": {"timerMode": "OFF"},
}
# Turn on fan mode
await common.async_set_fan_mode(hass, FAN_ON)
await hass.async_block_till_done()
assert auth.method == "post"
assert auth.url == DEVICE_COMMAND
assert auth.json == {
"command": "sdm.devices.commands.Fan.SetTimer",
"params": {
"duration": "43200s",
"timerMode": "ON",
},
}
async def test_thermostat_set_fan_when_off(
hass: HomeAssistant,
setup_platform: PlatformSetup,
auth: FakeAuth,
create_device: CreateDevice,
) -> None: ) -> None:
"""Test a thermostat enabling the fan.""" """Test a thermostat enabling the fan."""
create_device.create( create_device.create(
@ -929,34 +1003,18 @@ async def test_thermostat_set_fan(
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 1
thermostat = hass.states.get("climate.my_thermostat") thermostat = hass.states.get("climate.my_thermostat")
assert thermostat is not None assert thermostat is not None
assert thermostat.state == HVAC_MODE_FAN_ONLY assert thermostat.state == HVAC_MODE_OFF
assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON
assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF]
assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.FAN_MODE
)
# Turn off fan mode # Fan cannot be turned on when HVAC is off
await common.async_set_fan_mode(hass, FAN_OFF) with pytest.raises(ValueError):
await hass.async_block_till_done() await common.async_set_fan_mode(hass, FAN_ON, entity_id="climate.my_thermostat")
assert auth.method == "post"
assert auth.url == DEVICE_COMMAND
assert auth.json == {
"command": "sdm.devices.commands.Fan.SetTimer",
"params": {"timerMode": "OFF"},
}
# Turn on fan mode
await common.async_set_fan_mode(hass, FAN_ON)
await hass.async_block_till_done()
assert auth.method == "post"
assert auth.url == DEVICE_COMMAND
assert auth.json == {
"command": "sdm.devices.commands.Fan.SetTimer",
"params": {
"duration": "43200s",
"timerMode": "ON",
},
}
async def test_thermostat_fan_empty( async def test_thermostat_fan_empty(
@ -994,6 +1052,10 @@ async def test_thermostat_fan_empty(
} }
assert ATTR_FAN_MODE not in thermostat.attributes assert ATTR_FAN_MODE not in thermostat.attributes
assert ATTR_FAN_MODES not in thermostat.attributes assert ATTR_FAN_MODES not in thermostat.attributes
assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
# Ignores set_fan_mode since it is lacking SUPPORT_FAN_MODE # Ignores set_fan_mode since it is lacking SUPPORT_FAN_MODE
await common.async_set_fan_mode(hass, FAN_ON) await common.async_set_fan_mode(hass, FAN_ON)
@ -1018,7 +1080,7 @@ async def test_thermostat_invalid_fan_mode(
"sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatHvac": {"status": "OFF"},
"sdm.devices.traits.ThermostatMode": { "sdm.devices.traits.ThermostatMode": {
"availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"],
"mode": "OFF", "mode": "COOL",
}, },
"sdm.devices.traits.Temperature": { "sdm.devices.traits.Temperature": {
"ambientTemperatureCelsius": 16.2, "ambientTemperatureCelsius": 16.2,
@ -1030,14 +1092,13 @@ async def test_thermostat_invalid_fan_mode(
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 1
thermostat = hass.states.get("climate.my_thermostat") thermostat = hass.states.get("climate.my_thermostat")
assert thermostat is not None assert thermostat is not None
assert thermostat.state == HVAC_MODE_FAN_ONLY assert thermostat.state == HVAC_MODE_COOL
assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2
assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { assert set(thermostat.attributes[ATTR_HVAC_MODES]) == {
HVAC_MODE_HEAT, HVAC_MODE_HEAT,
HVAC_MODE_COOL, HVAC_MODE_COOL,
HVAC_MODE_HEAT_COOL, HVAC_MODE_HEAT_COOL,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_OFF, HVAC_MODE_OFF,
} }
assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON
@ -1048,58 +1109,6 @@ async def test_thermostat_invalid_fan_mode(
await hass.async_block_till_done() await hass.async_block_till_done()
async def test_thermostat_set_hvac_fan_only(
hass: HomeAssistant,
setup_platform: PlatformSetup,
auth: FakeAuth,
create_device: CreateDevice,
) -> None:
"""Test a thermostat enabling the fan via hvac_mode."""
create_device.create(
{
"sdm.devices.traits.Fan": {
"timerMode": "OFF",
"timerTimeout": "2019-05-10T03:22:54Z",
},
"sdm.devices.traits.ThermostatHvac": {
"status": "OFF",
},
"sdm.devices.traits.ThermostatMode": {
"availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"],
"mode": "OFF",
},
}
)
await setup_platform()
assert len(hass.states.async_all()) == 1
thermostat = hass.states.get("climate.my_thermostat")
assert thermostat is not None
assert thermostat.state == HVAC_MODE_OFF
assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF
assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF]
await common.async_set_hvac_mode(hass, HVAC_MODE_FAN_ONLY)
await hass.async_block_till_done()
assert len(auth.captured_requests) == 2
(method, url, json, headers) = auth.captured_requests.pop(0)
assert method == "post"
assert url == DEVICE_COMMAND
assert json == {
"command": "sdm.devices.commands.Fan.SetTimer",
"params": {"duration": "43200s", "timerMode": "ON"},
}
(method, url, json, headers) = auth.captured_requests.pop(0)
assert method == "post"
assert url == DEVICE_COMMAND
assert json == {
"command": "sdm.devices.commands.ThermostatMode.SetMode",
"params": {"mode": "OFF"},
}
async def test_thermostat_target_temp( async def test_thermostat_target_temp(
hass: HomeAssistant, hass: HomeAssistant,
setup_platform: PlatformSetup, setup_platform: PlatformSetup,
@ -1397,7 +1406,7 @@ async def test_thermostat_hvac_mode_failure(
"sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatHvac": {"status": "OFF"},
"sdm.devices.traits.ThermostatMode": { "sdm.devices.traits.ThermostatMode": {
"availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"],
"mode": "OFF", "mode": "COOL",
}, },
"sdm.devices.traits.Fan": { "sdm.devices.traits.Fan": {
"timerMode": "OFF", "timerMode": "OFF",
@ -1416,8 +1425,8 @@ async def test_thermostat_hvac_mode_failure(
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 1
thermostat = hass.states.get("climate.my_thermostat") thermostat = hass.states.get("climate.my_thermostat")
assert thermostat is not None assert thermostat is not None
assert thermostat.state == HVAC_MODE_OFF assert thermostat.state == HVAC_MODE_COOL
assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)]
with pytest.raises(HomeAssistantError): with pytest.raises(HomeAssistantError):