Warn once for legacy time clients, fail loudly on stale api_options, add presence integration test

This commit is contained in:
J. Nick Koston
2026-08-21 14:44:46 -05:00
parent ae5d197842
commit bf8bb02189
4 changed files with 95 additions and 4 deletions
+7 -1
View File
@@ -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 // 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 // absent field. Older clients send only the deprecated timezone string, which is no
// longer decoded; for them the device keeps its codegen-configured timezone. // 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; const auto &pt = value.parsed_timezone;
time::ParsedTimezone tz{}; time::ParsedTimezone tz{};
tz.std_offset_seconds = pt.std_offset_seconds; tz.std_offset_seconds = pt.std_offset_seconds;
+4 -3
View File
@@ -946,9 +946,10 @@ class MessageType(TypeInfo):
@property @property
def _track_presence(self) -> bool: def _track_presence(self) -> bool:
# Use getattr to handle older versions of api_options_pb2 # Presence is only observable on the decode side
opt = getattr(pb, "track_presence", None) return self._needs_decode and get_field_opt(
return opt is not None and get_field_opt(self._field, opt, False) self._field, pb.track_presence, False
)
@property @property
def decode_length_content(self) -> str: def decode_length_content(self) -> str:
@@ -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;
@@ -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")