diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f2bf3752fa..c880e036cb 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1169,7 +1169,7 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) { void APIConnection::on_get_time_response(const GetTimeResponse &value) { if (homeassistant::global_homeassistant_time != nullptr) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); -#ifdef USE_TIME_TIMEZONE +#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE) if (!value.timezone.empty()) { // Check if the sender provided pre-parsed timezone data. // If std_offset is non-zero or DST rules are present, the parsed data was populated. diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index d6150fbd29..7fba091730 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -101,6 +101,7 @@ async def async_run_logs( client_info=f"ESPHome Logs {__version__}", noise_psk=noise_psk, addresses=addresses, # Pass all addresses for automatic retry + provide_time=False, ) # Try platform-specific stacktrace handler first, fall back to generic diff --git a/esphome/components/homeassistant/time/__init__.py b/esphome/components/homeassistant/time/__init__.py index 62cb96a25a..05ca86a26e 100644 --- a/esphome/components/homeassistant/time/__init__.py +++ b/esphome/components/homeassistant/time/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_TIMEZONE from .. import homeassistant_ns @@ -21,3 +21,5 @@ async def to_code(config): await time_.register_time(var, config) await cg.register_component(var, config) cg.add_define("USE_HOMEASSISTANT_TIME") + if CONF_TIMEZONE not in config: + cg.add_define("USE_HOMEASSISTANT_TIMEZONE") diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 29bb01b499..8839a988a1 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -30,13 +30,21 @@ from esphome.const import ( CONF_SECONDS, CONF_TIMEZONE, CONF_TRIGGER_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_HOST, + PLATFORM_LN882X, + PLATFORM_RP2040, + PLATFORM_RTL87XX, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True +DOMAIN = "time" time_ns = cg.esphome_ns.namespace("time") RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent) @@ -92,20 +100,34 @@ def _extract_tz_string(tzfile: bytes) -> str: raise -def detect_tz() -> str: +def detect_tz() -> str | None: + if CORE.target_platform not in { + PLATFORM_ESP8266, + PLATFORM_ESP32, + PLATFORM_RP2040, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + PLATFORM_HOST, + }: + return None + # Avoids duplicate logger messages when multiple time components are configured + if cached := CORE.data.setdefault(DOMAIN, {}).get(CONF_TIMEZONE): + return cached iana_key = tzlocal.get_localzone_name() if iana_key is None: - raise cv.Invalid( + raise EsphomeError( "Could not automatically determine timezone, please set timezone manually." ) - _LOGGER.info("Detected timezone '%s'", iana_key) tzfile = _load_tzdata(iana_key) if tzfile is None: - raise cv.Invalid( + raise EsphomeError( "Could not automatically determine timezone, please set timezone manually." ) ret = _extract_tz_string(tzfile) + _LOGGER.info("Detected timezone '%s'", iana_key) _LOGGER.debug(" -> TZ string %s", ret) + CORE.data.setdefault(DOMAIN, {})[CONF_TIMEZONE] = ret return ret @@ -312,16 +334,7 @@ def validate_tz(value: str) -> str: TIME_SCHEMA = cv.Schema( { - cv.SplitDefault( - CONF_TIMEZONE, - esp8266=detect_tz, - esp32=detect_tz, - rp2040=detect_tz, - bk72xx=detect_tz, - rtl87xx=detect_tz, - ln882x=detect_tz, - host=detect_tz, - ): cv.All( + cv.Optional(CONF_TIMEZONE): cv.All( cv.only_with_framework(["arduino", "esp-idf", "host"]), validate_tz, ), @@ -384,7 +397,11 @@ def _emit_parsed_timezone_fields(parsed): async def setup_time_core_(time_var, config): - if timezone := config.get(CONF_TIMEZONE): + timezone = config.get(CONF_TIMEZONE) + # an empty timezone is treated as disabling timezones completely as before + if timezone is None: + timezone = detect_tz() + if timezone: cg.add_define("USE_TIME_TIMEZONE") if CORE.is_host: @@ -392,8 +409,11 @@ async def setup_time_core_(time_var, config): cg.add(time_var.set_timezone(timezone)) else: # Embedded: pre-parse at codegen time, emit struct directly - parsed = parse_posix_tz_python(timezone) - _emit_parsed_timezone_fields(parsed) + try: + parsed = parse_posix_tz_python(timezone) + _emit_parsed_timezone_fields(parsed) + except ValueError as e: + raise EsphomeError(f"Invalid timezone: {timezone}") from e for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3cb92616bb..0229bc14fa 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -71,6 +71,7 @@ #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU #define USE_HOMEASSISTANT_TIME +#define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE diff --git a/tests/component_tests/time/__init__.py b/tests/component_tests/time/__init__.py new file mode 100644 index 0000000000..dc24f4e532 --- /dev/null +++ b/tests/component_tests/time/__init__.py @@ -0,0 +1 @@ +"""Tests for the time component.""" diff --git a/tests/component_tests/time/test_init.py b/tests/component_tests/time/test_init.py new file mode 100644 index 0000000000..44469cfe28 --- /dev/null +++ b/tests/component_tests/time/test_init.py @@ -0,0 +1,369 @@ +"""Tests for time component – ha-timezone branch changes. + +Covers: +- detect_tz() platform guard (returns None for unsupported platforms) +- detect_tz() result caching (avoids duplicate log messages) +- detect_tz() error paths (tzlocal None, tzdata missing) +- validate_tz() accepts/rejects POSIX timezone strings and IANA keys +- TIME_SCHEMA: timezone is now truly optional (was SplitDefault) +- homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define emitted iff + CONF_TIMEZONE is absent from the config +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +from esphome.components.time import DOMAIN, TIME_SCHEMA, detect_tz, validate_tz +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_TIMEZONE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Platform, + PlatformFramework, +) +from esphome.core import CORE, EsphomeError +from tests.component_tests.types import SetCoreConfigCallable + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# A minimal TZif v2/v3 file that encodes "EST5EDT" as the footer line. +# The binary content is not validated at this level – what matters is that +# _extract_tz_string() picks up the last-but-one newline-terminated line. +_FAKE_TZFILE = b"\x00" * 44 + b"TZif2\x00" * 1 + b"\n" + b"EST5EDT,M3.2.0,M11.1.0\n" + + +def _set_platform(platform: Platform) -> None: + """Set CORE.data so that CORE.target_platform returns *platform*.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: "arduino", + } + + +# --------------------------------------------------------------------------- +# detect_tz – platform guard +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "platform_framework", + [ + PlatformFramework.NRF52_ZEPHYR, + ], +) +def test_detect_tz_returns_none_for_unsupported_platform( + platform_framework: PlatformFramework, + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must return None for platforms that do not support TZ auto-detection.""" + set_core_config(platform_framework) + result = detect_tz() + assert result is None + + +@pytest.mark.parametrize( + "platform_framework", + [ + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP8266_ARDUINO, + PlatformFramework.RP2040_ARDUINO, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.HOST_NATIVE, + ], +) +def test_detect_tz_calls_tzlocal_for_supported_platform( + platform_framework: PlatformFramework, + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must call tzlocal for every supported platform.""" + set_core_config(platform_framework) + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="America/New_York", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ), + ): + result = detect_tz() + assert result is not None + assert isinstance(result, str) + assert len(result) > 0 + + +# --------------------------------------------------------------------------- +# detect_tz – caching +# --------------------------------------------------------------------------- + + +def test_detect_tz_caches_result( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """detect_tz() must cache the TZ string after the first call so that + subsequent invocations (e.g. when multiple time platforms are configured) + skip tzlocal and avoid duplicate INFO messages.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="America/New_York", + ) as mock_tz, + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ) as mock_load, + ): + first = detect_tz() + second = detect_tz() + + assert first == second + # tzlocal and _load_tzdata must be called exactly once despite two detect_tz() calls + mock_tz.assert_called_once() + mock_load.assert_called_once() + + +def test_detect_tz_cache_stored_in_core_data( + set_core_config: SetCoreConfigCallable, +) -> None: + """The cached TZ string should be stored under CORE.data[DOMAIN][CONF_TIMEZONE].""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="Europe/London", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ), + ): + result = detect_tz() + + assert CORE.data.get(DOMAIN, {}).get(CONF_TIMEZONE) == result + + +def test_detect_tz_returns_pre_seeded_cache( + set_core_config: SetCoreConfigCallable, +) -> None: + """If CORE.data already has a cached TZ string, detect_tz() must return it + without calling tzlocal at all.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[DOMAIN] = {CONF_TIMEZONE: "CET-1CEST,M3.5.0,M10.5.0/3"} + + with mock.patch("esphome.components.time.tzlocal.get_localzone_name") as mock_tz: + result = detect_tz() + + assert result == "CET-1CEST,M3.5.0,M10.5.0/3" + mock_tz.assert_not_called() + + +# --------------------------------------------------------------------------- +# detect_tz – error paths +# --------------------------------------------------------------------------- + + +def test_detect_tz_raises_when_tzlocal_returns_none( + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must raise EsphomeError when the local timezone cannot be determined.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value=None, + ), + pytest.raises(EsphomeError, match="Could not automatically determine timezone"), + ): + detect_tz() + + +def test_detect_tz_raises_when_tzdata_not_found( + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must raise EsphomeError when tzdata has no entry for the IANA key.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="Antarctica/Troll", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=None, + ), + pytest.raises(EsphomeError, match="Could not automatically determine timezone"), + ): + detect_tz() + + +# --------------------------------------------------------------------------- +# validate_tz +# --------------------------------------------------------------------------- + + +def test_validate_tz_accepts_valid_posix_string() -> None: + """validate_tz() must accept a syntactically valid POSIX TZ string.""" + result = validate_tz("UTC0") + assert result == "UTC0" + + +def test_validate_tz_accepts_posix_string_with_dst() -> None: + """validate_tz() must accept a full POSIX TZ string with DST rules.""" + tz = "EST5EDT,M3.2.0,M11.1.0" + result = validate_tz(tz) + assert result == tz + + +def test_validate_tz_accepts_iana_key_and_converts() -> None: + """validate_tz() must accept an IANA timezone key and return the POSIX string.""" + with mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ): + result = validate_tz("America/New_York") + + # Should have been converted from IANA to POSIX via _extract_tz_string + assert result == "EST5EDT,M3.2.0,M11.1.0" + + +def test_validate_tz_rejects_invalid_posix_string() -> None: + """validate_tz() must raise cv.Invalid for a malformed POSIX TZ string.""" + with pytest.raises(cv.Invalid, match="Invalid POSIX timezone string"): + validate_tz("NOTAVALIDTZ!!!") + + +def test_validate_tz_accepts_empty_string() -> None: + """An empty string is accepted by validate_tz() and signals 'disable timezone'.""" + result = validate_tz("") + assert result == "" + + +# --------------------------------------------------------------------------- +# TIME_SCHEMA – timezone is now cv.Optional (no SplitDefault) +# --------------------------------------------------------------------------- + + +def test_time_schema_timezone_is_optional( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must accept a config with no timezone key on a supported platform.""" + set_core_config(PlatformFramework.ESP32_IDF) + # Should not raise + config = TIME_SCHEMA({}) + assert CONF_TIMEZONE not in config + + +def test_time_schema_explicit_timezone_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must accept an explicit valid POSIX timezone on Arduino/IDF.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = TIME_SCHEMA({CONF_TIMEZONE: "UTC0"}) + assert config[CONF_TIMEZONE] == "UTC0" + + +def test_time_schema_explicit_empty_timezone_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """An empty timezone string (timezone-disable sentinel) must pass TIME_SCHEMA.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = TIME_SCHEMA({CONF_TIMEZONE: ""}) + assert config[CONF_TIMEZONE] == "" + + +def test_time_schema_timezone_rejected_on_zephyr( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must reject a timezone value on Zephyr with the framework error. + + The platform check (cv.only_with_framework) must run BEFORE validate_tz so + that users receive an actionable "unsupported framework" message rather than a + confusing TZ-parsing error. + """ + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="only available with framework"): + TIME_SCHEMA({CONF_TIMEZONE: "UTC0"}) + + +def test_time_schema_invalid_tz_on_zephyr_gives_framework_error( + set_core_config: SetCoreConfigCallable, +) -> None: + """Even a syntactically invalid TZ string must produce the framework error on Zephyr. + + This specifically tests that cv.only_with_framework is evaluated before + validate_tz: if the order were reversed, an invalid POSIX string would + generate a misleading TZ-parsing error instead. + """ + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="only available with framework"): + TIME_SCHEMA({CONF_TIMEZONE: "NOTAVALIDTZ!!!"}) + + +# --------------------------------------------------------------------------- +# homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_ha_cg(): + """Mock codegen functions used by homeassistant/time to_code.""" + with ( + mock.patch( + "esphome.components.homeassistant.time.cg.new_Pvariable", + return_value=mock.MagicMock(), + ), + mock.patch( + "esphome.components.homeassistant.time.cg.add_define", + ) as mock_add_define, + mock.patch( + "esphome.components.homeassistant.time.cg.register_component", + new_callable=mock.AsyncMock, + ), + mock.patch( + "esphome.components.homeassistant.time.time_.register_time", + new_callable=mock.AsyncMock, + ), + ): + yield mock_add_define + + +@pytest.mark.asyncio +async def test_ha_time_defines_ha_timezone_when_no_explicit_tz(mock_ha_cg) -> None: + """When CONF_TIMEZONE is absent from the config, to_code() must call + cg.add_define('USE_HOMEASSISTANT_TIMEZONE').""" + from esphome.components.homeassistant.time import to_code + + await to_code({CONF_ID: mock.MagicMock()}) + + mock_ha_cg.assert_any_call("USE_HOMEASSISTANT_TIMEZONE") + + +@pytest.mark.asyncio +async def test_ha_time_no_ha_timezone_define_when_explicit_tz(mock_ha_cg) -> None: + """When CONF_TIMEZONE is present in the config, to_code() must NOT call + cg.add_define('USE_HOMEASSISTANT_TIMEZONE').""" + from esphome.components.homeassistant.time import to_code + + await to_code({CONF_ID: mock.MagicMock(), CONF_TIMEZONE: "UTC0"}) + + define_calls = [call.args[0] for call in mock_ha_cg.call_args_list] + assert "USE_HOMEASSISTANT_TIME" in define_calls + assert "USE_HOMEASSISTANT_TIMEZONE" not in define_calls