diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d9baf2e455..6ea124d155 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1007,7 +1007,7 @@ message GetTimeResponse { // Assistant 2026.3.0 that send only the string leave the device on its // codegen-configured timezone (or UTC). string timezone = 2 [deprecated = true]; - ParsedTimezone parsed_timezone = 3; + ParsedTimezone parsed_timezone = 3 [(track_presence) = true]; } // ==================== USER-DEFINES SERVICES ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 8aa99d3bf2..8c28852029 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1205,12 +1205,11 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); #if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE) // Apply only if the sender provided pre-parsed timezone data (Home Assistant 2026.3.0 - // and newer). Older clients send only the deprecated timezone string, which is no - // longer decoded; for them the struct stays all-zero and the device keeps its - // codegen-configured timezone. Actual UTC (all zeros) is also skipped, which is - // harmless since UTC is the default. - const auto &pt = value.parsed_timezone; - if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) { + // 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) { + const auto &pt = value.parsed_timezone; time::ParsedTimezone tz{}; tz.std_offset_seconds = pt.std_offset_seconds; tz.dst_offset_seconds = pt.dst_offset_seconds; diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index ac9c4e59cc..66295b3d53 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -116,4 +116,10 @@ extend google.protobuf.FieldOptions { // the per-byte loop when the upper bits are non-zero (the common case // for real MAC addresses, since OUIs occupy the top 24 bits). optional bool mac_address = 50019 [default=false]; + + // track_presence: Track whether this message-typed field was present on the wire. + // Generates a `bool has_{false};` member on the decoding side that is set + // to true when the field arrives, so an all-default submessage can be told apart + // from an absent one (e.g. a UTC ParsedTimezone, which is all zeros). + optional bool track_presence = 50020 [default=false]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 09f0430dab..4f59b85260 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1250,6 +1250,7 @@ bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: + this->has_parsed_timezone = true; value.decode_to_message(this->parsed_timezone); break; default: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 81febb01b1..13db857467 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1289,6 +1289,7 @@ class GetTimeResponse final : public ProtoDecodableMessage { #endif uint32_t epoch_seconds{0}; ParsedTimezone parsed_timezone{}; + bool has_parsed_timezone{false}; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 3ba10e0db1..94ff6ab051 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -36,6 +36,7 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.helpers import cpp_string_escape _LOGGER = logging.getLogger(__name__) @@ -412,7 +413,7 @@ async def setup_time_core_(time_var, config): if CORE.is_host: # Host platform also needs setenv("TZ")/tzset() for libc compatibility - cg.add(cg.RawExpression(f'setenv("TZ", "{timezone}", 1)')) + cg.add(cg.RawExpression(f'setenv("TZ", {cpp_string_escape(timezone)}, 1)')) cg.add(cg.RawExpression("tzset()")) # Pre-parse at codegen time, emit struct directly diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f4eff4a254..2f8c64e072 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -937,9 +937,29 @@ class MessageType(TypeInfo): # runtime polymorphism through virtual function calls. return None + @property + def public_content(self) -> list[str]: + content = [self.class_member] + if self._track_presence: + content.append(f"bool has_{self.name}{{false}};") + return content + + @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) + @property def decode_length_content(self) -> str: # Custom decode that doesn't use templates + if self._track_presence: + return ( + f"case {self.number}:\n" + f" this->has_{self.name} = true;\n" + f" value.decode_to_message(this->{self.field_name});\n" + f" break;" + ) return f"case {self.number}: value.decode_to_message(this->{self.field_name}); break;" def dump(self, name: str) -> str: diff --git a/tests/components/time/test.host.yaml b/tests/components/time/test.host.yaml new file mode 100644 index 0000000000..f6bec9fd1d --- /dev/null +++ b/tests/components/time/test.host.yaml @@ -0,0 +1,10 @@ +network: + +api: + +time: + - platform: homeassistant + # Angle-bracket name pins the explicit-timezone host codegen path + # (setenv/tzset plus pre-parsed struct emission) with characters that + # would break unescaped string interpolation. + timezone: "<+07>-7"