Skip to content
Merged
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
5 changes: 3 additions & 2 deletions tests/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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()
Expand Down
92 changes: 92 additions & 0 deletions tests/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down
15 changes: 14 additions & 1 deletion zha/application/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion zha/application/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
5 changes: 4 additions & 1 deletion zha/application/platforms/climate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading