Track parsed_timezone presence and escape host TZ codegen

This commit is contained in:
J. Nick Koston
2026-08-14 14:18:20 -05:00
parent dffe9863d1
commit c8380daf85
8 changed files with 46 additions and 8 deletions
+1 -1
View File
@@ -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 ====================
+5 -6
View File
@@ -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;
+6
View File
@@ -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_<field>{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];
}
+1
View File
@@ -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:
+1
View File
@@ -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
+2 -1
View File
@@ -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
+20
View File
@@ -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:
+10
View File
@@ -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"