Skip to content
Open
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
81 changes: 81 additions & 0 deletions tests/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,48 @@ async def test_sinope_time(
entity._async_update_time.reset_mock()


async def test_sinope_enable_is_idempotent_for_time_update_task(
zha_gateway: Gateway,
) -> None:
"""Test Sinope enable does not duplicate time updater task."""
dev_climate_sinope = await device_climate_sinope(zha_gateway)
entity: ThermostatEntity = get_entity(
dev_climate_sinope, platform=Platform.CLIMATE, entity_type=ThermostatEntity
)

time_updater_task_name = f"sinope_time_updater_{entity.unique_id}"

def active_time_update_tasks() -> list[asyncio.Task]:
return [
task
for task in entity._tracked_tasks
if (
task.get_name() == time_updater_task_name
and not task.done()
and not task.cancelled()
)
]

try:
entity.disable()
assert not active_time_update_tasks()

entity.enable()
assert len(active_time_update_tasks()) == 1

# Issue being validated:
# enable() always calls start_polling(), so a second-pass enable on an already
# enabled Sinope thermostat creates another time-update background task.
#
# Why this is a problem:
# duplicated periodic tasks can trigger duplicate writes and long-lived
# background task leakage over repeated enable operations.
entity.enable()
assert len(active_time_update_tasks()) == 1
finally:
await dev_climate_sinope.on_remove()


async def test_climate_hvac_action_running_state_zen(
zha_gateway: Gateway,
):
Expand Down Expand Up @@ -1126,6 +1168,45 @@ async def test_set_temperature_heat(
}


async def test_set_temperature_heat_decimal_precision(
zha_gateway: Gateway,
):
"""Test heating setpoint conversion keeps decimal precision."""

device_climate = await device_climate_mock(
zha_gateway,
CLIMATE_SINOPE,
{
"occupied_cooling_setpoint": 2500,
"occupied_heating_setpoint": 2000,
"system_mode": Thermostat.SystemMode.Heat,
"unoccupied_heating_setpoint": 1600,
"unoccupied_cooling_setpoint": 2700,
},
manuf=MANUF_SINOPE,
quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat,
)
thrm_cluster = device_climate.device.endpoints[1].thermostat
entity: ThermostatEntity = get_entity(
device_climate, platform=Platform.CLIMATE, entity_type=ThermostatEntity
)

# Issue being validated:
# conversion uses int(temperature * 100). For 19.9, binary floating-point produces
# 1989.999..., and int() truncates to 1989 instead of the intended 1990.
#
# Why this is a problem:
# thermostat writes are off by 0.01C for some decimal inputs, causing subtle setpoint
# drift and mismatches between requested and actual configured temperature.
await entity.async_set_temperature(temperature=19.9)
await zha_gateway.async_block_till_done()

assert thrm_cluster.write_attributes.await_count == 1
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"occupied_heating_setpoint": 1990
}


async def test_set_temperature_cool(
zha_gateway: Gateway,
):
Expand Down
17 changes: 12 additions & 5 deletions zha/application/platforms/climate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from abc import ABC, abstractmethod
from asyncio import Task
import contextlib
from dataclasses import dataclass
import datetime as dt
import functools
Expand Down Expand Up @@ -591,23 +592,23 @@ async def async_set_temperature(
if self.hvac_mode == HVACMode.HEAT_COOL:
if target_temp_low is not None:
await self._thermostat_cluster_handler.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._thermostat_cluster_handler.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._thermostat_cluster_handler.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._thermostat_cluster_handler.async_set_heating_setpoint(
temperature=int(temperature * ZCL_TEMP),
temperature=round(temperature * ZCL_TEMP),
is_away=is_away,
)
else:
Expand Down Expand Up @@ -665,6 +666,11 @@ def on_add(self) -> None:

def start_polling(self) -> None:
"""Start polling."""
if self._time_update_task and not (
self._time_update_task.done() or self._time_update_task.cancelled()
):
return

self._time_update_task = self.device.gateway.async_create_background_task(
self._update_time(),
name=f"sinope_time_updater_{self.unique_id}",
Expand All @@ -686,7 +692,8 @@ def disable(self) -> None:
"""Disable the entity."""
super().disable()
if self._time_update_task:
self._tracked_tasks.remove(self._time_update_task)
with contextlib.suppress(ValueError):
self._tracked_tasks.remove(self._time_update_task)
self._time_update_task.cancel()
self._time_update_task = None

Expand Down
Loading