Merge branch 'posix_tz_proto' into integration

This commit is contained in:
J. Nick Koston
2026-02-23 16:02:34 -06:00
19 changed files with 308 additions and 20 deletions

View File

@@ -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 ====================

View File

@@ -1113,7 +1113,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<uint16_t>(pt.dst_start.day);
tz.dst_start.type = static_cast<time::DSTRuleType>(pt.dst_start.type);
tz.dst_start.month = static_cast<uint8_t>(pt.dst_start.month);
tz.dst_start.week = static_cast<uint8_t>(pt.dst_start.week);
tz.dst_start.day_of_week = static_cast<uint8_t>(pt.dst_start.day_of_week);
tz.dst_end.time_seconds = pt.dst_end.time_seconds;
tz.dst_end.day = static_cast<uint16_t>(pt.dst_end.day);
tz.dst_end.type = static_cast<time::DSTRuleType>(pt.dst_end.type);
tz.dst_end.month = static_cast<uint8_t>(pt.dst_end.month);
tz.dst_end.week = static_cast<uint8_t>(pt.dst_end.week);
tz.dst_end.day_of_week = static_cast<uint8_t>(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
}

View File

@@ -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<enums::DSTRuleType>(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<const char *>(value.data()), value.size());
break;
}
case 3:
value.decode_to_message(this->parsed_timezone);
break;
default:
return false;
}

View File

@@ -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,
@@ -1115,15 +1121,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

View File

@@ -208,6 +208,20 @@ template<> const char *proto_enum_to_string<enums::LogLevel>(enums::LogLevel val
return "UNKNOWN";
}
}
template<> const char *proto_enum_to_string<enums::DSTRuleType>(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>(enums::ServiceArgType value) {
switch (value) {
@@ -1252,10 +1266,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<enums::DSTRuleType>(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

View File

@@ -14,6 +14,7 @@ from esphome.const import (
CONF_BOARD,
CONF_COMPONENTS,
CONF_DISABLED,
CONF_ENABLE_OTA_ROLLBACK,
CONF_ESPHOME,
CONF_FRAMEWORK,
CONF_IGNORE_EFUSE_CUSTOM_MAC,
@@ -90,7 +91,6 @@ CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES = "enable_idf_experimental_features"
CONF_ENGINEERING_SAMPLE = "engineering_sample"
CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components"
CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert"
CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback"
CONF_EXECUTE_FROM_PSRAM = "execute_from_psram"
CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision"
CONF_RELEASE = "release"

View File

@@ -24,8 +24,29 @@ namespace http_request {
static const char *const TAG = "http_request.update";
static const size_t MAX_READ_SIZE = 256;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000;
static constexpr uint8_t INITIAL_CHECK_MAX_ATTEMPTS = 6;
void HttpRequestUpdate::setup() { this->ota_parent_->add_state_listener(this); }
void HttpRequestUpdate::setup() {
this->ota_parent_->add_state_listener(this);
// Check periodically until network is ready
// Only if update interval is > total retry window to avoid redundant checks
if (this->get_update_interval() != SCHEDULER_DONT_RUN &&
this->get_update_interval() > INITIAL_CHECK_INTERVAL_MS * INITIAL_CHECK_MAX_ATTEMPTS) {
this->initial_check_remaining_ = INITIAL_CHECK_MAX_ATTEMPTS;
this->set_interval(INITIAL_CHECK_INTERVAL_ID, INITIAL_CHECK_INTERVAL_MS, [this]() {
bool connected = network::is_connected();
if (--this->initial_check_remaining_ == 0 || connected) {
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
if (connected) {
this->update();
}
}
});
}
}
void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_t error) {
if (state == ota::OTAState::OTA_IN_PROGRESS) {
@@ -45,6 +66,7 @@ void HttpRequestUpdate::update() {
ESP_LOGD(TAG, "Network not connected, skipping update check");
return;
}
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
#ifdef USE_ESP32
xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_);
#else

View File

@@ -40,6 +40,7 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
#ifdef USE_ESP32
TaskHandle_t update_task_handle_{nullptr};
#endif
uint8_t initial_check_remaining_{0};
};
} // namespace http_request

View File

@@ -20,8 +20,6 @@ __attribute__((weak)) void print_coredump() {}
namespace esphome::logger {
static const uint32_t CRASH_MAGIC = 0xDEADBEEF;
__attribute__((section(".noinit"))) struct {
uint32_t magic;
uint32_t reason;
@@ -152,7 +150,7 @@ static const char *reason_to_str(unsigned int reason, char *buf) {
void Logger::dump_crash_() {
ESP_LOGD(TAG, "Crash buffer address %p", &crash_buf);
if (crash_buf.magic == CRASH_MAGIC) {
if (crash_buf.magic == App.get_config_hash()) {
char reason_buf[REASON_BUF_SIZE];
ESP_LOGE(TAG, "Last crash:");
ESP_LOGE(TAG, "Reason=%s PC=0x%08x LR=0x%08x", reason_to_str(crash_buf.reason, reason_buf), crash_buf.pc,
@@ -164,7 +162,7 @@ void Logger::dump_crash_() {
}
void k_sys_fatal_error_handler(unsigned int reason, const z_arch_esf_t *esf) {
crash_buf.magic = CRASH_MAGIC;
crash_buf.magic = App.get_config_hash();
crash_buf.reason = reason;
if (esf) {
crash_buf.pc = esf->basic.pc;

View File

@@ -237,7 +237,7 @@ async def to_code(config):
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"remote_receiver_esp32.cpp": {
"remote_receiver_rmt.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},

View File

@@ -171,7 +171,7 @@ async def to_code(config):
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"remote_transmitter_esp32.cpp": {
"remote_transmitter_rmt.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},

View File

@@ -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)

View File

@@ -17,7 +17,8 @@ const ParsedTimezone &get_global_tz() { return global_tz_; }
namespace internal {
// Helper to parse an unsigned integer from string, updating pointer
// Remove before 2026.9.0: parse_uint, skip_tz_name, parse_offset, parse_dst_rule,
// and parse_transition_time are only used by parse_posix_tz() (bridge code).
static uint32_t parse_uint(const char *&p) {
uint32_t value = 0;
while (std::isdigit(static_cast<unsigned char>(*p))) {
@@ -364,6 +365,12 @@ bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone
}
}
// Remove before 2026.9.0: This parser is bridge code for backward compatibility with
// older Home Assistant clients that send the timezone as a POSIX TZ string instead of
// the pre-parsed ParsedTimezone protobuf struct. Once all clients send the struct
// directly, this function and the parsing helpers above (skip_tz_name, parse_offset,
// parse_dst_rule, parse_transition_time) can be removed.
// See https://github.com/esphome/backlog/issues/91
bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) {
if (!tz_string || !*tz_string) {
return false;

View File

@@ -37,6 +37,14 @@ struct ParsedTimezone {
};
/// Parse a POSIX TZ string into a ParsedTimezone struct.
///
/// @deprecated Remove before 2026.9.0 (bridge code for backward compatibility).
/// This parser only exists so that older Home Assistant clients that send the timezone
/// as a string (instead of the pre-parsed ParsedTimezone protobuf struct) can still
/// set the timezone on the device. Once all clients are updated to send the struct
/// directly, this function and all internal parsing helpers will be removed.
/// See https://github.com/esphome/backlog/issues/91
///
/// Supports formats like:
/// - "EST5" (simple offset, no DST)
/// - "EST5EDT,M3.2.0,M11.1.0" (with DST, M-format rules)
@@ -72,7 +80,11 @@ const ParsedTimezone &get_global_tz();
/// @return true if DST is in effect at the given time
bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz);
// Internal helper functions exposed for testing
// Internal helper functions exposed for testing.
// Remove before 2026.9.0: skip_tz_name, parse_offset, parse_dst_rule are only
// used by parse_posix_tz() which is bridge code for backward compatibility.
// The remaining helpers (epoch_to_tm_utc, day_of_week, days_in_month, etc.)
// are used by the conversion functions and will stay.
namespace internal {

View File

@@ -354,6 +354,7 @@ CONF_ELSE = "else"
CONF_ENABLE_BTM = "enable_btm"
CONF_ENABLE_IPV6 = "enable_ipv6"
CONF_ENABLE_ON_BOOT = "enable_on_boot"
CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback"
CONF_ENABLE_PIN = "enable_pin"
CONF_ENABLE_PRIVATE_NETWORK_ACCESS = "enable_private_network_access"
CONF_ENABLE_RRM = "enable_rrm"

View File

@@ -12,7 +12,7 @@ platformio==6.1.19
esptool==5.2.0
click==8.1.7
esphome-dashboard==20260210.0
aioesphomeapi==44.0.0
aioesphomeapi==44.1.0
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import

View File

@@ -1,5 +1,14 @@
// Tests for the POSIX TZ parser and ESPTime::strptime implementations
// These custom parsers avoid pulling in the scanf family, saving ~9.8KB on ESP32-IDF.
// Tests for the POSIX TZ parser, time conversion functions, and ESPTime::strptime.
//
// Most tests here cover the C++ POSIX TZ string parser (parse_posix_tz), which is
// bridge code for backward compatibility — it will be removed before ESPHome 2026.9.0.
// After https://github.com/esphome/esphome/pull/14233 merges, the parser is solely
// used to handle timezone strings from Home Assistant clients older than 2026.3.0
// that haven't been updated to send the pre-parsed ParsedTimezone protobuf struct.
// See https://github.com/esphome/backlog/issues/91
//
// The epoch_to_local_tm, is_in_dst, and ESPTime::strptime tests cover conversion
// functions that will remain permanently.
// Enable USE_TIME_TIMEZONE for tests
#define USE_TIME_TIMEZONE