From bcef34012d6c93b05b547281eba20c152e39e505 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 9 Jun 2021 09:38:32 +0200 Subject: [PATCH] Time handling (#2901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add new time handling * migrate date for python3.9 * add timedate * add tests & simplify it * better testing * use ssl * use hostname with new interface * expose to API * update data * add base handler * new timezone handling * improve handling * Improve handling * Add tests * Time adjustment function * Fix logging * tweak condition * don't adjust synchronized time * Guard * ignore UTC * small cleanup * like that, we can leaf it * add URL * add comment * Apply suggestions from code review Co-authored-by: Joakim Sørensen Co-authored-by: Joakim Sørensen --- requirements.txt | 2 +- supervisor/api/const.py | 6 + supervisor/api/host.py | 7 + supervisor/api/info.py | 2 +- supervisor/bootstrap.py | 5 - supervisor/config.py | 15 +- supervisor/const.py | 1 + supervisor/core.py | 49 ++ supervisor/coresys.py | 14 + supervisor/dbus/__init__.py | 8 + supervisor/dbus/const.py | 7 + supervisor/dbus/hostname.py | 45 +- supervisor/dbus/interface.py | 14 + supervisor/dbus/timedate.py | 93 +++ supervisor/docker/addon.py | 2 +- supervisor/docker/audio.py | 2 +- supervisor/docker/cli.py | 2 +- supervisor/docker/dns.py | 2 +- supervisor/docker/homeassistant.py | 4 +- supervisor/docker/multicast.py | 2 +- supervisor/docker/observer.py | 2 +- supervisor/exceptions.py | 15 + supervisor/host/__init__.py | 6 +- supervisor/host/control.py | 40 +- supervisor/host/info.py | 41 +- supervisor/supervisor.py | 2 +- supervisor/utils/dt.py | 41 +- supervisor/utils/pwned.py | 2 +- supervisor/utils/validate.py | 18 +- supervisor/utils/whoami.py | 58 ++ supervisor/validate.py | 2 +- tests/conftest.py | 3 - tests/dbus/test_hostname.py | 33 + tests/dbus/test_timedate.py | 45 ++ ...esktop_hostname1-SetStaticHostname.fixture | 1 + tests/fixtures/org_freedesktop_hostname1.json | 15 + tests/fixtures/org_freedesktop_hostname1.xml | 102 +++ tests/fixtures/org_freedesktop_login1.xml | 381 +++++++++ tests/fixtures/org_freedesktop_systemd1.xml | 726 ++++++++++++++++++ .../org_freedesktop_timedate1-SetNTP.fixture | 1 + .../org_freedesktop_timedate1-SetTime.fixture | 1 + tests/fixtures/org_freedesktop_timedate1.json | 9 + tests/fixtures/org_freedesktop_timedate1.xml | 77 ++ tests/{test_core_state.py => test_core.py} | 3 +- tests/test_coresys.py | 17 + 45 files changed, 1810 insertions(+), 113 deletions(-) create mode 100644 supervisor/api/const.py create mode 100644 supervisor/dbus/timedate.py create mode 100644 supervisor/utils/whoami.py create mode 100644 tests/dbus/test_hostname.py create mode 100644 tests/dbus/test_timedate.py create mode 100644 tests/fixtures/org_freedesktop_hostname1-SetStaticHostname.fixture create mode 100644 tests/fixtures/org_freedesktop_hostname1.json create mode 100644 tests/fixtures/org_freedesktop_hostname1.xml create mode 100644 tests/fixtures/org_freedesktop_login1.xml create mode 100644 tests/fixtures/org_freedesktop_systemd1.xml create mode 100644 tests/fixtures/org_freedesktop_timedate1-SetNTP.fixture create mode 100644 tests/fixtures/org_freedesktop_timedate1-SetTime.fixture create mode 100644 tests/fixtures/org_freedesktop_timedate1.json create mode 100644 tests/fixtures/org_freedesktop_timedate1.xml rename tests/{test_core_state.py => test_core.py} (79%) create mode 100644 tests/test_coresys.py diff --git a/requirements.txt b/requirements.txt index 65a1ed4b6..0bc57c7c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ attrs==21.2.0 awesomeversion==21.5.0 brotli==1.0.9 cchardet==2.1.7 +ciso8601==2.1.3 colorlog==5.0.1 cpe==1.2.1 cryptography==3.4.6 @@ -13,7 +14,6 @@ docker==5.0.0 gitpython==3.1.17 jinja2==3.0.1 pulsectl==21.5.17 -pytz==2021.1 pyudev==0.22.0 ruamel.yaml==0.15.100 sentry-sdk==1.1.0 diff --git a/supervisor/api/const.py b/supervisor/api/const.py new file mode 100644 index 000000000..90db0d4a4 --- /dev/null +++ b/supervisor/api/const.py @@ -0,0 +1,6 @@ +"""Const for API.""" + +ATTR_USE_RTC = "use_rtc" +ATTR_USE_NTP = "use_ntp" +ATTR_DT_UTC = "dt_utc" +ATTR_DT_SYNCHRONIZED = "dt_synchronized" diff --git a/supervisor/api/host.py b/supervisor/api/host.py index 6da299e27..2f21dacaf 100644 --- a/supervisor/api/host.py +++ b/supervisor/api/host.py @@ -21,9 +21,11 @@ from ..const import ( ATTR_OPERATING_SYSTEM, ATTR_SERVICES, ATTR_STATE, + ATTR_TIMEZONE, CONTENT_TYPE_BINARY, ) from ..coresys import CoreSysAttributes +from .const import ATTR_DT_SYNCHRONIZED, ATTR_DT_UTC, ATTR_USE_NTP, ATTR_USE_RTC from .utils import api_process, api_process_raw, api_validate SERVICE = "service" @@ -49,6 +51,11 @@ class APIHost(CoreSysAttributes): ATTR_HOSTNAME: self.sys_host.info.hostname, ATTR_KERNEL: self.sys_host.info.kernel, ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system, + ATTR_TIMEZONE: self.sys_host.info.timezone, + ATTR_DT_UTC: self.sys_host.info.dt_utc, + ATTR_DT_SYNCHRONIZED: self.sys_host.info.dt_synchronized, + ATTR_USE_NTP: self.sys_host.info.use_ntp, + ATTR_USE_RTC: self.sys_host.info.use_rtc, } @api_process diff --git a/supervisor/api/info.py b/supervisor/api/info.py index 85b9519c5..10f88c722 100644 --- a/supervisor/api/info.py +++ b/supervisor/api/info.py @@ -48,5 +48,5 @@ class APIInfo(CoreSysAttributes): ATTR_SUPPORTED: self.sys_core.supported, ATTR_CHANNEL: self.sys_updater.channel, ATTR_LOGGING: self.sys_config.logging, - ATTR_TIMEZONE: self.sys_config.timezone, + ATTR_TIMEZONE: self.sys_timezone, } diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index 29155f648..7ac11c0ea 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -51,7 +51,6 @@ from .snapshots import SnapshotManager from .store import StoreManager from .supervisor import Supervisor from .updater import Updater -from .utils.dt import fetch_timezone _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -95,10 +94,6 @@ async def initialize_coresys() -> CoreSys: if MACHINE_ID.exists(): coresys.machine_id = MACHINE_ID.read_text().strip() - # Init TimeZone - if coresys.config.timezone == "UTC": - coresys.config.timezone = await fetch_timezone(coresys.websession) - # Set machine type if os.environ.get(ENV_SUPERVISOR_MACHINE): coresys.machine = os.environ[ENV_SUPERVISOR_MACHINE] diff --git a/supervisor/config.py b/supervisor/config.py index 9f39d3e35..40a5d6aa9 100644 --- a/supervisor/config.py +++ b/supervisor/config.py @@ -48,6 +48,11 @@ MEDIA_DATA = PurePath("media") DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat() +# We filter out UTC because it's the system default fallback +# Core also not respect the cotnainer timezone and reset timezones +# to UTC if the user overflight the onboarding. +_UTC = "UTC" + class CoreConfig(FileConfiguration): """Hold all core config data.""" @@ -57,13 +62,19 @@ class CoreConfig(FileConfiguration): super().__init__(FILE_HASSIO_CONFIG, SCHEMA_SUPERVISOR_CONFIG) @property - def timezone(self) -> str: + def timezone(self) -> Optional[str]: """Return system timezone.""" - return self._data[ATTR_TIMEZONE] + timezone = self._data.get(ATTR_TIMEZONE) + if timezone != _UTC: + return timezone + self._data.pop(ATTR_TIMEZONE, None) + return None @timezone.setter def timezone(self, value: str) -> None: """Set system timezone.""" + if value == _UTC: + return self._data[ATTR_TIMEZONE] = value @property diff --git a/supervisor/const.py b/supervisor/const.py index dcd036357..3399d883e 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -443,3 +443,4 @@ class HostFeature(str, Enum): REBOOT = "reboot" SERVICES = "services" SHUTDOWN = "shutdown" + TIMEDATE = "timedate" diff --git a/supervisor/core.py b/supervisor/core.py index 2e94bc2b3..d55d031ac 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -1,6 +1,7 @@ """Main file for Supervisor.""" import asyncio from contextlib import suppress +from datetime import timedelta import logging from typing import Awaitable, List, Optional @@ -13,9 +14,13 @@ from .exceptions import ( HomeAssistantCrashError, HomeAssistantError, SupervisorUpdateError, + WhoamiError, + WhoamiSSLError, ) from .homeassistant.core import LANDINGPAGE from .resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason +from .utils.dt import utcnow +from .utils.whoami import retrieve_whoami _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -109,6 +114,8 @@ class Core(CoreSysAttributes): self.sys_dbus.load(), # Load Host self.sys_host.load(), + # Adjust timezone / time settings + self._adjust_system_datetime(), # Load Plugins container self.sys_plugins.load(), # load last available data @@ -310,6 +317,48 @@ class Core(CoreSysAttributes): self.sys_config.last_boot = self.sys_hardware.helper.last_boot self.sys_config.save_data() + async def _adjust_system_datetime(self): + """Adjust system time/date on startup.""" + # If no timezone is detect or set + # If we are not connected or time sync + if ( + self.sys_config.timezone + or self.sys_host.info.timezone not in ("Etc/UTC", None) + ) and (self.sys_host.info.dt_synchronized or self.sys_supervisor.connectivity): + return + + # Get Timezone data + try: + data = await retrieve_whoami(self.sys_websession) + except WhoamiSSLError: + pass + except WhoamiError as err: + _LOGGER.warning("Can't adjust Time/Date settings: %s", err) + return + else: + if not self.sys_config.timezone: + self.sys_config.timezone = data.timezone + return + + # Adjust timesettings in case SSL fails + try: + data = await retrieve_whoami(self.sys_websession, with_ssl=False) + except WhoamiError as err: + _LOGGER.error("Can't adjust Time/Date settings: %s", err) + return + else: + if not self.sys_config.timezone: + self.sys_config.timezone = data.timezone + + # Calculate if system time is out of sync + delta = data.dt_utc - utcnow() + if delta < timedelta(days=7) or self.sys_host.info.dt_synchronized: + return + + _LOGGER.warning("System time/date shift over more as 7days found!") + await self.sys_host.control.set_datetime(data.dt_utc) + await self.sys_supervisor.check_connectivity() + async def repair(self): """Repair system integrity.""" _LOGGER.info("Starting repair of Supervisor Environment") diff --git a/supervisor/coresys.py b/supervisor/coresys.py index 10611f37d..f5e9e46cd 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -91,6 +91,15 @@ class CoreSys: """Return True if we run dev mode.""" return bool(os.environ.get(ENV_SUPERVISOR_DEV, 0)) + @property + def timezone(self) -> str: + """Return system timezone.""" + if self.config.timezone: + return self.config.timezone + if self.host.info.timezone: + return self.host.info.timezone + return "UTC" + @property def loop(self) -> asyncio.BaseEventLoop: """Return loop object.""" @@ -463,6 +472,11 @@ class CoreSysAttributes: coresys: CoreSys + @property + def sys_timezone(self) -> str: + """Return system internal used timezone.""" + return self.coresys.timezone + @property def sys_machine(self) -> Optional[str]: """Return running machine type of the Supervisor system.""" diff --git a/supervisor/dbus/__init__.py b/supervisor/dbus/__init__.py index 2abdc854e..81780ba48 100644 --- a/supervisor/dbus/__init__.py +++ b/supervisor/dbus/__init__.py @@ -10,6 +10,7 @@ from .logind import Logind from .network import NetworkManager from .rauc import Rauc from .systemd import Systemd +from .timedate import TimeDate _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -26,6 +27,7 @@ class DBusManager(CoreSysAttributes): self._hostname: Hostname = Hostname() self._rauc: Rauc = Rauc() self._network: NetworkManager = NetworkManager() + self._timedate: TimeDate = TimeDate() @property def systemd(self) -> Systemd: @@ -37,6 +39,11 @@ class DBusManager(CoreSysAttributes): """Return the logind interface.""" return self._logind + @property + def timedate(self) -> TimeDate: + """Return the timedate interface.""" + return self._timedate + @property def hostname(self) -> Hostname: """Return the hostname interface.""" @@ -64,6 +71,7 @@ class DBusManager(CoreSysAttributes): self.systemd, self.logind, self.hostname, + self.timedate, self.network, self.rauc, ] diff --git a/supervisor/dbus/const.py b/supervisor/dbus/const.py index 97edbdf2e..1c9e12821 100644 --- a/supervisor/dbus/const.py +++ b/supervisor/dbus/const.py @@ -19,6 +19,7 @@ DBUS_NAME_NM_CONNECTION_ACTIVE_CHANGED = ( ) DBUS_NAME_SYSTEMD = "org.freedesktop.systemd1" DBUS_NAME_LOGIND = "org.freedesktop.login1" +DBUS_NAME_TIMEDATE = "org.freedesktop.timedate1" DBUS_OBJECT_BASE = "/" DBUS_OBJECT_DNS = "/org/freedesktop/NetworkManager/DnsManager" @@ -27,6 +28,7 @@ DBUS_OBJECT_HOSTNAME = "/org/freedesktop/hostname1" DBUS_OBJECT_NM = "/org/freedesktop/NetworkManager" DBUS_OBJECT_SYSTEMD = "/org/freedesktop/systemd1" DBUS_OBJECT_LOGIND = "/org/freedesktop/login1" +DBUS_OBJECT_TIMEDATE = "/org/freedesktop/timedate1" DBUS_ATTR_ACTIVE_CONNECTIONS = "ActiveConnections" DBUS_ATTR_ACTIVE_CONNECTION = "ActiveConnection" @@ -70,6 +72,11 @@ DBUS_ATTR_VARIANT = "Variant" DBUS_ATTR_VERSION = "Version" DBUS_ATTR_MANAGED = "Managed" DBUS_ATTR_CONNECTION_ENABLED = "ConnectivityCheckEnabled" +DBUS_ATTR_TIMEZONE = "Timezone" +DBUS_ATTR_LOCALRTC = "LocalRTC" +DBUS_ATTR_NTP = "NTP" +DBUS_ATTR_NTPSYNCHRONIZED = "NTPSynchronized" +DBUS_ATTR_TIMEUSEC = "TimeUSec" class RaucState(str, Enum): diff --git a/supervisor/dbus/hostname.py b/supervisor/dbus/hostname.py index 93ddbf656..ff97981c3 100644 --- a/supervisor/dbus/hostname.py +++ b/supervisor/dbus/hostname.py @@ -1,6 +1,6 @@ """D-Bus interface for hostname.""" import logging -from typing import Optional +from typing import Any, Dict, Optional from ..exceptions import DBusError, DBusInterfaceError from ..utils.gdbus import DBus @@ -14,7 +14,7 @@ from .const import ( DBUS_NAME_HOSTNAME, DBUS_OBJECT_HOSTNAME, ) -from .interface import DBusInterface +from .interface import DBusInterface, dbus_property from .utils import dbus_connected _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -27,56 +27,57 @@ class Hostname(DBusInterface): def __init__(self): """Initialize Properties.""" - self._hostname: Optional[str] = None - self._chassis: Optional[str] = None - self._deployment: Optional[str] = None - self._kernel: Optional[str] = None - self._operating_system: Optional[str] = None - self._cpe: Optional[str] = None + self.properties: Dict[str, Any] = {} async def connect(self): """Connect to system's D-Bus.""" try: self.dbus = await DBus.connect(DBUS_NAME_HOSTNAME, DBUS_OBJECT_HOSTNAME) except DBusError: - _LOGGER.warning("Can't connect to hostname") + _LOGGER.warning("Can't connect to systemd-hostname") except DBusInterfaceError: _LOGGER.warning( "No hostname support on the host. Hostname functions have been disabled." ) @property + @dbus_property def hostname(self) -> Optional[str]: """Return local hostname.""" - return self._hostname + return self.properties[DBUS_ATTR_STATIC_HOSTNAME] @property + @dbus_property def chassis(self) -> Optional[str]: """Return local chassis type.""" - return self._chassis + return self.properties[DBUS_ATTR_CHASSIS] @property + @dbus_property def deployment(self) -> Optional[str]: """Return local deployment type.""" - return self._deployment + return self.properties[DBUS_ATTR_DEPLOYMENT] @property + @dbus_property def kernel(self) -> Optional[str]: """Return local kernel version.""" - return self._kernel + return self.properties[DBUS_ATTR_KERNEL_RELEASE] @property + @dbus_property def operating_system(self) -> Optional[str]: """Return local operating system.""" - return self._operating_system + return self.properties[DBUS_ATTR_OPERATING_SYSTEM_PRETTY_NAME] @property + @dbus_property def cpe(self) -> Optional[str]: """Return local CPE.""" - return self._cpe + return self.properties[DBUS_ATTR_STATIC_OPERATING_SYSTEM_CPE_NAME] @dbus_connected - def set_static_hostname(self, hostname): + def set_static_hostname(self, hostname: str): """Change local hostname. Return a coroutine. @@ -86,14 +87,4 @@ class Hostname(DBusInterface): @dbus_connected async def update(self): """Update Properties.""" - data = await self.dbus.get_properties(DBUS_NAME_HOSTNAME) - if not data: - _LOGGER.warning("Can't get properties for Hostname") - return - - self._hostname = data.get(DBUS_ATTR_STATIC_HOSTNAME) - self._chassis = data.get(DBUS_ATTR_CHASSIS) - self._deployment = data.get(DBUS_ATTR_DEPLOYMENT) - self._kernel = data.get(DBUS_ATTR_KERNEL_RELEASE) - self._operating_system = data.get(DBUS_ATTR_OPERATING_SYSTEM_PRETTY_NAME) - self._cpe = data.get(DBUS_ATTR_STATIC_OPERATING_SYSTEM_CPE_NAME) + self.properties = await self.dbus.get_properties(DBUS_NAME_HOSTNAME) diff --git a/supervisor/dbus/interface.py b/supervisor/dbus/interface.py index d682aa104..64de23b2e 100644 --- a/supervisor/dbus/interface.py +++ b/supervisor/dbus/interface.py @@ -1,10 +1,24 @@ """Interface class for D-Bus wrappers.""" from abc import ABC, abstractmethod +from functools import wraps from typing import Any, Dict, Optional from ..utils.gdbus import DBus +def dbus_property(func): + """Wrap not loaded properties.""" + + @wraps(func) + def wrapper(*args, **kwds): + try: + return func(*args, **kwds) + except KeyError: + return None + + return wrapper + + class DBusInterface(ABC): """Handle D-Bus interface for hostname/system.""" diff --git a/supervisor/dbus/timedate.py b/supervisor/dbus/timedate.py new file mode 100644 index 000000000..6d6f4f38d --- /dev/null +++ b/supervisor/dbus/timedate.py @@ -0,0 +1,93 @@ +"""Interface to systemd-timedate over D-Bus.""" +from datetime import datetime +import logging +from typing import Any, Dict + +from ..exceptions import DBusError, DBusInterfaceError +from ..utils.dt import utc_from_timestamp +from ..utils.gdbus import DBus +from .const import ( + DBUS_ATTR_LOCALRTC, + DBUS_ATTR_NTP, + DBUS_ATTR_NTPSYNCHRONIZED, + DBUS_ATTR_TIMEUSEC, + DBUS_ATTR_TIMEZONE, + DBUS_NAME_TIMEDATE, + DBUS_OBJECT_TIMEDATE, +) +from .interface import DBusInterface, dbus_property +from .utils import dbus_connected + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class TimeDate(DBusInterface): + """Timedate function handler.""" + + name = DBUS_NAME_TIMEDATE + + def __init__(self) -> None: + """Initialize Properties.""" + self.properties: Dict[str, Any] = {} + + @property + @dbus_property + def timezone(self) -> str: + """Return host timezone.""" + return self.properties[DBUS_ATTR_TIMEZONE] + + @property + @dbus_property + def local_rtc(self) -> bool: + """Return if a local RTC exists.""" + return self.properties[DBUS_ATTR_LOCALRTC] + + @property + @dbus_property + def ntp(self) -> bool: + """Return if NTP is enabled.""" + return self.properties[DBUS_ATTR_NTP] + + @property + @dbus_property + def ntp_synchronized(self) -> bool: + """Return if NTP is synchronized.""" + return self.properties[DBUS_ATTR_NTPSYNCHRONIZED] + + @property + @dbus_property + def dt_utc(self) -> datetime: + """Return the system UTC time.""" + return utc_from_timestamp(self.properties[DBUS_ATTR_TIMEUSEC] / 1000000) + + async def connect(self): + """Connect to D-Bus.""" + try: + self.dbus = await DBus.connect(DBUS_NAME_TIMEDATE, DBUS_OBJECT_TIMEDATE) + except DBusError: + _LOGGER.warning("Can't connect to systemd-timedate") + except DBusInterfaceError: + _LOGGER.warning( + "No timedate support on the host. Time/Date functions have been disabled." + ) + + @dbus_connected + def set_time(self, utc: datetime): + """Set time & date on host as UTC. + + Return a coroutine. + """ + return self.dbus.SetTime(int(utc.timestamp() * 1000000), False, False) + + @dbus_connected + def set_ntp(self, use_ntp: bool): + """Turn NTP on or off. + + Return a coroutine. + """ + return self.dbus.SetNTP(use_ntp) + + @dbus_connected + async def update(self): + """Update Properties.""" + self.properties = await self.dbus.get_properties(DBUS_NAME_TIMEDATE) diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 0a241c391..1844cfd32 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -112,7 +112,7 @@ class DockerAddon(DockerInterface): return { **addon_env, - ENV_TIME: self.sys_config.timezone, + ENV_TIME: self.sys_timezone, ENV_TOKEN: self.addon.supervisor_token, ENV_TOKEN_HASSIO: self.addon.supervisor_token, } diff --git a/supervisor/docker/audio.py b/supervisor/docker/audio.py index 15fc4da3d..2b6a45ead 100644 --- a/supervisor/docker/audio.py +++ b/supervisor/docker/audio.py @@ -94,7 +94,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes): ulimits=self.ulimits, cpu_rt_runtime=self.cpu_rt_runtime, device_cgroup_rules=self.cgroups_rules, - environment={ENV_TIME: self.sys_config.timezone}, + environment={ENV_TIME: self.sys_timezone}, volumes=self.volumes, ) diff --git a/supervisor/docker/cli.py b/supervisor/docker/cli.py index 19e25ceaf..64497eba3 100644 --- a/supervisor/docker/cli.py +++ b/supervisor/docker/cli.py @@ -51,7 +51,7 @@ class DockerCli(DockerInterface, CoreSysAttributes): "observer": self.sys_docker.network.observer, }, environment={ - ENV_TIME: self.sys_config.timezone, + ENV_TIME: self.sys_timezone, ENV_TOKEN: self.sys_plugins.cli.supervisor_token, }, ) diff --git a/supervisor/docker/dns.py b/supervisor/docker/dns.py index 44f898f75..12d996856 100644 --- a/supervisor/docker/dns.py +++ b/supervisor/docker/dns.py @@ -45,7 +45,7 @@ class DockerDNS(DockerInterface, CoreSysAttributes): hostname=self.name.replace("_", "-"), detach=True, security_opt=self.security_opt, - environment={ENV_TIME: self.sys_config.timezone}, + environment={ENV_TIME: self.sys_timezone}, volumes={ str(self.sys_config.path_extern_dns): {"bind": "/config", "mode": "rw"} }, diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index df16fa185..467738a71 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -143,7 +143,7 @@ class DockerHomeAssistant(DockerInterface): environment={ "HASSIO": self.sys_docker.network.supervisor, "SUPERVISOR": self.sys_docker.network.supervisor, - ENV_TIME: self.sys_config.timezone, + ENV_TIME: self.sys_timezone, ENV_TOKEN: self.sys_homeassistant.supervisor_token, ENV_TOKEN_HASSIO: self.sys_homeassistant.supervisor_token, }, @@ -181,7 +181,7 @@ class DockerHomeAssistant(DockerInterface): "mode": "ro", }, }, - environment={ENV_TIME: self.sys_config.timezone}, + environment={ENV_TIME: self.sys_timezone}, ) def is_initialize(self) -> Awaitable[bool]: diff --git a/supervisor/docker/multicast.py b/supervisor/docker/multicast.py index 79f1a8103..44c818392 100644 --- a/supervisor/docker/multicast.py +++ b/supervisor/docker/multicast.py @@ -53,7 +53,7 @@ class DockerMulticast(DockerInterface, CoreSysAttributes): security_opt=self.security_opt, cap_add=self.capabilities, extra_hosts={"supervisor": self.sys_docker.network.supervisor}, - environment={ENV_TIME: self.sys_config.timezone}, + environment={ENV_TIME: self.sys_timezone}, ) self._meta = docker_container.attrs diff --git a/supervisor/docker/observer.py b/supervisor/docker/observer.py index 405ce7728..9b4d30e15 100644 --- a/supervisor/docker/observer.py +++ b/supervisor/docker/observer.py @@ -48,7 +48,7 @@ class DockerObserver(DockerInterface, CoreSysAttributes): restart_policy={"Name": "always"}, extra_hosts={"supervisor": self.sys_docker.network.supervisor}, environment={ - ENV_TIME: self.sys_config.timezone, + ENV_TIME: self.sys_timezone, ENV_TOKEN: self.sys_plugins.observer.supervisor_token, ENV_NETWORK_MASK: DOCKER_NETWORK_MASK, }, diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index 6505bd3d7..11021ed09 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -352,6 +352,21 @@ class CodeNotaryBackendError(CodeNotaryError): """CodeNotary backend error happening.""" +# util/whoami + + +class WhoamiError(HassioError): + """Error while using whoami.""" + + +class WhoamiSSLError(WhoamiError): + """Error with the SSL certificate.""" + + +class WhoamiConnectivityError(WhoamiError): + """Connectivity errors while using whoami.""" + + # docker/api diff --git a/supervisor/host/__init__.py b/supervisor/host/__init__.py index a08edc9e0..5b384fa5e 100644 --- a/supervisor/host/__init__.py +++ b/supervisor/host/__init__.py @@ -82,6 +82,9 @@ class HostManager(CoreSysAttributes): if self.sys_dbus.hostname.is_connected: features.append(HostFeature.HOSTNAME) + if self.sys_dbus.timedate.is_connected: + features.append(HostFeature.TIMEDATE) + if self.sys_hassos.available: features.append(HostFeature.HASSOS) @@ -89,8 +92,7 @@ class HostManager(CoreSysAttributes): async def reload(self): """Reload host functions.""" - if self.sys_dbus.hostname.is_connected: - await self.info.update() + await self.info.update() if self.sys_dbus.systemd.is_connected: await self.services.update() diff --git a/supervisor/host/control.py b/supervisor/host/control.py index f38eb361e..f1d7fc9ff 100644 --- a/supervisor/host/control.py +++ b/supervisor/host/control.py @@ -1,14 +1,13 @@ """Power control for host.""" +from datetime import datetime import logging +from ..const import HostFeature from ..coresys import CoreSysAttributes from ..exceptions import HostNotSupportedError _LOGGER: logging.Logger = logging.getLogger(__name__) -MANAGER = "manager" -HOSTNAME = "hostname" - class SystemControl(CoreSysAttributes): """Handle host power controls.""" @@ -17,21 +16,24 @@ class SystemControl(CoreSysAttributes): """Initialize host power handling.""" self.coresys = coresys - def _check_dbus(self, flag): + def _check_dbus(self, flag: HostFeature) -> None: """Check if systemd is connect or raise error.""" - if flag == MANAGER and ( + if flag in (HostFeature.SHUTDOWN, HostFeature.REBOOT) and ( self.sys_dbus.systemd.is_connected or self.sys_dbus.logind.is_connected ): return - if flag == HOSTNAME and self.sys_dbus.hostname.is_connected: + if flag == HostFeature.HOSTNAME and self.sys_dbus.hostname.is_connected: + return + if flag == HostFeature.TIMEDATE and self.sys_dbus.timedate.is_connected: return - _LOGGER.error("No %s D-Bus connection available", flag) - raise HostNotSupportedError() + raise HostNotSupportedError( + f"No {flag!s} D-Bus connection available", _LOGGER.error + ) - async def reboot(self): + async def reboot(self) -> None: """Reboot host system.""" - self._check_dbus(MANAGER) + self._check_dbus(HostFeature.REBOOT) use_logind = self.sys_dbus.logind.is_connected _LOGGER.info( @@ -46,9 +48,9 @@ class SystemControl(CoreSysAttributes): else: await self.sys_dbus.systemd.reboot() - async def shutdown(self): + async def shutdown(self) -> None: """Shutdown host system.""" - self._check_dbus(MANAGER) + self._check_dbus(HostFeature.SHUTDOWN) use_logind = self.sys_dbus.logind.is_connected _LOGGER.info( @@ -63,10 +65,18 @@ class SystemControl(CoreSysAttributes): else: await self.sys_dbus.systemd.power_off() - async def set_hostname(self, hostname): + async def set_hostname(self, hostname: str) -> None: """Set local a new Hostname.""" - self._check_dbus(HOSTNAME) + self._check_dbus(HostFeature.HOSTNAME) _LOGGER.info("Set hostname %s", hostname) await self.sys_dbus.hostname.set_static_hostname(hostname) - await self.sys_host.info.update() + await self.sys_dbus.hostname.update() + + async def set_datetime(self, new_time: datetime) -> None: + """Update host clock with new (utc) datetime.""" + self._check_dbus(HostFeature.TIMEDATE) + + _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() diff --git a/supervisor/host/info.py b/supervisor/host/info.py index da83a600a..788e9891f 100644 --- a/supervisor/host/info.py +++ b/supervisor/host/info.py @@ -1,15 +1,11 @@ """Info control for host.""" import asyncio +from datetime import datetime import logging from typing import Optional from ..coresys import CoreSysAttributes -from ..exceptions import ( - DBusError, - DBusNotConnectedError, - HostError, - HostNotSupportedError, -) +from ..exceptions import DBusError, HostError _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -51,6 +47,31 @@ class InfoCenter(CoreSysAttributes): """Return local CPE.""" return self.sys_dbus.hostname.cpe + @property + def timezone(self) -> Optional[str]: + """Return host timezone.""" + return self.sys_dbus.timedate.timezone + + @property + def dt_utc(self) -> Optional[datetime]: + """Return host UTC time.""" + return self.sys_dbus.timedate.dt_utc + + @property + def use_rtc(self) -> Optional[bool]: + """Return true if host have an RTC.""" + return self.sys_dbus.timedate.local_rtc + + @property + def use_ntp(self) -> Optional[bool]: + """Return true if host using NTP.""" + return self.sys_dbus.timedate.ntp + + @property + def dt_synchronized(self) -> Optional[bool]: + """Return true if host time is syncronized.""" + return self.sys_dbus.timedate.ntp_synchronized + @property def total_space(self) -> float: """Return total space (GiB) on disk for supervisor data directory.""" @@ -98,9 +119,9 @@ class InfoCenter(CoreSysAttributes): """Update properties over dbus.""" _LOGGER.info("Updating local host information") try: - await self.sys_dbus.hostname.update() + if self.sys_dbus.hostname.is_connected: + await self.sys_dbus.hostname.update() + if self.sys_dbus.timedate.is_connected: + await self.sys_dbus.timedate.update() except DBusError: _LOGGER.warning("Can't update host system information!") - except DBusNotConnectedError: - _LOGGER.error("No hostname D-Bus connection available") - raise HostNotSupportedError() from None diff --git a/supervisor/supervisor.py b/supervisor/supervisor.py index 021d90976..fa93dd568 100644 --- a/supervisor/supervisor.py +++ b/supervisor/supervisor.py @@ -241,7 +241,7 @@ class Supervisor(CoreSysAttributes): timeout = aiohttp.ClientTimeout(total=10) try: await self.sys_websession.head( - "http://version.home-assistant.io/online.txt", timeout=timeout + "https://version.home-assistant.io/online.txt", timeout=timeout ) except (ClientError, asyncio.TimeoutError): self.connectivity = False diff --git a/supervisor/utils/dt.py b/supervisor/utils/dt.py index c7c5e1760..cd37a56ab 100644 --- a/supervisor/utils/dt.py +++ b/supervisor/utils/dt.py @@ -1,18 +1,13 @@ """Tools file for Supervisor.""" -import asyncio +from contextlib import suppress from datetime import datetime, timedelta, timezone, tzinfo -import logging import re from typing import Any, Dict, Optional +import zoneinfo -import aiohttp -import pytz +import ciso8601 -UTC = pytz.utc - -GEOIP_URL = "http://ip-api.com/json/" - -_LOGGER: logging.Logger = logging.getLogger(__name__) +UTC = timezone.utc # Copyright (c) Django Software Foundation and individual contributors. @@ -26,21 +21,6 @@ DATETIME_RE = re.compile( ) -async def fetch_timezone(websession: aiohttp.ClientSession): - """Read timezone from freegeoip.""" - data = {} - try: - async with websession.get(GEOIP_URL, timeout=10) as request: - data = await request.json() - - except (aiohttp.ClientError, asyncio.TimeoutError) as err: - _LOGGER.warning("Can't fetch freegeoip data: %s", err) - except ValueError as err: - _LOGGER.warning("Error on parse freegeoip data: %s", err) - - return data.get("timezone", "UTC") - - # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. # https://github.com/django/django/blob/master/LICENSE @@ -52,6 +32,9 @@ def parse_datetime(dt_str): Raises ValueError if the input is well formatted but not a valid datetime. Returns None if the input isn't well formatted. """ + with suppress(ValueError, IndexError): + return ciso8601.parse_datetime(dt_str) + match = DATETIME_RE.match(dt_str) if not match: return None @@ -84,4 +67,12 @@ def utcnow() -> datetime: def utc_from_timestamp(timestamp: float) -> datetime: """Return a UTC time from a timestamp.""" - return UTC.localize(datetime.utcfromtimestamp(timestamp)) + return datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC) + + +def get_time_zone(time_zone_str: str) -> Optional[tzinfo]: + """Get time zone from string. Return None if unable to determine.""" + try: + return zoneinfo.ZoneInfo(time_zone_str) + except zoneinfo.ZoneInfoNotFoundError: + return None diff --git a/supervisor/utils/pwned.py b/supervisor/utils/pwned.py index 55725d7d4..912bc0f0a 100644 --- a/supervisor/utils/pwned.py +++ b/supervisor/utils/pwned.py @@ -43,5 +43,5 @@ async def check_pwned_password(websession: aiohttp.ClientSession, sha1_pw: str) except (aiohttp.ClientError, asyncio.TimeoutError) as err: raise PwnedConnectivityError( - f"Can't fetch HIBP data: {err}", _LOGGER.warning + f"Can't fetch HIBP data: {str(err) or 'Timeout'}", _LOGGER.warning ) from err diff --git a/supervisor/utils/validate.py b/supervisor/utils/validate.py index 886ab65c0..60146bc27 100644 --- a/supervisor/utils/validate.py +++ b/supervisor/utils/validate.py @@ -1,8 +1,9 @@ """Validate utils.""" -import pytz import voluptuous as vol +from .dt import get_time_zone + def schema_or(schema): """Allow schema or empty.""" @@ -18,12 +19,9 @@ def schema_or(schema): def validate_timezone(timezone): """Validate voluptuous timezone.""" - try: - pytz.timezone(timezone) - except pytz.exceptions.UnknownTimeZoneError: - raise vol.Invalid( - "Invalid time zone passed in. Valid options can be found here: " - "http://en.wikipedia.org/wiki/List_of_tz_database_time_zones" - ) from None - - return timezone + if get_time_zone(timezone) is not None: + return timezone + raise vol.Invalid( + "Invalid time zone passed in. Valid options can be found here: " + "http://en.wikipedia.org/wiki/List_of_tz_database_time_zones" + ) from None diff --git a/supervisor/utils/whoami.py b/supervisor/utils/whoami.py new file mode 100644 index 000000000..e11de1cd4 --- /dev/null +++ b/supervisor/utils/whoami.py @@ -0,0 +1,58 @@ +"""Small wrapper for whoami API. + +https://github.com/home-assistant/whoami.home-assistant.io +""" +import asyncio +from datetime import datetime +import logging + +import aiohttp +import attr + +from ..exceptions import WhoamiConnectivityError, WhoamiError, WhoamiSSLError +from .dt import utc_from_timestamp + +_LOGGER: logging.Logger = logging.getLogger(__name__) +_API_CALL: str = "whoami.home-assistant.io/v1" + + +@attr.s(slots=True, frozen=True) +class WhoamiData: + """Client Whoami data.""" + + timezone: str = attr.ib() + dt_utc: datetime = attr.ib() + + +async def retrieve_whoami( + websession: aiohttp.ClientSession, with_ssl: bool = True +) -> WhoamiData: + """Check if password is pwned.""" + url: str = f"http{'s' if with_ssl else ''}://{_API_CALL}" + + _LOGGER.debug("Check whoami to verify connectivity/system with: %s", url) + try: + async with websession.get( + url, timeout=aiohttp.ClientTimeout(total=10) + ) as request: + if request.status != 200: + raise WhoamiError( + f"Whoami service response with {request.status}", _LOGGER.warning + ) + data = await request.json() + + return WhoamiData( + data["timezone"], utc_from_timestamp(float(data["timestamp"])) + ) + + except aiohttp.ClientConnectorSSLError as err: + # Expired certificate / Date ISSUE + # pylint: disable=bad-exception-context + raise WhoamiSSLError( + f"Whoami service failed with SSL verification: {err!s}", _LOGGER.warning + ) from err + + except (aiohttp.ClientError, asyncio.TimeoutError) as err: + raise WhoamiConnectivityError( + f"Can't fetch Whoami data: {str(err) or 'Timeout'}", _LOGGER.warning + ) from err diff --git a/supervisor/validate.py b/supervisor/validate.py index 53feda723..9ec027783 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -136,7 +136,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema( # pylint: disable=no-value-for-parameter SCHEMA_SUPERVISOR_CONFIG = vol.Schema( { - vol.Optional(ATTR_TIMEZONE, default="UTC"): validate_timezone, + vol.Optional(ATTR_TIMEZONE): validate_timezone, vol.Optional(ATTR_LAST_BOOT): vol.Coerce(str), vol.Optional( ATTR_VERSION, default=AwesomeVersion(SUPERVISOR_VERSION) diff --git a/tests/conftest.py b/tests/conftest.py index b157d4190..7dd93ce8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -128,9 +128,6 @@ async def coresys(loop, docker, network_manager, aiohttp_client) -> CoreSys: """Create a CoreSys Mock.""" with patch("supervisor.bootstrap.initialize_system_data"), patch( "supervisor.bootstrap.setup_diagnostics" - ), patch( - "supervisor.bootstrap.fetch_timezone", - return_value="Europe/Zurich", ): coresys_obj = await initialize_coresys() diff --git a/tests/dbus/test_hostname.py b/tests/dbus/test_hostname.py new file mode 100644 index 000000000..4dacd9d4d --- /dev/null +++ b/tests/dbus/test_hostname.py @@ -0,0 +1,33 @@ +"""Test hostname dbus interface.""" + +import pytest + +from supervisor.coresys import CoreSys +from supervisor.exceptions import DBusNotConnectedError + + +async def test_dbus_hostname_info(coresys: CoreSys): + """Test coresys dbus connection.""" + assert coresys.dbus.hostname.hostname is None + + await coresys.dbus.hostname.connect() + await coresys.dbus.hostname.update() + + assert coresys.dbus.hostname.hostname == "homeassistant-n2" + assert coresys.dbus.hostname.kernel == "5.10.33" + assert ( + coresys.dbus.hostname.cpe + == "cpe:2.3:o:home-assistant:haos:6.0.dev20210504:*:development:*:*:*:odroid-n2:*" + ) + assert coresys.dbus.hostname.operating_system == "Home Assistant OS 6.0.dev20210504" + + +async def test_dbus_sethostname(coresys: CoreSys): + """Set hostname on backend.""" + + with pytest.raises(DBusNotConnectedError): + await coresys.dbus.hostname.set_static_hostname("StarWars") + + await coresys.dbus.hostname.connect() + + await coresys.dbus.hostname.set_static_hostname("StarWars") diff --git a/tests/dbus/test_timedate.py b/tests/dbus/test_timedate.py new file mode 100644 index 000000000..34e5b93c4 --- /dev/null +++ b/tests/dbus/test_timedate.py @@ -0,0 +1,45 @@ +"""Test TimeDate dbus interface.""" +from datetime import datetime, timezone + +import pytest + +from supervisor.coresys import CoreSys +from supervisor.exceptions import DBusNotConnectedError + + +async def test_dbus_timezone(coresys: CoreSys): + """Test coresys dbus connection.""" + assert coresys.dbus.timedate.dt_utc is None + + await coresys.dbus.timedate.connect() + await coresys.dbus.timedate.update() + + assert coresys.dbus.timedate.dt_utc == datetime( + 2021, 5, 19, 8, 36, 54, 405718, tzinfo=timezone.utc + ) + + assert ( + coresys.dbus.timedate.dt_utc.isoformat() == "2021-05-19T08:36:54.405718+00:00" + ) + + +async def test_dbus_settime(coresys: CoreSys): + """Set timestamp on backend.""" + test_dt = datetime(2021, 5, 19, 8, 36, 54, 405718, tzinfo=timezone.utc) + + with pytest.raises(DBusNotConnectedError): + await coresys.dbus.timedate.set_time(test_dt) + + await coresys.dbus.timedate.connect() + + await coresys.dbus.timedate.set_time(test_dt) + + +async def test_dbus_setntp(coresys: CoreSys): + """Disable NTP on backend.""" + with pytest.raises(DBusNotConnectedError): + await coresys.dbus.timedate.set_ntp(False) + + await coresys.dbus.timedate.connect() + + await coresys.dbus.timedate.set_ntp(False) diff --git a/tests/fixtures/org_freedesktop_hostname1-SetStaticHostname.fixture b/tests/fixtures/org_freedesktop_hostname1-SetStaticHostname.fixture new file mode 100644 index 000000000..dd626a0f3 --- /dev/null +++ b/tests/fixtures/org_freedesktop_hostname1-SetStaticHostname.fixture @@ -0,0 +1 @@ +() \ No newline at end of file diff --git a/tests/fixtures/org_freedesktop_hostname1.json b/tests/fixtures/org_freedesktop_hostname1.json new file mode 100644 index 000000000..8dfba061f --- /dev/null +++ b/tests/fixtures/org_freedesktop_hostname1.json @@ -0,0 +1,15 @@ +{ + "Hostname": "homeassistant-n2", + "StaticHostname": "homeassistant-n2", + "PrettyHostname": "", + "IconName": "computer-embedded", + "Chassis": "embedded", + "Deployment": "development", + "Location": "", + "KernelName": "Linux", + "KernelRelease": "5.10.33", + "KernelVersion": "#1 SMP PREEMPT Wed May 5 00:55:38 UTC 2021", + "OperatingSystemPrettyName": "Home Assistant OS 6.0.dev20210504", + "OperatingSystemCPEName": "cpe:2.3:o:home-assistant:haos:6.0.dev20210504:*:development:*:*:*:odroid-n2:*", + "HomeURL": "https://hass.io/" +} diff --git a/tests/fixtures/org_freedesktop_hostname1.xml b/tests/fixtures/org_freedesktop_hostname1.xml new file mode 100644 index 000000000..33106720b --- /dev/null +++ b/tests/fixtures/org_freedesktop_hostname1.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/org_freedesktop_login1.xml b/tests/fixtures/org_freedesktop_login1.xml new file mode 100644 index 000000000..283429e63 --- /dev/null +++ b/tests/fixtures/org_freedesktop_login1.xml @@ -0,0 +1,381 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/org_freedesktop_systemd1.xml b/tests/fixtures/org_freedesktop_systemd1.xml new file mode 100644 index 000000000..ec584dcde --- /dev/null +++ b/tests/fixtures/org_freedesktop_systemd1.xml @@ -0,0 +1,726 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/org_freedesktop_timedate1-SetNTP.fixture b/tests/fixtures/org_freedesktop_timedate1-SetNTP.fixture new file mode 100644 index 000000000..dd626a0f3 --- /dev/null +++ b/tests/fixtures/org_freedesktop_timedate1-SetNTP.fixture @@ -0,0 +1 @@ +() \ No newline at end of file diff --git a/tests/fixtures/org_freedesktop_timedate1-SetTime.fixture b/tests/fixtures/org_freedesktop_timedate1-SetTime.fixture new file mode 100644 index 000000000..dd626a0f3 --- /dev/null +++ b/tests/fixtures/org_freedesktop_timedate1-SetTime.fixture @@ -0,0 +1 @@ +() \ No newline at end of file diff --git a/tests/fixtures/org_freedesktop_timedate1.json b/tests/fixtures/org_freedesktop_timedate1.json new file mode 100644 index 000000000..87f0f56af --- /dev/null +++ b/tests/fixtures/org_freedesktop_timedate1.json @@ -0,0 +1,9 @@ +{ + "Timezone": "Etc/UTC", + "LocalRTC": false, + "CanNTP": true, + "NTP": true, + "NTPSynchronized": true, + "TimeUSec": 1621413414405718, + "RTCTimeUSec": 1621413415000000 +} diff --git a/tests/fixtures/org_freedesktop_timedate1.xml b/tests/fixtures/org_freedesktop_timedate1.xml new file mode 100644 index 000000000..589d4c0e3 --- /dev/null +++ b/tests/fixtures/org_freedesktop_timedate1.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_core_state.py b/tests/test_core.py similarity index 79% rename from tests/test_core_state.py rename to tests/test_core.py index 00a440b31..8e25bac68 100644 --- a/tests/test_core_state.py +++ b/tests/test_core.py @@ -1,9 +1,10 @@ """Testing handling with CoreState.""" from supervisor.const import CoreState +from supervisor.coresys import CoreSys -def test_write_state(run_dir, coresys): +def test_write_state(run_dir, coresys: CoreSys): """Test write corestate to /run/supervisor.""" coresys.core.state = CoreState.RUNNING diff --git a/tests/test_coresys.py b/tests/test_coresys.py new file mode 100644 index 000000000..d00134f65 --- /dev/null +++ b/tests/test_coresys.py @@ -0,0 +1,17 @@ +"""Testing handling with CoreState.""" + +from supervisor.coresys import CoreSys + + +async def test_timezone(run_dir, coresys: CoreSys): + """Test write corestate to /run/supervisor.""" + + assert coresys.timezone == "UTC" + assert coresys.config.timezone is None + + await coresys.dbus.timedate.connect() + await coresys.dbus.timedate.update() + assert coresys.timezone == "Etc/UTC" + + coresys.config.timezone = "Europe/Zurich" + assert coresys.timezone == "Europe/Zurich"