diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ac3bacae6c..a5e7440243 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1208,7 +1208,13 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { // and newer); field presence distinguishes a genuine all-zero UTC timezone from an // absent field. Older clients send only the deprecated timezone string, which is no // longer decoded; for them the device keeps its codegen-configured timezone. - if (value.has_parsed_timezone) { + if (!value.has_parsed_timezone) { + static bool warned = false; + if (!warned) { + warned = true; + ESP_LOGW(TAG, "Time source sent no parsed timezone; keeping configured timezone. Update Home Assistant"); + } + } else { const auto &pt = value.parsed_timezone; time::ParsedTimezone tz{}; tz.std_offset_seconds = pt.std_offset_seconds; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 2f8c64e072..c3037caa4e 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -946,9 +946,10 @@ class MessageType(TypeInfo): @property def _track_presence(self) -> bool: - # Use getattr to handle older versions of api_options_pb2 - opt = getattr(pb, "track_presence", None) - return opt is not None and get_field_opt(self._field, opt, False) + # Presence is only observable on the decode side + return self._needs_decode and get_field_opt( + self._field, pb.track_presence, False + ) @property def decode_length_content(self) -> str: diff --git a/tests/integration/fixtures/api_get_time_response_timezone.yaml b/tests/integration/fixtures/api_get_time_response_timezone.yaml new file mode 100644 index 0000000000..bece0c5684 --- /dev/null +++ b/tests/integration/fixtures/api_get_time_response_timezone.yaml @@ -0,0 +1,20 @@ +esphome: + name: get-time-tz-test +host: +api: +logger: + +time: + - platform: homeassistant + id: ha_time + +sensor: + # Exposes the standard offset of the effective timezone so the test can + # observe which GetTimeResponse messages changed it + - platform: template + name: "TZ Offset" + id: tz_offset + accuracy_decimals: 0 + update_interval: 100ms + lambda: |- + return time::get_global_tz().std_offset_seconds; diff --git a/tests/integration/test_api_get_time_response_timezone.py b/tests/integration/test_api_get_time_response_timezone.py new file mode 100644 index 0000000000..3aa5c374af --- /dev/null +++ b/tests/integration/test_api_get_time_response_timezone.py @@ -0,0 +1,64 @@ +"""Integration test for GetTimeResponse parsed_timezone presence handling.""" + +from __future__ import annotations + +from aioesphomeapi import connection as api_connection +from aioesphomeapi.api_pb2 import GetTimeResponse +import pytest + +from .state_utils import SensorTracker, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + +# 2024-01-01 00:00:00 UTC +EPOCH = 1704067200 +# POSIX offsets are positive west of UTC, so UTC+7 is -25200 and UTC-5 is 18000 +UTC_PLUS_7 = -25200 +UTC_MINUS_5 = 18000 + + +@pytest.mark.asyncio +async def test_api_get_time_response_timezone( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A present parsed_timezone is applied even when all zero; an absent one is ignored.""" + # The client answers the device's own GetTimeRequest with the host timezone; + # strip the parsed field from that reply so only the messages sent below + # can change the device timezone. + monkeypatch.setattr(api_connection, "_build_parsed_tz_proto", lambda tz: None) + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + tracker = SensorTracker(["tz_offset"]) + tracker.key_to_sensor = build_key_to_entity_mapping(entities, ["tz_offset"]) + client.subscribe_states(tracker.on_state) + + await tracker.await_change(tracker.expect_any("tz_offset"), "tz_offset") + initial = tracker.sensor_states["tz_offset"][-1] + # Pick a zone that differs from the codegen default so the change is visible + target = UTC_PLUS_7 if initial != UTC_PLUS_7 else UTC_MINUS_5 + + # Present, non-zero: applied + future = tracker.expect("tz_offset", target) + resp = GetTimeResponse(epoch_seconds=EPOCH) + resp.parsed_timezone.std_offset_seconds = target + resp.parsed_timezone.dst_offset_seconds = target + client._connection.send_messages((resp,)) + await tracker.await_change(future, "tz_offset") + + # Absent (legacy client with only the deprecated string): ignored, and in + # particular not mistaken for an all-zero UTC zone + future = tracker.expect("tz_offset", 0) + resp = GetTimeResponse(epoch_seconds=EPOCH, timezone="UTC0") + client._connection.send_messages((resp,)) + await tracker.await_must_not_change(future, "tz_offset", timeout=1.0) + assert tracker.sensor_states["tz_offset"][-1] == target + + # Present but all zero (genuine UTC): applied + future = tracker.expect("tz_offset", 0) + resp = GetTimeResponse(epoch_seconds=EPOCH) + resp.parsed_timezone.SetInParent() + client._connection.send_messages((resp,)) + await tracker.await_change(future, "tz_offset")