From 3115e51d8adc94f7a28fc276216bcf651f9aec14 Mon Sep 17 00:00:00 2001 From: Lloyd Engebretsen Date: Sat, 24 Oct 2026 15:12:44 -0400 Subject: [PATCH 1/3] Pass the configured timezone to zigpy for Time cluster DST support --- tests/test_climate.py | 5 +- tests/test_gateway.py | 71 +++++++++++++++++++ zha/application/gateway.py | 12 +++- zha/application/helpers.py | 3 +- zha/application/platforms/climate/__init__.py | 5 +- 5 files changed, 91 insertions(+), 5 deletions(-) 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..6b7d6ef7a 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -3,6 +3,7 @@ import asyncio from contextlib import suppress from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch +import zoneinfo import pytest from zhaquirks.builder import QuirkBuilder @@ -764,6 +765,76 @@ 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) + + async def _mock_new(*args, **kwargs): + # Simulate what zigpy does: store local_timezone on the controller instance. + zigpy_app_controller.local_timezone = kwargs.get("local_timezone") + return zigpy_app_controller + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + side_effect=_mock_new, + ) as mock_new: + await zha_gw.async_initialize() + + assert mock_new.call_args.kwargs["local_timezone"] == tz + assert zha_gw.application_controller.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_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..13d61fbf2 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,6 +273,7 @@ async def _async_initialize(self) -> None: uninitialized_packet_handler=( self.config.config.quirks_configuration.uninitialized_packet_handler ), + local_timezone=self.config.local_timezone, ) await self.application_controller.startup(auto_form=True) @@ -294,6 +298,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() From bcbdd01e2ad79699b9c31bdb990b8c7cac7c5852 Mon Sep 17 00:00:00 2001 From: Lloyd Engebretsen Date: Sun, 25 Oct 2026 00:52:27 -0400 Subject: [PATCH 2/3] Simplify timezone test: drop redundant controller assertion --- tests/test_gateway.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 6b7d6ef7a..a093c901c 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -781,19 +781,13 @@ async def test_configured_timezone_passed_to_controller( zha_data.local_timezone = tz zha_gw = Gateway(zha_data) - async def _mock_new(*args, **kwargs): - # Simulate what zigpy does: store local_timezone on the controller instance. - zigpy_app_controller.local_timezone = kwargs.get("local_timezone") - return zigpy_app_controller - with patch( "bellows.zigbee.application.ControllerApplication.new", - side_effect=_mock_new, + AsyncMock(return_value=zigpy_app_controller), ) as mock_new: await zha_gw.async_initialize() assert mock_new.call_args.kwargs["local_timezone"] == tz - assert zha_gw.application_controller.local_timezone == tz await zha_gw.shutdown() From dad4e5758bad7908ee1b2087ad8c09f3bccb325b Mon Sep 17 00:00:00 2001 From: zigpy-review-bot <286747149+zigpy-review-bot@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:19:27 +0100 Subject: [PATCH 3/3] Reapply configured timezone after controller creation --- tests/test_gateway.py | 27 +++++++++++++++++++++++++++ zha/application/gateway.py | 3 +++ 2 files changed, 30 insertions(+) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index a093c901c..f0459ce1b 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -2,6 +2,7 @@ import asyncio from contextlib import suppress +from typing import Any from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch import zoneinfo @@ -829,6 +830,32 @@ async def test_update_local_timezone_before_init( 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 13d61fbf2..b4643082a 100644 --- a/zha/application/gateway.py +++ b/zha/application/gateway.py @@ -275,6 +275,9 @@ async def _async_initialize(self) -> None: ), 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)