Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 112 additions & 29 deletions tests/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@
from zha.application.platforms.climate import (
HVAC_MODE_2_SYSTEM,
SEQ_OF_OPERATION,
SinopeTechnologiesThermostat as SinopeThermostatEntity,
Thermostat as ThermostatEntity,
ZehnderThermostat,
)
from zha.application.platforms.climate.const import FanState
from zha.application.platforms.climate.const import PRECISION_TENTHS, FanState
from zha.application.platforms.number import NumberConfigurationEntity
from zha.application.platforms.sensor import (
Sensor,
Expand Down Expand Up @@ -242,7 +243,7 @@ async def device_climate_mock(
"ep_attribute",
"sinope_manufacturer_specific",
)
async def device_climate_sinope(zha_gateway: Gateway):
async def device_climate_sinope(zha_gateway: Gateway) -> Device:
"""Sinope thermostat."""

return await device_climate_mock(
Expand All @@ -253,6 +254,18 @@ async def device_climate_sinope(zha_gateway: Gateway):
)


async def sinope_climate_entity(
zha_gateway: Gateway,
) -> tuple[Device, SinopeThermostatEntity]:
"""Create a Sinope thermostat and return its climate entity."""
device = await device_climate_sinope(zha_gateway)
entity = get_entity(
device, platform=Platform.CLIMATE, entity_type=SinopeThermostatEntity
)
assert isinstance(entity, SinopeThermostatEntity)
return device, entity


def test_sequence_mappings():
"""Test correct mapping between control sequence -> HVAC Mode -> Sysmode."""

Expand Down Expand Up @@ -372,14 +385,10 @@ async def test_sinope_time(
):
"""Test hvac action via running state."""

dev_climate_sinope = await device_climate_sinope(zha_gateway)
dev_climate_sinope, entity = await sinope_climate_entity(zha_gateway)
mfg_cluster = dev_climate_sinope.device.endpoints[1].sinope_manufacturer_specific
assert mfg_cluster is not None

entity: ThermostatEntity = get_entity(
dev_climate_sinope, platform=Platform.CLIMATE, entity_type=ThermostatEntity
)

entity._async_update_time = AsyncMock(wraps=entity._async_update_time)

await asyncio.sleep(4600)
Expand Down Expand Up @@ -432,6 +441,79 @@ async def test_sinope_time(
entity._async_update_time.reset_mock()


async def test_sinope_time_update_task_lifecycle_is_idempotent(
zha_gateway: Gateway,
) -> None:
"""Test polling is idempotent and restarts after cancellation completes."""
_, entity = await sinope_climate_entity(zha_gateway)
time_update_task = entity._time_update_task
assert time_update_task is not None
assert entity._tracked_tasks == [time_update_task]
assert time_update_task in zha_gateway._untracked_background_tasks

for _ in range(3):
entity.enable()
entity.start_polling()
assert entity._time_update_task is time_update_task
assert entity._tracked_tasks == [time_update_task]

entity.disable()
entity.start_polling()
assert not entity.enabled
assert entity._time_update_task is time_update_task
assert time_update_task.cancelling() == 1

entity.enable()
assert entity.enabled
assert entity._time_update_task is time_update_task

await asyncio.gather(time_update_task, return_exceptions=True)
await asyncio.sleep(0)

replacement_task = entity._time_update_task
assert replacement_task is not None
assert replacement_task is not time_update_task
assert time_update_task.done()
assert entity._tracked_tasks == [replacement_task]
assert replacement_task in zha_gateway._untracked_background_tasks
assert time_update_task not in zha_gateway._untracked_background_tasks

entity.disable()
await asyncio.gather(replacement_task, return_exceptions=True)
await asyncio.sleep(0)
assert replacement_task.done()
assert entity._time_update_task is None
assert not entity._tracked_tasks
assert replacement_task not in zha_gateway._untracked_background_tasks


async def test_sinope_removal_prevents_updater_restart(
zha_gateway: Gateway,
) -> None:
"""Test removal cancels the updater and prevents a pending restart."""
_, entity = await sinope_climate_entity(zha_gateway)
time_update_task = entity._time_update_task
assert time_update_task is not None

entity.disable()
entity.enable()
assert entity.enabled
assert entity._time_update_task is time_update_task

await entity.on_remove()
assert not entity.enabled
assert time_update_task.done()
assert entity._time_update_task is None
assert not entity._tracked_tasks
assert time_update_task not in zha_gateway._untracked_background_tasks

entity.enable()
entity.start_polling()
assert not entity.enabled
assert entity._time_update_task is None
assert not entity._tracked_tasks


async def test_climate_hvac_action_running_state_zen(
zha_gateway: Gateway,
):
Expand Down Expand Up @@ -1037,34 +1119,34 @@ async def test_set_temperature_heat_cool(
assert entity.state["target_temperature_high"] == 25.0
assert thrm_cluster.write_attributes.await_count == 0

await entity.async_set_temperature(target_temp_high=26, target_temp_low=19)
await entity.async_set_temperature(target_temp_high=20.4, target_temp_low=19.9)
await zha_gateway.async_block_till_done()

assert entity.state["target_temperature_low"] == 19.0
assert entity.state["target_temperature_high"] == 26.0
assert entity.state["target_temperature_low"] == 19.9
assert entity.state["target_temperature_high"] == 20.4
assert thrm_cluster.write_attributes.await_count == 2
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"occupied_heating_setpoint": 1900
"occupied_heating_setpoint": 1990
}
assert thrm_cluster.write_attributes.call_args_list[1][0][0] == {
"occupied_cooling_setpoint": 2600
"occupied_cooling_setpoint": 2040
}

await entity.async_set_preset_mode("away")
await zha_gateway.async_block_till_done()
thrm_cluster.write_attributes.reset_mock()

await entity.async_set_temperature(target_temp_high=30, target_temp_low=15)
await entity.async_set_temperature(target_temp_high=-19.9, target_temp_low=-20.4)
await zha_gateway.async_block_till_done()

assert entity.state["target_temperature_low"] == 15.0
assert entity.state["target_temperature_high"] == 30.0
assert entity.state["target_temperature_low"] == -20.4
assert entity.state["target_temperature_high"] == -19.9
assert thrm_cluster.write_attributes.await_count == 2
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"unoccupied_heating_setpoint": 1500
"unoccupied_heating_setpoint": -2040
}
assert thrm_cluster.write_attributes.call_args_list[1][0][0] == {
"unoccupied_cooling_setpoint": 3000
"unoccupied_cooling_setpoint": -1990
}


Expand All @@ -1090,6 +1172,7 @@ async def test_set_temperature_heat(
entity: ThermostatEntity = get_entity(
device_climate, platform=Platform.CLIMATE, entity_type=ThermostatEntity
)
assert entity._attr_precision == PRECISION_TENTHS

assert entity.state["hvac_mode"] == "heat"

Expand All @@ -1101,30 +1184,30 @@ async def test_set_temperature_heat(
assert entity.state["target_temperature"] == 20.0
assert thrm_cluster.write_attributes.await_count == 0

await entity.async_set_temperature(temperature=21)
await entity.async_set_temperature(temperature=19.9)
await zha_gateway.async_block_till_done()

assert entity.state["target_temperature_low"] is None
assert entity.state["target_temperature_high"] is None
assert entity.state["target_temperature"] == 21.0
assert entity.state["target_temperature"] == 19.9
assert thrm_cluster.write_attributes.await_count == 1
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"occupied_heating_setpoint": 2100
"occupied_heating_setpoint": 1990
}

await entity.async_set_preset_mode("away")
await zha_gateway.async_block_till_done()
thrm_cluster.write_attributes.reset_mock()

await entity.async_set_temperature(temperature=22)
await entity.async_set_temperature(temperature=-19.9)
await zha_gateway.async_block_till_done()

assert entity.state["target_temperature_low"] is None
assert entity.state["target_temperature_high"] is None
assert entity.state["target_temperature"] == 22.0
assert entity.state["target_temperature"] == -19.9
assert thrm_cluster.write_attributes.await_count == 1
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"unoccupied_heating_setpoint": 2200
"unoccupied_heating_setpoint": -1990
}


Expand Down Expand Up @@ -1161,30 +1244,30 @@ async def test_set_temperature_cool(
assert entity.state["target_temperature"] == 25.0
assert thrm_cluster.write_attributes.await_count == 0

await entity.async_set_temperature(temperature=21)
await entity.async_set_temperature(temperature=20.4)
await zha_gateway.async_block_till_done()

assert entity.state["target_temperature_low"] is None
assert entity.state["target_temperature_high"] is None
assert entity.state["target_temperature"] == 21.0
assert entity.state["target_temperature"] == 20.4
assert thrm_cluster.write_attributes.await_count == 1
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"occupied_cooling_setpoint": 2100
"occupied_cooling_setpoint": 2040
}

await entity.async_set_preset_mode("away")
await zha_gateway.async_block_till_done()
thrm_cluster.write_attributes.reset_mock()

await entity.async_set_temperature(temperature=22)
await entity.async_set_temperature(temperature=-20.4)
await zha_gateway.async_block_till_done()

assert entity.state["target_temperature_low"] is None
assert entity.state["target_temperature_high"] is None
assert entity.state["target_temperature"] == 22.0
assert entity.state["target_temperature"] == -20.4
assert thrm_cluster.write_attributes.await_count == 1
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"unoccupied_cooling_setpoint": 2200
"unoccupied_cooling_setpoint": -2040
}


Expand Down
40 changes: 31 additions & 9 deletions zha/application/platforms/climate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -881,23 +881,23 @@ async def async_set_temperature(
if self.hvac_mode == HVACMode.HEAT_COOL:
if target_temp_low is not None:
await self._async_set_heating_setpoint(
temperature=int(target_temp_low * ZCL_TEMP),
temperature=round(target_temp_low * ZCL_TEMP),
is_away=is_away,
)
if target_temp_high is not None:
await self._async_set_cooling_setpoint(
temperature=int(target_temp_high * ZCL_TEMP),
temperature=round(target_temp_high * ZCL_TEMP),
is_away=is_away,
)
elif temperature is not None:
if self.hvac_mode == HVACMode.COOL:
await self._async_set_cooling_setpoint(
temperature=int(temperature * ZCL_TEMP),
temperature=round(temperature * ZCL_TEMP),
is_away=is_away,
)
elif self.hvac_mode == HVACMode.HEAT:
await self._async_set_heating_setpoint(
temperature=int(temperature * ZCL_TEMP),
temperature=round(temperature * ZCL_TEMP),
is_away=is_away,
)
else:
Expand Down Expand Up @@ -945,7 +945,8 @@ def __init__(
self._sinope_cluster = endpoint.zigpy_endpoint.in_clusters[
SINOPE_MANUFACTURER_CLUSTER
]
self._time_update_task: Task | None = None
self._time_update_task: Task[Any] | None = None
self._time_update_removed = False

def recompute_capabilities(self) -> None:
"""Recompute capabilities and feature flags."""
Expand All @@ -957,32 +958,53 @@ def on_add(self) -> None:
super().on_add()
self.start_polling()

def _time_update_task_done(self, task: Task[Any]) -> None:
"""Release the updater task and restart it if the entity was re-enabled."""
self._tracked_tasks.remove(task)
self._time_update_task = None
self.start_polling()

def start_polling(self) -> None:
"""Start polling."""
if (
self._time_update_removed
or not self.enabled
or self._time_update_task is not None
):
return

self._time_update_task = self.device.gateway.async_create_background_task(
self._update_time(),
name=f"sinope_time_updater_{self.unique_id}",
eager_start=True,
untracked=True,
)
self._tracked_tasks.append(self._time_update_task)
self._time_update_task.add_done_callback(self._time_update_task_done)
self.debug(
"started time updating interval of %s",
getattr(self, "__polling_interval"),
)

def enable(self) -> None:
"""Enable the entity."""
if self._time_update_removed:
return
super().enable()
self.start_polling()

def disable(self) -> None:
"""Disable the entity."""
super().disable()
if self._time_update_task:
self._tracked_tasks.remove(self._time_update_task)
self._time_update_task.cancel()
self._time_update_task = None
time_update_task = self._time_update_task
if time_update_task is not None and not time_update_task.cancelling():
time_update_task.cancel()

async def on_remove(self) -> None:
"""Stop time updates before removing the entity."""
self._time_update_removed = True
super().disable()
await super().on_remove()

@periodic((2700, 4500))
async def _update_time(self) -> None:
Expand Down
Loading