From 0e18e4461e8d82df75b3acbb61c7dcc8c9993311 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Mar 2026 16:52:53 -1000 Subject: [PATCH] [time,api] Send pre-parsed timezone struct over protobuf (#14233) Co-authored-by: Claude Opus 4.6 --- esphome/components/api/api.proto | 28 ++++++++++ esphome/components/api/api_connection.cpp | 25 ++++++++- esphome/components/api/api_pb2.cpp | 54 ++++++++++++++++++ esphome/components/api/api_pb2.h | 38 ++++++++++++- esphome/components/api/api_pb2_dump.cpp | 39 +++++++++++++ esphome/components/time/__init__.py | 68 +++++++++++++++++++++-- 6 files changed, 245 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d7f32cd8d1..802e3e3ae2 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -834,6 +834,33 @@ message GetTimeRequest { option (source) = SOURCE_SERVER; } +enum DSTRuleType { + DST_RULE_TYPE_NONE = 0; + DST_RULE_TYPE_MONTH_WEEK_DAY = 1; + DST_RULE_TYPE_JULIAN_NO_LEAP = 2; + DST_RULE_TYPE_DAY_OF_YEAR = 3; +} + +message DSTRule { + option (source) = SOURCE_CLIENT; + + sint32 time_seconds = 1; + uint32 day = 2; + DSTRuleType type = 3; + uint32 month = 4; + uint32 week = 5; + uint32 day_of_week = 6; +} + +message ParsedTimezone { + option (source) = SOURCE_CLIENT; + + sint32 std_offset_seconds = 1; + sint32 dst_offset_seconds = 2; + DSTRule dst_start = 3; + DSTRule dst_end = 4; +} + message GetTimeResponse { option (id) = 37; option (source) = SOURCE_CLIENT; @@ -841,6 +868,7 @@ message GetTimeResponse { fixed32 epoch_seconds = 1; string timezone = 2; + ParsedTimezone parsed_timezone = 3; } // ==================== USER-DEFINES SERVICES ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 215af611db..90287ec2dd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1123,7 +1123,30 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); #ifdef USE_TIME_TIMEZONE if (!value.timezone.empty()) { - homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size()); + // 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. + // For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent. + const auto &pt = value.parsed_timezone; + if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) { + time::ParsedTimezone tz{}; + tz.std_offset_seconds = pt.std_offset_seconds; + tz.dst_offset_seconds = pt.dst_offset_seconds; + tz.dst_start.time_seconds = pt.dst_start.time_seconds; + tz.dst_start.day = static_cast(pt.dst_start.day); + tz.dst_start.type = static_cast(pt.dst_start.type); + tz.dst_start.month = static_cast(pt.dst_start.month); + tz.dst_start.week = static_cast(pt.dst_start.week); + tz.dst_start.day_of_week = static_cast(pt.dst_start.day_of_week); + tz.dst_end.time_seconds = pt.dst_end.time_seconds; + tz.dst_end.day = static_cast(pt.dst_end.day); + tz.dst_end.type = static_cast(pt.dst_end.type); + tz.dst_end.month = static_cast(pt.dst_end.month); + tz.dst_end.week = static_cast(pt.dst_end.week); + tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week); + time::set_global_tz(tz); + } else { + homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size()); + } } #endif } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 5c50a8aa5b..9e74d5ddc7 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -954,12 +954,66 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel return true; } #endif +bool DSTRule::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->time_seconds = value.as_sint32(); + break; + case 2: + this->day = value.as_uint32(); + break; + case 3: + this->type = static_cast(value.as_uint32()); + break; + case 4: + this->month = value.as_uint32(); + break; + case 5: + this->week = value.as_uint32(); + break; + case 6: + this->day_of_week = value.as_uint32(); + break; + default: + return false; + } + return true; +} +bool ParsedTimezone::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->std_offset_seconds = value.as_sint32(); + break; + case 2: + this->dst_offset_seconds = value.as_sint32(); + break; + default: + return false; + } + return true; +} +bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 3: + value.decode_to_message(this->dst_start); + break; + case 4: + value.decode_to_message(this->dst_end); + break; + default: + return false; + } + return true; +} bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); break; } + case 3: + value.decode_to_message(this->parsed_timezone); + break; default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 22dc3de995..033b789c18 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -63,6 +63,12 @@ enum LogLevel : uint32_t { LOG_LEVEL_VERBOSE = 6, LOG_LEVEL_VERY_VERBOSE = 7, }; +enum DSTRuleType : uint32_t { + DST_RULE_TYPE_NONE = 0, + DST_RULE_TYPE_MONTH_WEEK_DAY = 1, + DST_RULE_TYPE_JULIAN_NO_LEAP = 2, + DST_RULE_TYPE_DAY_OF_YEAR = 3, +}; #ifdef USE_API_USER_DEFINED_ACTIONS enum ServiceArgType : uint32_t { SERVICE_ARG_TYPE_BOOL = 0, @@ -1116,15 +1122,45 @@ class GetTimeRequest final : public ProtoMessage { protected: }; +class DSTRule final : public ProtoDecodableMessage { + public: + int32_t time_seconds{0}; + uint32_t day{0}; + enums::DSTRuleType type{}; + uint32_t month{0}; + uint32_t week{0}; + uint32_t day_of_week{0}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class ParsedTimezone final : public ProtoDecodableMessage { + public: + int32_t std_offset_seconds{0}; + int32_t dst_offset_seconds{0}; + DSTRule dst_start{}; + DSTRule dst_end{}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; class GetTimeResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 37; - static constexpr uint8_t ESTIMATED_SIZE = 14; + static constexpr uint8_t ESTIMATED_SIZE = 31; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "get_time_response"; } #endif uint32_t epoch_seconds{0}; StringRef timezone{}; + ParsedTimezone parsed_timezone{}; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 52d2486410..4eec42e936 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -208,6 +208,20 @@ template<> const char *proto_enum_to_string(enums::LogLevel val return "UNKNOWN"; } } +template<> const char *proto_enum_to_string(enums::DSTRuleType value) { + switch (value) { + case enums::DST_RULE_TYPE_NONE: + return "DST_RULE_TYPE_NONE"; + case enums::DST_RULE_TYPE_MONTH_WEEK_DAY: + return "DST_RULE_TYPE_MONTH_WEEK_DAY"; + case enums::DST_RULE_TYPE_JULIAN_NO_LEAP: + return "DST_RULE_TYPE_JULIAN_NO_LEAP"; + case enums::DST_RULE_TYPE_DAY_OF_YEAR: + return "DST_RULE_TYPE_DAY_OF_YEAR"; + default: + return "UNKNOWN"; + } +} #ifdef USE_API_USER_DEFINED_ACTIONS template<> const char *proto_enum_to_string(enums::ServiceArgType value) { switch (value) { @@ -1254,10 +1268,35 @@ const char *GetTimeRequest::dump_to(DumpBuffer &out) const { out.append("GetTimeRequest {}"); return out.c_str(); } +const char *DSTRule::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "DSTRule"); + dump_field(out, "time_seconds", this->time_seconds); + dump_field(out, "day", this->day); + dump_field(out, "type", static_cast(this->type)); + dump_field(out, "month", this->month); + dump_field(out, "week", this->week); + dump_field(out, "day_of_week", this->day_of_week); + return out.c_str(); +} +const char *ParsedTimezone::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "ParsedTimezone"); + dump_field(out, "std_offset_seconds", this->std_offset_seconds); + dump_field(out, "dst_offset_seconds", this->dst_offset_seconds); + out.append(" dst_start: "); + this->dst_start.dump_to(out); + out.append("\n"); + out.append(" dst_end: "); + this->dst_end.dump_to(out); + out.append("\n"); + return out.c_str(); +} const char *GetTimeResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "GetTimeResponse"); dump_field(out, "epoch_seconds", this->epoch_seconds); dump_field(out, "timezone", this->timezone); + out.append(" parsed_timezone: "); + this->parsed_timezone.dump_to(out); + out.append("\n"); return out.c_str(); } #ifdef USE_API_USER_DEFINED_ACTIONS diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index a20d79b857..7ffa408db9 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,6 +1,10 @@ from importlib import resources import logging +from aioesphomeapi.posix_tz import ( + DSTRuleType as PyDSTRuleType, + parse_posix_tz as parse_posix_tz_python, +) import tzlocal from esphome import automation @@ -39,6 +43,19 @@ CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Co SyncTrigger = time_ns.class_("SyncTrigger", automation.Trigger.template(), cg.Component) TimeHasTimeCondition = time_ns.class_("TimeHasTimeCondition", Condition) +# C++ types for pre-parsed timezone struct generation +DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) +DSTRule_cpp = time_ns.struct("DSTRule") +ParsedTimezone_cpp = time_ns.struct("ParsedTimezone") + +# Map Python DSTRuleType enum values to C++ enum expressions +_DST_RULE_TYPE_MAP = { + PyDSTRuleType.NONE: DSTRuleType_cpp.NONE, + PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY, + PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP, + PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR, +} + def _load_tzdata(iana_key: str) -> bytes | None: # From https://tzdata.readthedocs.io/en/latest/#examples @@ -260,11 +277,17 @@ def validate_tz(value: str) -> str: value = cv.string_strict(value) tzfile = _load_tzdata(value) - if tzfile is None: - # Not a IANA key, probably a TZ string - return value + if tzfile is not None: + value = _extract_tz_string(tzfile) - return _extract_tz_string(tzfile) + # Validate that the POSIX TZ string is parseable (skip empty strings) + if value: + try: + parse_posix_tz_python(value) + except ValueError as e: + raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e + + return value TIME_SCHEMA = cv.Schema( @@ -305,11 +328,46 @@ TIME_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("15min")) +def _emit_dst_rule_fields(prefix, rule): + """Emit field-by-field assignments for a DSTRule to avoid rodata struct blob.""" + cg.add(cg.RawExpression(f"{prefix}.time_seconds = {rule.time_seconds}")) + cg.add(cg.RawExpression(f"{prefix}.day = {rule.day}")) + cg.add(cg.RawExpression(f"{prefix}.type = {_DST_RULE_TYPE_MAP[rule.type]}")) + cg.add(cg.RawExpression(f"{prefix}.month = {rule.month}")) + cg.add(cg.RawExpression(f"{prefix}.week = {rule.week}")) + cg.add(cg.RawExpression(f"{prefix}.day_of_week = {rule.day_of_week}")) + + +def _emit_parsed_timezone_fields(parsed): + """Emit field-by-field assignments for a local ParsedTimezone, then set_global_tz(). + + Uses individual assignments on a stack variable instead of a struct initializer + to keep constants as immediate operands in instructions (.irom0.text/flash) + rather than a const blob in .rodata (which maps to RAM on ESP8266). + Wrapped in a scope block to allow multiple time platforms in the same build. + """ + cg.add(cg.RawStatement("{")) + cg.add(cg.RawExpression("time::ParsedTimezone tz{}")) + cg.add(cg.RawExpression(f"tz.std_offset_seconds = {parsed.std_offset_seconds}")) + cg.add(cg.RawExpression(f"tz.dst_offset_seconds = {parsed.dst_offset_seconds}")) + _emit_dst_rule_fields("tz.dst_start", parsed.dst_start) + _emit_dst_rule_fields("tz.dst_end", parsed.dst_end) + cg.add(time_ns.set_global_tz(cg.RawExpression("tz"))) + cg.add(cg.RawStatement("}")) + + async def setup_time_core_(time_var, config): if timezone := config.get(CONF_TIMEZONE): - cg.add(time_var.set_timezone(timezone)) cg.add_define("USE_TIME_TIMEZONE") + if CORE.is_host: + # Host platform needs setenv("TZ")/tzset() for libc compatibility + 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) + for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)