From b49ce96df8082f81a070e6321c20e29937ccdc6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Wed, 20 Aug 2025 01:30:57 +0200 Subject: [PATCH] Propagate timezone setting to host in OS 16.2 and newer (#6099) * Propagate timezone setting to host in OS 16.2 and newer With home-assistant/operating-system#4224, timezone setting in OS can be peristently set in HAOS as well. Propagate the timezone configured in Supervisor config (which can be changed through general system settings in HA Core) through the DBus API for setting the timezone. * Persist timezone also when it's been obtained from Whoami * Suppress pylint fixme error --- supervisor/api/supervisor.py | 1 + supervisor/core.py | 17 ++++++++++++++++- supervisor/dbus/timedate.py | 5 +++++ supervisor/host/control.py | 23 +++++++++++++++++++++++ tests/dbus/test_timedate.py | 18 ++++++++++++++++++ tests/host/test_control.py | 33 +++++++++++++++++++++++++++++++++ tests/test_core.py | 16 ++++++++++++++++ 7 files changed, 112 insertions(+), 1 deletion(-) diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index c79f88f42..cc4a62839 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -142,6 +142,7 @@ class APISupervisor(CoreSysAttributes): ): await self.sys_run_in_executor(validate_timezone, timezone) await self.sys_config.set_timezone(timezone) + await self.sys_host.control.set_timezone(timezone) if ATTR_CHANNEL in body: self.sys_updater.channel = body[ATTR_CHANNEL] diff --git a/supervisor/core.py b/supervisor/core.py index 1b9dd2d65..bb869b015 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -392,6 +392,19 @@ class Core(CoreSysAttributes): async def _adjust_system_datetime(self) -> None: """Adjust system time/date on startup.""" + # Ensure host system timezone matches supervisor timezone configuration + if ( + self.sys_config.timezone + and self.sys_host.info.timezone != self.sys_config.timezone + and self.sys_dbus.timedate.is_connected + ): + _LOGGER.info( + "Timezone in Supervisor config '%s' differs from host '%s'", + self.sys_config.timezone, + self.sys_host.info.timezone, + ) + await self.sys_host.control.set_timezone(self.sys_config.timezone) + # If no timezone is detect or set # If we are not connected or time sync if ( @@ -413,7 +426,9 @@ class Core(CoreSysAttributes): _LOGGER.warning("Can't adjust Time/Date settings: %s", err) return - await self.sys_config.set_timezone(self.sys_config.timezone or data.timezone) + timezone = self.sys_config.timezone or data.timezone + await self.sys_config.set_timezone(timezone) + await self.sys_host.control.set_timezone(timezone) # Calculate if system time is out of sync delta = data.dt_utc - utcnow() diff --git a/supervisor/dbus/timedate.py b/supervisor/dbus/timedate.py index 407847e06..334be6a83 100644 --- a/supervisor/dbus/timedate.py +++ b/supervisor/dbus/timedate.py @@ -112,3 +112,8 @@ class TimeDate(DBusInterfaceProxy): async def set_ntp(self, use_ntp: bool) -> None: """Turn NTP on or off.""" await self.connected_dbus.call("set_ntp", use_ntp, False) + + @dbus_connected + async def set_timezone(self, timezone: str) -> None: + """Set timezone on host.""" + await self.connected_dbus.call("set_timezone", timezone, False) diff --git a/supervisor/host/control.py b/supervisor/host/control.py index 1a8790fcd..95abc05d3 100644 --- a/supervisor/host/control.py +++ b/supervisor/host/control.py @@ -3,6 +3,8 @@ from datetime import datetime import logging +from awesomeversion import AwesomeVersion + from ..const import HostFeature from ..coresys import CoreSysAttributes from ..exceptions import HostNotSupportedError @@ -80,3 +82,24 @@ class SystemControl(CoreSysAttributes): _LOGGER.info("Setting new host datetime: %s", new_time.isoformat()) await self.sys_dbus.timedate.set_time(new_time) await self.sys_dbus.timedate.update() + + async def set_timezone(self, timezone: str) -> None: + """Set timezone on host.""" + self._check_dbus(HostFeature.TIMEDATE) + + # /etc/localtime is not writable on OS older than 16.2 + if ( + self.coresys.os.available + and self.coresys.os.version is not None + and self.sys_os.version >= AwesomeVersion("16.2.dev0") + ): + _LOGGER.info("Setting host timezone: %s", timezone) + await self.sys_dbus.timedate.set_timezone(timezone) + await self.sys_dbus.timedate.update() + else: + # pylint: disable=fixme + # TODO: we can change this to a warning once 16.2 is out + _LOGGER.info( + "Skipping persistent timezone setting, OS %s < 16.2", + self.sys_os.version, + ) diff --git a/tests/dbus/test_timedate.py b/tests/dbus/test_timedate.py index ab4caf888..9b05678dc 100644 --- a/tests/dbus/test_timedate.py +++ b/tests/dbus/test_timedate.py @@ -82,6 +82,24 @@ async def test_dbus_setntp( assert timedate.ntp is False +async def test_dbus_set_timezone( + timedate_service: TimeDateService, dbus_session_bus: MessageBus +): + """Test setting of host timezone.""" + timedate_service.SetTimezone.calls.clear() + timedate = TimeDate() + + with pytest.raises(DBusNotConnectedError): + await timedate.set_timezone("Europe/Prague") + + await timedate.connect(dbus_session_bus) + + assert await timedate.set_timezone("Europe/Prague") is None + assert timedate_service.SetTimezone.calls == [("Europe/Prague", False)] + await timedate_service.ping() + assert timedate.timezone == "Europe/Prague" + + async def test_dbus_timedate_connect_error( dbus_session_bus: MessageBus, caplog: pytest.LogCaptureFixture ): diff --git a/tests/host/test_control.py b/tests/host/test_control.py index dde24955c..b543360a2 100644 --- a/tests/host/test_control.py +++ b/tests/host/test_control.py @@ -1,9 +1,12 @@ """Test host control.""" +import pytest + from supervisor.coresys import CoreSys from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.hostname import Hostname as HostnameService +from tests.dbus_service_mocks.timedate import TimeDate as TimeDateService async def test_set_hostname( @@ -20,3 +23,33 @@ async def test_set_hostname( assert hostname_service.SetStaticHostname.calls == [("test", False)] await hostname_service.ping() assert coresys.dbus.hostname.hostname == "test" + + +@pytest.mark.parametrize("os_available", ["16.2"], indirect=True) +async def test_set_timezone( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], + os_available: str, +): + """Test set timezone.""" + timedate_service: TimeDateService = all_dbus_services["timedate"] + timedate_service.SetTimezone.calls.clear() + + assert coresys.dbus.timedate.timezone == "Etc/UTC" + + await coresys.host.control.set_timezone("Europe/Prague") + assert timedate_service.SetTimezone.calls == [("Europe/Prague", False)] + + +@pytest.mark.parametrize("os_available", ["16.1"], indirect=True) +async def test_set_timezone_unsupported( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], + os_available: str, +): + """Test DBus call is not made when OS doesn't support it.""" + timedate_service: TimeDateService = all_dbus_services["timedate"] + timedate_service.SetTimezone.calls.clear() + + await coresys.host.control.set_timezone("Europe/Prague") + assert timedate_service.SetTimezone.calls == [] diff --git a/tests/test_core.py b/tests/test_core.py index bde0ce105..200d9ff26 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -83,6 +83,7 @@ async def test_adjust_system_datetime_if_time_behind( side_effect=[WhoamiData("Europe/Zurich", utc_ts)], ) as mock_retrieve_whoami, patch.object(SystemControl, "set_datetime") as mock_set_datetime, + patch.object(SystemControl, "set_timezone") as mock_set_timezone, patch.object( InfoCenter, "dt_synchronized", new=PropertyMock(return_value=False) ), @@ -92,6 +93,21 @@ async def test_adjust_system_datetime_if_time_behind( mock_retrieve_whoami.assert_called_once() mock_set_datetime.assert_called_once() mock_check_connectivity.assert_called_once() + mock_set_timezone.assert_called_once_with("Europe/Zurich") + + +async def test_adjust_system_datetime_sync_timezone_to_host( + coresys: CoreSys, websession: MagicMock +): + """Test _adjust_system_datetime method syncs timezone to host when different.""" + await coresys.core.sys_config.set_timezone("Europe/Prague") + + with ( + patch.object(SystemControl, "set_timezone") as mock_set_timezone, + patch.object(InfoCenter, "timezone", new=PropertyMock(return_value="Etc/UTC")), + ): + await coresys.core._adjust_system_datetime() + mock_set_timezone.assert_called_once_with("Europe/Prague") async def test_write_state_failure(