diff --git a/tests/test_climate.py b/tests/test_climate.py index c5bef03bf..c78115c13 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -391,7 +391,8 @@ async def test_sinope_time( write_attributes.reset_mock() - # Default time zone of UTC + # No configured timezone (local_timezone is None): falls back to UTC. + assert zha_gateway.config.local_timezone is None with freeze_time("2000-01-02 00:00:00"): await entity._async_update_time() secs_since_2k = write_attributes.mock_calls[0].args[0]["secs_since_2k"] @@ -400,7 +401,7 @@ async def test_sinope_time( write_attributes.reset_mock() # New time zone - zha_gateway.config.local_timezone = zoneinfo.ZoneInfo("America/New_York") + zha_gateway.update_local_timezone(zoneinfo.ZoneInfo("America/New_York")) with freeze_time("2000-01-02 00:00:00"): await entity._async_update_time() diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 442563d85..f0459ce1b 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -2,7 +2,9 @@ import asyncio from contextlib import suppress +from typing import Any from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch +import zoneinfo import pytest from zhaquirks.builder import QuirkBuilder @@ -764,6 +766,96 @@ async def mock_send_packet(*args, **kwargs): # pylint: disable=unused-argument assert 1 == max(concurrencies) == zha_gw.radio_concurrency +@pytest.mark.parametrize( + "tz", + [ + None, + zoneinfo.ZoneInfo("America/New_York"), + ], +) +async def test_configured_timezone_passed_to_controller( + tz: zoneinfo.ZoneInfo | None, + zigpy_app_controller: ControllerApplication, + zha_data: ZHAData, +) -> None: + """Test that the configured timezone is forwarded to ControllerApplication.new.""" + zha_data.local_timezone = tz + zha_gw = Gateway(zha_data) + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + AsyncMock(return_value=zigpy_app_controller), + ) as mock_new: + await zha_gw.async_initialize() + + assert mock_new.call_args.kwargs["local_timezone"] == tz + + await zha_gw.shutdown() + + +async def test_update_local_timezone_syncs_controller( + zha_gateway: Gateway, +) -> None: + """Test that update_local_timezone keeps ZHAData and the controller in sync.""" + new_tz = zoneinfo.ZoneInfo("Europe/London") + + zha_gateway.update_local_timezone(new_tz) + + assert zha_gateway.config.local_timezone is new_tz + assert zha_gateway.application_controller.local_timezone is new_tz + + # Also verify clearing to None propagates. + zha_gateway.update_local_timezone(None) + + assert zha_gateway.config.local_timezone is None + assert zha_gateway.application_controller.local_timezone is None + + +async def test_update_local_timezone_before_init( + zha_data: ZHAData, +) -> None: + """Test update_local_timezone before and after controller lifecycle.""" + tz = zoneinfo.ZoneInfo("America/Chicago") + gw = Gateway(zha_data) + + # Before async_initialize: controller is None; must not raise. + gw.update_local_timezone(tz) + assert gw.config.local_timezone is tz + assert gw.application_controller is None + + # After shutdown: controller is reset to None; must not raise. + await gw.shutdown() + gw.update_local_timezone(zoneinfo.ZoneInfo("Europe/Paris")) + assert gw.config.local_timezone == zoneinfo.ZoneInfo("Europe/Paris") + assert gw.application_controller is None + + +async def test_update_local_timezone_during_controller_creation( + zigpy_app_controller: ControllerApplication, + zha_data: ZHAData, +) -> None: + """Test a timezone update while ControllerApplication.new() is suspended.""" + new_tz = zoneinfo.ZoneInfo("Europe/London") + zha_data.local_timezone = None + zha_gw = Gateway(zha_data) + + async def slow_new(*args: Any, **kwargs: Any) -> ControllerApplication: + # Simulates the awaited database load: the update lands mid-creation. + zha_gw.update_local_timezone(new_tz) + zigpy_app_controller.local_timezone = kwargs["local_timezone"] + return zigpy_app_controller + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + AsyncMock(side_effect=slow_new), + ): + await zha_gw.async_initialize() + + assert zha_gw.application_controller.local_timezone is new_tz + + await zha_gw.shutdown() + + async def test_gateway_device_removed(zha_gateway: Gateway) -> None: """Test ZHA device removal.""" diff --git a/zha/application/gateway.py b/zha/application/gateway.py index 23c932c0c..b4643082a 100644 --- a/zha/application/gateway.py +++ b/zha/application/gateway.py @@ -6,7 +6,7 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager, suppress from dataclasses import dataclass -from datetime import timedelta +from datetime import timedelta, tzinfo from enum import Enum import logging import random @@ -262,6 +262,9 @@ async def _async_initialize(self) -> None: self.shutting_down = False app_controller_cls, app_config = self.get_application_controller_data() + # local_timezone is captured once at controller creation time. For runtime + # changes (e.g. the host's timezone setting changes) call update_local_timezone() + # so that both ZHAData and the ControllerApplication stay in sync. self.application_controller = await app_controller_cls.new( config=app_config, auto_form=False, @@ -270,7 +273,11 @@ async def _async_initialize(self) -> None: uninitialized_packet_handler=( self.config.config.quirks_configuration.uninitialized_packet_handler ), + local_timezone=self.config.local_timezone, ) + # `new()` awaits (database load), so `update_local_timezone()` may have run + # while `application_controller` was still None: re-apply the current value. + self.application_controller.local_timezone = self.config.local_timezone await self.application_controller.startup(auto_form=True) @@ -294,6 +301,12 @@ async def async_initialize(self) -> None: await self.shutdown() raise + def update_local_timezone(self, tz: tzinfo | None) -> None: + """Update the local timezone used for ZCL Time cluster attributes.""" + self.config.local_timezone = tz + if self.application_controller is not None: + self.application_controller.local_timezone = tz + @asynccontextmanager async def request_priority( self, priority: t.PacketPriority | None diff --git a/zha/application/helpers.py b/zha/application/helpers.py index c754481e5..82b058c6c 100644 --- a/zha/application/helpers.py +++ b/zha/application/helpers.py @@ -428,7 +428,8 @@ class ZHAData: default_factory=dict ) allow_polling: bool = dataclasses.field(default=False) - local_timezone: dt.tzinfo = dataclasses.field(default=dt.UTC) + # Set once at startup; use Gateway.update_local_timezone() for runtime changes. + local_timezone: dt.tzinfo | None = None country_code: str | None = None diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index 9d234882a..fd973eb2c 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -1016,7 +1016,10 @@ def _rm_rs_action(self) -> HVACAction: async def _async_update_time(self) -> None: """Update thermostat's time display.""" - now = dt.datetime.now(self._device.gateway.config.local_timezone) + # Fall back to UTC when no timezone is configured; datetime.now(None) would + # return a naive system-local datetime and produce wrong secs_since_2k values. + tz = self._device.gateway.config.local_timezone or dt.UTC + now = dt.datetime.now(tz) secs_2k = ( now.replace(tzinfo=None) - dt.datetime(2000, 1, 1, 0, 0, 0, 0) ).total_seconds()