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(