Fix ZHA climate hvac_action for Centralite thermostat (#35993)

* Centralite specific control seq of operation

* Remove Fan safeguards

* Split hvac_action property.

* Refactor hvac_action property.

Current hvac_action logic is Zen Within thermostat specific and differs
a bit from ZCL specs. Implement it as a separate class.

* Refactor hvac_action property for default thermostat

Follow more closely ZCL specs in parsing hvac state of the thermostat.
This commit is contained in:
Alexei Chetroi 2020-05-23 06:22:36 -04:00 committed by GitHub
parent 3d253fa52a
commit 04cfd36d06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 144 additions and 46 deletions

View File

@ -103,6 +103,8 @@ SEQ_OF_OPERATION = {
0x04: (HVAC_MODE_OFF, HVAC_MODE_HEAT_COOL, HVAC_MODE_COOL, HVAC_MODE_HEAT),
# cooling and heating 4-pipes
0x05: (HVAC_MODE_OFF, HVAC_MODE_HEAT_COOL, HVAC_MODE_COOL, HVAC_MODE_HEAT),
0x06: (HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF), # centralite specific
0x07: (HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF), # centralite specific
}
@ -234,24 +236,39 @@ class Thermostat(ZhaEntity, ClimateEntity):
self._thrm.pi_heating_demand is None
and self._thrm.pi_cooling_demand is None
):
running_state = self._thrm.running_state
if running_state is None:
return None
if running_state & (RunningState.HEAT | RunningState.HEAT_STAGE_2):
return CURRENT_HVAC_HEAT
if running_state & (RunningState.COOL | RunningState.COOL_STAGE_2):
return CURRENT_HVAC_COOL
if running_state & (
RunningState.FAN | RunningState.FAN_STAGE_2 | RunningState.FAN_STAGE_3
):
return CURRENT_HVAC_FAN
else:
heating_demand = self._thrm.pi_heating_demand
if heating_demand is not None and heating_demand > 0:
return CURRENT_HVAC_HEAT
cooling_demand = self._thrm.pi_cooling_demand
if cooling_demand is not None and cooling_demand > 0:
return CURRENT_HVAC_COOL
return self._rm_rs_action
return self._pi_demand_action
@property
def _rm_rs_action(self) -> Optional[str]:
"""Return the current HVAC action based on running mode and running state."""
running_mode = self._thrm.running_mode
if running_mode == SystemMode.HEAT:
return CURRENT_HVAC_HEAT
if running_mode == SystemMode.COOL:
return CURRENT_HVAC_COOL
running_state = self._thrm.running_state
if running_state and running_state & (
RunningState.FAN | RunningState.FAN_STAGE_2 | RunningState.FAN_STAGE_3
):
return CURRENT_HVAC_FAN
if self.hvac_mode != HVAC_MODE_OFF and running_mode == SystemMode.OFF:
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_OFF
@property
def _pi_demand_action(self) -> Optional[str]:
"""Return the current HVAC action based on pi_demands."""
heating_demand = self._thrm.pi_heating_demand
if heating_demand is not None and heating_demand > 0:
return CURRENT_HVAC_HEAT
cooling_demand = self._thrm.pi_cooling_demand
if cooling_demand is not None and cooling_demand > 0:
return CURRENT_HVAC_COOL
if self.hvac_mode != HVAC_MODE_OFF:
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_OFF
@ -389,14 +406,11 @@ class Thermostat(ZhaEntity, ClimateEntity):
if occupancy is True:
self._preset = PRESET_NONE
self.debug("Attribute '%s' = %s update", record.attr_name, record.value)
self.async_write_ha_state()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
if self.fan_modes is None:
self.warning("Fan is not supported")
return
if fan_mode not in self.fan_modes:
self.warning("Unsupported '%s' fan mode", fan_mode)
return
@ -540,3 +554,32 @@ class SinopeTechnologiesThermostat(Thermostat):
self.debug("set occupancy to %s. Status: %s", 0 if is_away else 1, res)
return res
@STRICT_MATCH(
channel_names=CHANNEL_THERMOSTAT,
aux_channels=CHANNEL_FAN,
manufacturers="Zen Within",
)
class ZenWithinThermostat(Thermostat):
"""Zen Within Thermostat implementation."""
@property
def _rm_rs_action(self) -> Optional[str]:
"""Return the current HVAC action based on running mode and running state."""
running_state = self._thrm.running_state
if running_state is None:
return None
if running_state & (RunningState.HEAT | RunningState.HEAT_STAGE_2):
return CURRENT_HVAC_HEAT
if running_state & (RunningState.COOL | RunningState.COOL_STAGE_2):
return CURRENT_HVAC_COOL
if running_state & (
RunningState.FAN | RunningState.FAN_STAGE_2 | RunningState.FAN_STAGE_3
):
return CURRENT_HVAC_FAN
if self.hvac_mode != HVAC_MODE_OFF:
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_OFF

View File

@ -89,7 +89,22 @@ CLIMATE_SINOPE = {
"profile_id": 260,
},
}
SINOPE = "Sinope Technologies"
CLIMATE_ZEN = {
1: {
"device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT,
"in_clusters": [
zigpy.zcl.clusters.general.Basic.cluster_id,
zigpy.zcl.clusters.general.Identify.cluster_id,
zigpy.zcl.clusters.hvac.Fan.cluster_id,
zigpy.zcl.clusters.hvac.Thermostat.cluster_id,
zigpy.zcl.clusters.hvac.UserInterface.cluster_id,
],
"out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id],
}
}
MANUF_SINOPE = "Sinope Technologies"
MANUF_ZEN = "Zen Within"
ZCL_ATTR_PLUG = {
"abs_min_heat_setpoint_limit": 800,
@ -169,7 +184,14 @@ async def device_climate_fan(device_climate_mock):
async def device_climate_sinope(device_climate_mock):
"""Sinope thermostat."""
return await device_climate_mock(CLIMATE_SINOPE, manuf=SINOPE)
return await device_climate_mock(CLIMATE_SINOPE, manuf=MANUF_SINOPE)
@pytest.fixture
async def device_climate_zen(device_climate_mock):
"""Zen Within thermostat."""
return await device_climate_mock(CLIMATE_ZEN, manuf=MANUF_ZEN)
def test_sequence_mappings():
@ -201,6 +223,52 @@ async def test_climate_hvac_action_running_state(hass, device_climate):
thrm_cluster = device_climate.device.endpoints[1].thermostat
entity_id = await find_entity_id(DOMAIN, device_climate, hass)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF
await send_attributes_report(
hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Off}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF
await send_attributes_report(
hass, thrm_cluster, {0x001C: Thermostat.SystemMode.Auto}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
await send_attributes_report(
hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Cool}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL
await send_attributes_report(
hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Heat}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT
await send_attributes_report(
hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Off}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_State_On}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN
async def test_climate_hvac_action_running_state_zen(hass, device_climate_zen):
"""Test Zen hvac action via running state."""
thrm_cluster = device_climate_zen.device.endpoints[1].thermostat
entity_id = await find_entity_id(DOMAIN, device_climate_zen, hass)
state = hass.states.get(entity_id)
assert ATTR_HVAC_ACTION not in state.attributes
@ -266,7 +334,7 @@ async def test_climate_hvac_action_pi_demand(hass, device_climate):
entity_id = await find_entity_id(DOMAIN, device_climate, hass)
state = hass.states.get(entity_id)
assert ATTR_HVAC_ACTION not in state.attributes
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF
await send_attributes_report(hass, thrm_cluster, {0x0007: 10})
state = hass.states.get(entity_id)
@ -381,7 +449,7 @@ async def test_target_temperature(
"unoccupied_heating_setpoint": 1600,
"unoccupied_cooling_setpoint": 2700,
},
manuf=SINOPE,
manuf=MANUF_SINOPE,
)
entity_id = await find_entity_id(DOMAIN, device_climate, hass)
if preset:
@ -417,7 +485,7 @@ async def test_target_temperature_high(
"system_mode": Thermostat.SystemMode.Auto,
"unoccupied_cooling_setpoint": unoccupied,
},
manuf=SINOPE,
manuf=MANUF_SINOPE,
)
entity_id = await find_entity_id(DOMAIN, device_climate, hass)
if preset:
@ -453,7 +521,7 @@ async def test_target_temperature_low(
"system_mode": Thermostat.SystemMode.Auto,
"unoccupied_heating_setpoint": unoccupied,
},
manuf=SINOPE,
manuf=MANUF_SINOPE,
)
entity_id = await find_entity_id(DOMAIN, device_climate, hass)
if preset:
@ -665,7 +733,7 @@ async def test_set_temperature_heat_cool(hass, device_climate_mock):
"unoccupied_heating_setpoint": 1600,
"unoccupied_cooling_setpoint": 2700,
},
manuf=SINOPE,
manuf=MANUF_SINOPE,
)
entity_id = await find_entity_id(DOMAIN, device_climate, hass)
thrm_cluster = device_climate.device.endpoints[1].thermostat
@ -755,7 +823,7 @@ async def test_set_temperature_heat(hass, device_climate_mock):
"unoccupied_heating_setpoint": 1600,
"unoccupied_cooling_setpoint": 2700,
},
manuf=SINOPE,
manuf=MANUF_SINOPE,
)
entity_id = await find_entity_id(DOMAIN, device_climate, hass)
thrm_cluster = device_climate.device.endpoints[1].thermostat
@ -838,7 +906,7 @@ async def test_set_temperature_cool(hass, device_climate_mock):
"unoccupied_cooling_setpoint": 1600,
"unoccupied_heating_setpoint": 2700,
},
manuf=SINOPE,
manuf=MANUF_SINOPE,
)
entity_id = await find_entity_id(DOMAIN, device_climate, hass)
thrm_cluster = device_climate.device.endpoints[1].thermostat
@ -921,7 +989,7 @@ async def test_set_temperature_wrong_mode(hass, device_climate_mock):
"unoccupied_cooling_setpoint": 1600,
"unoccupied_heating_setpoint": 2700,
},
manuf=SINOPE,
manuf=MANUF_SINOPE,
)
entity_id = await find_entity_id(DOMAIN, device_climate, hass)
thrm_cluster = device_climate.device.endpoints[1].thermostat
@ -1000,19 +1068,6 @@ async def test_fan_mode(hass, device_climate_fan):
assert state.attributes[ATTR_FAN_MODE] == FAN_ON
async def test_set_fan_mode_no_fan(hass, device_climate):
"""Test setting fan mode on fun less climate."""
entity_id = await find_entity_id(DOMAIN, device_climate, hass)
await hass.services.async_call(
DOMAIN,
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_ON},
blocking=True,
)
async def test_set_fan_mode_not_supported(hass, device_climate_fan):
"""Test fan setting unsupported mode."""

View File

@ -3349,7 +3349,7 @@ DEVICES = [
},
("climate", "00:11:22:33:44:55:66:77-1"): {
"channels": ["thermostat", "fan"],
"entity_class": "Thermostat",
"entity_class": "ZenWithinThermostat",
"entity_id": "climate.zen_within_zen_01_77665544_fan_thermostat",
},
},