Compare commits

..

6 Commits

Author SHA1 Message Date
J. Nick Koston
ca99f1bda4 [esp32_hosted] Replace sscanf with strtol for version parsing 2026-01-30 20:31:52 -06:00
J0k3r2k1
5e3561d60b [mipi_spi] Fix log_pin() FlashStringHelper compatibility (#13624)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-01-30 14:33:45 -06:00
Thomas Rupprecht
ca9ed369f9 [pmsx003] support device-types PMS1003, PMS3003, PMS9003M (#13640) 2026-01-30 14:59:47 -05:00
J. Nick Koston
4e96b20b46 [mqtt] Restore ESP8266 on_message defer to prevent stack overflow (#13648) 2026-01-30 12:49:14 -06:00
J. Nick Koston
a1a60c44da [web_server_base] Update ESPAsyncWebServer to 3.9.6 (#13639) 2026-01-30 12:48:34 -06:00
Shivam Maurya
898c8a5836 [core] ESP32 chip revision text (#13647) 2026-01-30 11:01:00 -05:00
22 changed files with 327 additions and 2259 deletions

View File

@@ -1 +1 @@
cf3d341206b4184ec8b7fe85141aef4fe4696aa720c3f8a06d4e57930574bdab
069fa9526c52f7c580a9ec17c7678d12f142221387e9b561c18f95394d4629a3

View File

@@ -34,14 +34,29 @@ static const char *const ESP_HOSTED_VERSION_STR = STRINGIFY(ESP_HOSTED_VERSION_M
ESP_HOSTED_VERSION_MINOR_1) "." STRINGIFY(ESP_HOSTED_VERSION_PATCH_1);
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
// Parse an integer from str, advancing ptr past the number
// Returns false if no digits were parsed
static bool parse_int(const char *&ptr, int &value) {
char *end;
value = static_cast<int>(strtol(ptr, &end, 10));
if (end == ptr)
return false;
ptr = end;
return true;
}
// Parse version string "major.minor.patch" into components
// Returns true if parsing succeeded
// Returns true if at least major.minor was parsed
static bool parse_version(const std::string &version_str, int &major, int &minor, int &patch) {
major = minor = patch = 0;
if (sscanf(version_str.c_str(), "%d.%d.%d", &major, &minor, &patch) >= 2) {
return true;
}
return false;
const char *ptr = version_str.c_str();
if (!parse_int(ptr, major) || *ptr++ != '.' || !parse_int(ptr, minor))
return false;
if (*ptr == '.')
parse_int(++ptr, patch);
return true;
}
// Compare two versions, returns:

View File

@@ -205,7 +205,6 @@ async def to_code(config):
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
"pre:remove_float_scanf.py",
"post:post_build.py",
],
)
@@ -343,8 +342,3 @@ def copy_files() -> None:
exclude_waveform_file,
CORE.relative_build_path("exclude_waveform.py"),
)
remove_float_scanf_file = dir / "remove_float_scanf.py.script"
copy_file_if_changed(
remove_float_scanf_file,
CORE.relative_build_path("remove_float_scanf.py"),
)

View File

@@ -1,46 +0,0 @@
# pylint: disable=E0602
Import("env") # noqa
# Remove forced scanf linkage to allow garbage collection of unused code
#
# The ESP8266 Arduino framework unconditionally adds:
# -u _printf_float -u _scanf_float
#
# The -u flag forces symbols to be linked even if unreferenced, which pulls
# in the entire scanf family (~7-8KB). ESPHome doesn't use scanf at all
# (verified by CI check in PR #13657), so this is pure dead weight.
#
# By removing -u _scanf_float, --gc-sections can eliminate:
# - scanf family functions (~7KB)
# - _strtod_l (~3.7KB)
# - Related parsing infrastructure
#
# We keep -u _printf_float because components still use %f in logging.
def remove_scanf_float_flag(source, target, env):
"""Remove -u _scanf_float from linker flags.
This is called as a pre-action before the link step, after the
Arduino framework has added its default flags.
"""
linkflags = env.get("LINKFLAGS", [])
new_linkflags = []
i = 0
while i < len(linkflags):
flag = linkflags[i]
if flag == "-u" and i + 1 < len(linkflags):
next_flag = linkflags[i + 1]
if next_flag == "_scanf_float":
print("ESPHome: Removing _scanf_float (saves ~8KB flash)")
i += 2 # Skip both -u and the symbol
continue
new_linkflags.append(flag)
i += 1
env.Replace(LINKFLAGS=new_linkflags)
# Register the callback to run before the link step
env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", remove_scanf_float_flag)

View File

@@ -1,6 +1,39 @@
#include "mipi_spi.h"
#include "esphome/core/log.h"
namespace esphome {
namespace mipi_spi {} // namespace mipi_spi
} // namespace esphome
namespace esphome::mipi_spi {
void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl,
bool invert_colors, int display_bits, bool is_big_endian, const optional<uint8_t> &brightness,
GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width) {
ESP_LOGCONFIG(TAG,
"MIPI_SPI Display\n"
" Model: %s\n"
" Width: %d\n"
" Height: %d\n"
" Swap X/Y: %s\n"
" Mirror X: %s\n"
" Mirror Y: %s\n"
" Invert colors: %s\n"
" Color order: %s\n"
" Display pixels: %d bits\n"
" Endianness: %s\n"
" SPI Mode: %d\n"
" SPI Data rate: %uMHz\n"
" SPI Bus width: %d",
model, width, height, YESNO(madctl & MADCTL_MV), YESNO(madctl & (MADCTL_MX | MADCTL_XFLIP)),
YESNO(madctl & (MADCTL_MY | MADCTL_YFLIP)), YESNO(invert_colors), (madctl & MADCTL_BGR) ? "BGR" : "RGB",
display_bits, is_big_endian ? "Big" : "Little", spi_mode, static_cast<unsigned>(data_rate / 1000000),
bus_width);
LOG_PIN(" CS Pin: ", cs);
LOG_PIN(" Reset Pin: ", reset);
LOG_PIN(" DC Pin: ", dc);
if (offset_width != 0)
ESP_LOGCONFIG(TAG, " Offset width: %d", offset_width);
if (offset_height != 0)
ESP_LOGCONFIG(TAG, " Offset height: %d", offset_height);
if (brightness.has_value())
ESP_LOGCONFIG(TAG, " Brightness: %u", brightness.value());
}
} // namespace esphome::mipi_spi

View File

@@ -63,6 +63,11 @@ enum BusType {
BUS_TYPE_SINGLE_16 = 16, // Single bit bus, but 16 bits per transfer
};
// Helper function for dump_config - defined in mipi_spi.cpp to allow use of LOG_PIN macro
void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl,
bool invert_colors, int display_bits, bool is_big_endian, const optional<uint8_t> &brightness,
GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width);
/**
* Base class for MIPI SPI displays.
* All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file.
@@ -201,37 +206,9 @@ class MipiSpi : public display::Display,
}
void dump_config() override {
esph_log_config(TAG,
"MIPI_SPI Display\n"
" Model: %s\n"
" Width: %u\n"
" Height: %u",
this->model_, WIDTH, HEIGHT);
if constexpr (OFFSET_WIDTH != 0)
esph_log_config(TAG, " Offset width: %u", OFFSET_WIDTH);
if constexpr (OFFSET_HEIGHT != 0)
esph_log_config(TAG, " Offset height: %u", OFFSET_HEIGHT);
esph_log_config(TAG,
" Swap X/Y: %s\n"
" Mirror X: %s\n"
" Mirror Y: %s\n"
" Invert colors: %s\n"
" Color order: %s\n"
" Display pixels: %d bits\n"
" Endianness: %s\n",
YESNO(this->madctl_ & MADCTL_MV), YESNO(this->madctl_ & (MADCTL_MX | MADCTL_XFLIP)),
YESNO(this->madctl_ & (MADCTL_MY | MADCTL_YFLIP)), YESNO(this->invert_colors_),
this->madctl_ & MADCTL_BGR ? "BGR" : "RGB", DISPLAYPIXEL * 8, IS_BIG_ENDIAN ? "Big" : "Little");
if (this->brightness_.has_value())
esph_log_config(TAG, " Brightness: %u", this->brightness_.value());
log_pin(TAG, " CS Pin: ", this->cs_);
log_pin(TAG, " Reset Pin: ", this->reset_pin_);
log_pin(TAG, " DC Pin: ", this->dc_pin_);
esph_log_config(TAG,
" SPI Mode: %d\n"
" SPI Data rate: %dMHz\n"
" SPI Bus width: %d",
this->mode_, static_cast<unsigned>(this->data_rate_ / 1000000), BUS_TYPE);
internal_dump_config(this->model_, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, this->madctl_, this->invert_colors_,
DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_,
this->mode_, this->data_rate_, BUS_TYPE);
}
protected:

View File

@@ -643,10 +643,34 @@ static bool topic_match(const char *message, const char *subscription) {
}
void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) {
for (auto &subscription : this->subscriptions_) {
if (topic_match(topic.c_str(), subscription.topic.c_str()))
subscription.callback(topic, payload);
}
#ifdef USE_ESP8266
// IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266.
//
// On ESP8266, this callback is invoked directly from the lwIP/AsyncTCP network stack
// which runs in the "sys" context with a very limited stack (~4KB). By the time we
// reach this function, the stack is already partially consumed by the network
// processing chain: tcp_input -> AsyncClient::_recv -> AsyncMqttClient::_onMessage -> here.
//
// MQTT subscription callbacks can trigger arbitrary user actions (automations, HTTP
// requests, sensor updates, etc.) which may have deep call stacks of their own.
// For example, an HTTP request action requires: DNS lookup -> TCP connect -> TLS
// handshake (if HTTPS) -> request formatting. This easily overflows the remaining
// system stack space, causing a LoadStoreAlignmentCause exception or silent corruption.
//
// By deferring to the main loop, we ensure callbacks execute with a fresh, full-size
// stack in the normal application context rather than the constrained network task.
//
// DO NOT REMOVE THIS DEFER without understanding the above. It may appear to work
// in simple tests but will cause crashes with complex automations.
this->defer([this, topic, payload]() {
#endif
for (auto &subscription : this->subscriptions_) {
if (topic_match(topic.c_str(), subscription.topic.c_str()))
subscription.callback(topic, payload);
}
#ifdef USE_ESP8266
});
#endif
}
// Setters

View File

@@ -181,17 +181,20 @@ optional<bool> PMSX003Component::check_byte_() {
bool PMSX003Component::check_payload_length_(uint16_t payload_length) {
// https://avaldebe.github.io/PyPMS/sensors/Plantower/
switch (this->type_) {
case Type::PMSX003:
// The expected payload length is typically 28 bytes.
// However, a 20-byte payload check was already present in the code.
// No official documentation was found confirming this.
// Retaining this check to avoid breaking existing behavior.
case Type::PMS1003:
return payload_length == 28; // 2*13+2
case Type::PMS3003: // Data 7/8/9 not set/reserved
return payload_length == 20; // 2*9+2
case Type::PMSX003: // Data 13 not set/reserved
// Deprecated: Length 20 is for PMS3003 backwards compatibility
return payload_length == 28 || payload_length == 20; // 2*13+2
case Type::PMS5003S:
case Type::PMS5003T:
return payload_length == 28; // 2*13+2 (Data 13 not set/reserved)
case Type::PMS5003ST:
return payload_length == 36; // 2*17+2 (Data 16 not set/reserved)
case Type::PMS5003T: // Data 13 not set/reserved
return payload_length == 28; // 2*13+2
case Type::PMS5003ST: // Data 16 not set/reserved
return payload_length == 36; // 2*17+2
case Type::PMS9003M:
return payload_length == 28; // 2*13+2
}
return false;
}
@@ -314,9 +317,10 @@ void PMSX003Component::parse_data_() {
}
// Firmware Version and Error Code
if (this->type_ == Type::PMS5003ST) {
const uint8_t firmware_version = this->data_[36];
const uint8_t error_code = this->data_[37];
if (this->type_ == Type::PMS1003 || this->type_ == Type::PMS5003ST || this->type_ == Type::PMS9003M) {
const uint8_t firmware_error_code_offset = (this->type_ == Type::PMS5003ST) ? 36 : 28;
const uint8_t firmware_version = this->data_[firmware_error_code_offset];
const uint8_t error_code = this->data_[firmware_error_code_offset + 1];
ESP_LOGD(TAG, "Got Firmware Version: 0x%02X, Error Code: 0x%02X", firmware_version, error_code);
}

View File

@@ -8,10 +8,13 @@
namespace esphome::pmsx003 {
enum class Type : uint8_t {
PMSX003 = 0,
PMS1003 = 0,
PMS3003,
PMSX003, // PMS5003, PMS6003, PMS7003, PMSA003 (NOT PMSA003I - see `pmsa003i` component)
PMS5003S,
PMS5003T,
PMS5003ST,
PMS9003M,
};
enum class Command : uint8_t {

View File

@@ -40,33 +40,127 @@ pmsx003_ns = cg.esphome_ns.namespace("pmsx003")
PMSX003Component = pmsx003_ns.class_("PMSX003Component", uart.UARTDevice, cg.Component)
PMSX003Sensor = pmsx003_ns.class_("PMSX003Sensor", sensor.Sensor)
TYPE_PMSX003 = "PMSX003"
TYPE_PMS1003 = "PMS1003"
TYPE_PMS3003 = "PMS3003"
TYPE_PMSX003 = "PMSX003" # PMS5003, PMS6003, PMS7003, PMSA003 (NOT PMSA003I - see `pmsa003i` component)
TYPE_PMS5003S = "PMS5003S"
TYPE_PMS5003T = "PMS5003T"
TYPE_PMS5003ST = "PMS5003ST"
TYPE_PMS9003M = "PMS9003M"
Type = pmsx003_ns.enum("Type", is_class=True)
PMSX003_TYPES = {
TYPE_PMS1003: Type.PMS1003,
TYPE_PMS3003: Type.PMS3003,
TYPE_PMSX003: Type.PMSX003,
TYPE_PMS5003S: Type.PMS5003S,
TYPE_PMS5003T: Type.PMS5003T,
TYPE_PMS5003ST: Type.PMS5003ST,
TYPE_PMS9003M: Type.PMS9003M,
}
SENSORS_TO_TYPE = {
CONF_PM_1_0_STD: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_2_5_STD: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_10_0_STD: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_1_0: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_2_5: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_10_0: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_0_3UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_0_5UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_1_0UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_2_5UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_5_0UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003ST],
CONF_PM_10_0UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003ST],
CONF_PM_1_0_STD: [
TYPE_PMS1003,
TYPE_PMS3003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003T,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_2_5_STD: [
TYPE_PMS1003,
TYPE_PMS3003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003T,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_10_0_STD: [
TYPE_PMS1003,
TYPE_PMS3003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003T,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_1_0: [
TYPE_PMS1003,
TYPE_PMS3003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003T,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_2_5: [
TYPE_PMS1003,
TYPE_PMS3003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003T,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_10_0: [
TYPE_PMS1003,
TYPE_PMS3003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003T,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_0_3UM: [
TYPE_PMS1003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003T,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_0_5UM: [
TYPE_PMS1003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003T,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_1_0UM: [
TYPE_PMS1003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003T,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_2_5UM: [
TYPE_PMS1003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003T,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_5_0UM: [
TYPE_PMS1003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_PM_10_0UM: [
TYPE_PMS1003,
TYPE_PMSX003,
TYPE_PMS5003S,
TYPE_PMS5003ST,
TYPE_PMS9003M,
],
CONF_FORMALDEHYDE: [TYPE_PMS5003S, TYPE_PMS5003ST],
CONF_TEMPERATURE: [TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_HUMIDITY: [TYPE_PMS5003T, TYPE_PMS5003ST],

View File

@@ -1,481 +0,0 @@
#include "esphome/core/defines.h"
#ifdef USE_TIME_TIMEZONE
#include "posix_tz.h"
#include <cctype>
namespace esphome::time {
// Global timezone - set once at startup, rarely changes
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state
static ParsedTimezone global_tz_{};
void set_global_tz(const ParsedTimezone &tz) { global_tz_ = tz; }
const ParsedTimezone &get_global_tz() { return global_tz_; }
namespace internal {
// Helper to parse an unsigned integer from string, updating pointer
static uint32_t parse_uint(const char *&p) {
uint32_t value = 0;
while (std::isdigit(static_cast<unsigned char>(*p))) {
value = value * 10 + (*p - '0');
p++;
}
return value;
}
bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); }
// Get days in year (avoids duplicate is_leap_year calls)
static inline int days_in_year(int year) { return is_leap_year(year) ? 366 : 365; }
// Convert days since epoch to year, updating days to remainder
static int __attribute__((noinline)) days_to_year(int64_t &days) {
int year = 1970;
int diy;
while (days >= (diy = days_in_year(year))) {
days -= diy;
year++;
}
while (days < 0) {
year--;
days += days_in_year(year);
}
return year;
}
// Extract just the year from a UTC epoch
static int epoch_to_year(time_t epoch) {
int64_t days = epoch / 86400;
if (epoch < 0 && epoch % 86400 != 0)
days--;
return days_to_year(days);
}
int days_in_month(int year, int month) {
switch (month) {
case 2:
return is_leap_year(year) ? 29 : 28;
case 4:
case 6:
case 9:
case 11:
return 30;
default:
return 31;
}
}
// Zeller-like algorithm for day of week (0 = Sunday)
int __attribute__((noinline)) day_of_week(int year, int month, int day) {
// Adjust for January/February
if (month < 3) {
month += 12;
year--;
}
int k = year % 100;
int j = year / 100;
int h = (day + (13 * (month + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7;
// Convert from Zeller (0=Sat) to standard (0=Sun)
return ((h + 6) % 7);
}
void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) {
// Days since epoch
int64_t days = epoch / 86400;
int32_t remaining_secs = epoch % 86400;
if (remaining_secs < 0) {
days--;
remaining_secs += 86400;
}
out_tm->tm_sec = remaining_secs % 60;
remaining_secs /= 60;
out_tm->tm_min = remaining_secs % 60;
out_tm->tm_hour = remaining_secs / 60;
// Day of week (Jan 1, 1970 was Thursday = 4)
out_tm->tm_wday = static_cast<int>((days + 4) % 7);
if (out_tm->tm_wday < 0)
out_tm->tm_wday += 7;
// Calculate year (updates days to day-of-year)
int year = days_to_year(days);
out_tm->tm_year = year - 1900;
out_tm->tm_yday = static_cast<int>(days);
// Calculate month and day
int month = 1;
int dim;
while (days >= (dim = days_in_month(year, month))) {
days -= dim;
month++;
}
out_tm->tm_mon = month - 1;
out_tm->tm_mday = static_cast<int>(days) + 1;
out_tm->tm_isdst = 0;
}
bool skip_tz_name(const char *&p) {
if (*p == '<') {
// Angle-bracket quoted name: <+07>, <-03>, <AEST>
p++; // skip '<'
while (*p && *p != '>') {
p++;
}
if (*p == '>') {
p++; // skip '>'
return true;
}
return false; // Unterminated
}
// Standard name: 3+ letters
const char *start = p;
while (*p && std::isalpha(static_cast<unsigned char>(*p))) {
p++;
}
return (p - start) >= 3;
}
int32_t __attribute__((noinline)) parse_offset(const char *&p) {
int sign = 1;
if (*p == '-') {
sign = -1;
p++;
} else if (*p == '+') {
p++;
}
int hours = parse_uint(p);
int minutes = 0;
int seconds = 0;
if (*p == ':') {
p++;
minutes = parse_uint(p);
if (*p == ':') {
p++;
seconds = parse_uint(p);
}
}
return sign * (hours * 3600 + minutes * 60 + seconds);
}
// Helper to parse the optional /time suffix (reuses parse_offset logic)
static void parse_transition_time(const char *&p, DSTRule &rule) {
rule.time_seconds = 2 * 3600; // Default 02:00
if (*p == '/') {
p++;
rule.time_seconds = parse_offset(p);
}
}
void __attribute__((noinline)) julian_to_month_day(int julian_day, int &out_month, int &out_day) {
// J format: day 1-365, Feb 29 is NOT counted even in leap years
// So day 60 is always March 1
// Iterate forward through months (no array needed)
int remaining = julian_day;
out_month = 1;
while (out_month <= 12) {
// Days in month for non-leap year (J format ignores leap years)
int dim = days_in_month(2001, out_month); // 2001 is non-leap year
if (remaining <= dim) {
out_day = remaining;
return;
}
remaining -= dim;
out_month++;
}
out_day = remaining;
}
void __attribute__((noinline)) day_of_year_to_month_day(int day_of_year, int year, int &out_month, int &out_day) {
// Plain format: day 0-365, Feb 29 IS counted in leap years
// Day 0 = Jan 1
int remaining = day_of_year;
out_month = 1;
while (out_month <= 12) {
int days_this_month = days_in_month(year, out_month);
if (remaining < days_this_month) {
out_day = remaining + 1;
return;
}
remaining -= days_this_month;
out_month++;
}
// Shouldn't reach here with valid input
out_month = 12;
out_day = 31;
}
bool parse_dst_rule(const char *&p, DSTRule &rule) {
rule = {}; // Zero initialize
if (*p == 'M' || *p == 'm') {
// M format: Mm.w.d (month.week.day)
rule.type = DSTRuleType::MONTH_WEEK_DAY;
p++;
rule.month = parse_uint(p);
if (rule.month < 1 || rule.month > 12)
return false;
if (*p++ != '.')
return false;
rule.week = parse_uint(p);
if (rule.week < 1 || rule.week > 5)
return false;
if (*p++ != '.')
return false;
rule.day_of_week = parse_uint(p);
if (rule.day_of_week > 6)
return false;
} else if (*p == 'J' || *p == 'j') {
// J format: Jn (Julian day 1-365, not counting Feb 29)
rule.type = DSTRuleType::JULIAN_NO_LEAP;
p++;
rule.day = parse_uint(p);
if (rule.day < 1 || rule.day > 365)
return false;
} else if (std::isdigit(static_cast<unsigned char>(*p))) {
// Plain number format: n (day 0-365, counting Feb 29)
rule.type = DSTRuleType::DAY_OF_YEAR;
rule.day = parse_uint(p);
if (rule.day > 365)
return false;
} else {
return false;
}
// Parse optional /time suffix
parse_transition_time(p, rule);
return true;
}
// Calculate days from Jan 1 of given year to given month/day
static int __attribute__((noinline)) days_from_year_start(int year, int month, int day) {
int days = day - 1;
for (int m = 1; m < month; m++) {
days += days_in_month(year, m);
}
return days;
}
// Calculate days from epoch to Jan 1 of given year (for DST transition calculations)
// Only supports years >= 1970. Timezone is either compiled in from YAML or set by
// Home Assistant, so pre-1970 dates are not a concern.
static int64_t __attribute__((noinline)) days_to_year_start(int year) {
int64_t days = 0;
for (int y = 1970; y < year; y++) {
days += days_in_year(y);
}
return days;
}
time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) {
int month, day;
switch (rule.type) {
case DSTRuleType::MONTH_WEEK_DAY: {
// Find the nth occurrence of day_of_week in the given month
int first_dow = day_of_week(year, rule.month, 1);
// Days until first occurrence of target day
int days_until_first = (rule.day_of_week - first_dow + 7) % 7;
int first_occurrence = 1 + days_until_first;
if (rule.week == 5) {
// "Last" occurrence - find the last one in the month
int dim = days_in_month(year, rule.month);
day = first_occurrence;
while (day + 7 <= dim) {
day += 7;
}
} else {
// nth occurrence
day = first_occurrence + (rule.week - 1) * 7;
}
month = rule.month;
break;
}
case DSTRuleType::JULIAN_NO_LEAP:
// J format: day 1-365, Feb 29 not counted
julian_to_month_day(rule.day, month, day);
break;
case DSTRuleType::DAY_OF_YEAR:
// Plain format: day 0-365, Feb 29 counted
day_of_year_to_month_day(rule.day, year, month, day);
break;
case DSTRuleType::NONE:
// Should never be called with NONE, but handle it gracefully
month = 1;
day = 1;
break;
}
// Calculate days from epoch to this date
int64_t days = days_to_year_start(year) + days_from_year_start(year, month, day);
// Convert to epoch and add transition time and base offset
return days * 86400 + rule.time_seconds + base_offset_seconds;
}
} // namespace internal
bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) {
if (!tz.has_dst()) {
return false;
}
int year = internal::epoch_to_year(utc_epoch);
// Calculate DST start and end for this year
// DST start transition happens in standard time
time_t dst_start = internal::calculate_dst_transition(year, tz.dst_start, tz.std_offset_seconds);
// DST end transition happens in daylight time
time_t dst_end = internal::calculate_dst_transition(year, tz.dst_end, tz.dst_offset_seconds);
if (dst_start < dst_end) {
// Northern hemisphere: DST is between start and end
return (utc_epoch >= dst_start && utc_epoch < dst_end);
} else {
// Southern hemisphere: DST is outside the range (wraps around year)
return (utc_epoch >= dst_start || utc_epoch < dst_end);
}
}
bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) {
if (!tz_string || !*tz_string) {
return false;
}
const char *p = tz_string;
// Initialize result (dst_start/dst_end default to type=NONE, so has_dst() returns false)
result.std_offset_seconds = 0;
result.dst_offset_seconds = 0;
result.dst_start = {};
result.dst_end = {};
// Skip standard timezone name
if (!internal::skip_tz_name(p)) {
return false;
}
// Parse standard offset (required)
if (!*p || (!std::isdigit(static_cast<unsigned char>(*p)) && *p != '+' && *p != '-')) {
return false;
}
result.std_offset_seconds = internal::parse_offset(p);
// Check for DST name
if (!*p) {
return true; // No DST
}
// If next char is comma, there's no DST name but there are rules (invalid)
if (*p == ',') {
return false;
}
// Check if there's something that looks like a DST name start
// (letter or angle bracket). If not, treat as trailing garbage and return success.
if (!std::isalpha(static_cast<unsigned char>(*p)) && *p != '<') {
return true; // No DST, trailing characters ignored
}
if (!internal::skip_tz_name(p)) {
return false; // Invalid DST name (started but malformed)
}
// Optional DST offset (default is std - 1 hour)
if (*p && *p != ',' && (std::isdigit(static_cast<unsigned char>(*p)) || *p == '+' || *p == '-')) {
result.dst_offset_seconds = internal::parse_offset(p);
} else {
result.dst_offset_seconds = result.std_offset_seconds - 3600;
}
// Parse DST rules (required when DST name is present)
if (*p != ',') {
// DST name without rules - treat as no DST since we can't determine transitions
return true;
}
p++;
if (!internal::parse_dst_rule(p, result.dst_start)) {
return false;
}
// Second rule is required per POSIX
if (*p != ',') {
return false;
}
p++;
// has_dst() now returns true since dst_start.type was set by parse_dst_rule
return internal::parse_dst_rule(p, result.dst_end);
}
bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) {
if (!out_tm) {
return false;
}
// Determine DST status once (avoids duplicate is_in_dst calculation)
bool in_dst = is_in_dst(utc_epoch, tz);
int32_t offset = in_dst ? tz.dst_offset_seconds : tz.std_offset_seconds;
// Apply offset (POSIX offset is positive west, so subtract to get local)
time_t local_epoch = utc_epoch - offset;
internal::epoch_to_tm_utc(local_epoch, out_tm);
out_tm->tm_isdst = in_dst ? 1 : 0;
return true;
}
} // namespace esphome::time
#ifndef USE_HOST
// Override libc's localtime functions to use our timezone on embedded platforms.
// This allows user lambdas calling ::localtime() to get correct local time
// without needing the TZ environment variable (which pulls in scanf bloat).
// On host, we use the normal TZ mechanism since there's no memory constraint.
// Thread-safe version
extern "C" struct tm *localtime_r(const time_t *timer, struct tm *result) {
if (timer == nullptr || result == nullptr) {
return nullptr;
}
esphome::time::epoch_to_local_tm(*timer, esphome::time::get_global_tz(), result);
return result;
}
// Non-thread-safe version (uses static buffer, standard libc behavior)
extern "C" struct tm *localtime(const time_t *timer) {
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static struct tm localtime_buf;
return localtime_r(timer, &localtime_buf);
}
#endif // !USE_HOST
#endif // USE_TIME_TIMEZONE

View File

@@ -1,132 +0,0 @@
#pragma once
#ifdef USE_TIME_TIMEZONE
#include <cstdint>
#include <ctime>
namespace esphome::time {
/// Type of DST transition rule
enum class DSTRuleType : uint8_t {
NONE = 0, ///< No DST rule (used to indicate no DST)
MONTH_WEEK_DAY, ///< M format: Mm.w.d (e.g., M3.2.0 = 2nd Sunday of March)
JULIAN_NO_LEAP, ///< J format: Jn (day 1-365, Feb 29 not counted)
DAY_OF_YEAR, ///< Plain number: n (day 0-365, Feb 29 counted in leap years)
};
/// Rule for DST transition (packed for 32-bit: 12 bytes)
struct DSTRule {
int32_t time_seconds; ///< Seconds after midnight (default 7200 = 2:00 AM)
uint16_t day; ///< Day of year (for JULIAN_NO_LEAP and DAY_OF_YEAR)
DSTRuleType type; ///< Type of rule
uint8_t month; ///< Month 1-12 (for MONTH_WEEK_DAY)
uint8_t week; ///< Week 1-5, 5 = last (for MONTH_WEEK_DAY)
uint8_t day_of_week; ///< Day 0-6, 0 = Sunday (for MONTH_WEEK_DAY)
};
/// Parsed POSIX timezone information (packed for 32-bit: 32 bytes)
struct ParsedTimezone {
int32_t std_offset_seconds; ///< Standard time offset from UTC in seconds (positive = west)
int32_t dst_offset_seconds; ///< DST offset from UTC in seconds
DSTRule dst_start; ///< When DST starts
DSTRule dst_end; ///< When DST ends
/// Check if this timezone has DST rules
bool has_dst() const { return this->dst_start.type != DSTRuleType::NONE; }
};
/// Parse a POSIX TZ string into a ParsedTimezone struct.
/// Supports formats like:
/// - "EST5" (simple offset, no DST)
/// - "EST5EDT,M3.2.0,M11.1.0" (with DST, M-format rules)
/// - "CST6CDT,M3.2.0/2,M11.1.0/2" (with transition times)
/// - "<+07>-7" (angle-bracket notation for special names)
/// - "IST-5:30" (half-hour offsets)
/// - "EST5EDT,J60,J300" (J-format: Julian day without leap day)
/// - "EST5EDT,60,300" (plain day number: day of year with leap day)
/// @param tz_string The POSIX TZ string to parse
/// @param result Output: the parsed timezone data
/// @return true if parsing succeeded, false on error
bool parse_posix_tz(const char *tz_string, ParsedTimezone &result);
/// Convert a UTC epoch to local time using the parsed timezone.
/// This replaces libc's localtime() to avoid scanf dependency.
/// @param utc_epoch Unix timestamp in UTC
/// @param tz The parsed timezone
/// @param[out] out_tm Output tm struct with local time
/// @return true on success
bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm);
/// Set the global timezone used by epoch_to_local_tm() when called without a timezone.
/// This is called by RealTimeClock::apply_timezone_() to enable ESPTime::from_epoch_local()
/// to work without libc's localtime().
void set_global_tz(const ParsedTimezone &tz);
/// Get the global timezone.
const ParsedTimezone &get_global_tz();
/// Check if a given UTC epoch falls within DST for the parsed timezone.
/// @param utc_epoch Unix timestamp in UTC
/// @param tz The parsed timezone
/// @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
namespace internal {
/// Skip a timezone name (letters or <...> quoted format)
/// @param p Pointer to current position, updated on return
/// @return true if a valid name was found
bool skip_tz_name(const char *&p);
/// Parse an offset in format [-]hh[:mm[:ss]]
/// @param p Pointer to current position, updated on return
/// @return Offset in seconds
int32_t parse_offset(const char *&p);
/// Parse a DST rule in format Mm.w.d[/time], Jn[/time], or n[/time]
/// @param p Pointer to current position, updated on return
/// @param rule Output: the parsed rule
/// @return true if parsing succeeded
bool parse_dst_rule(const char *&p, DSTRule &rule);
/// Convert Julian day (J format, 1-365 not counting Feb 29) to month/day
/// @param julian_day Day number 1-365
/// @param[out] month Output: month 1-12
/// @param[out] day Output: day of month
void julian_to_month_day(int julian_day, int &month, int &day);
/// Convert day of year (plain format, 0-365 counting Feb 29) to month/day
/// @param day_of_year Day number 0-365
/// @param year The year (for leap year calculation)
/// @param[out] month Output: month 1-12
/// @param[out] day Output: day of month
void day_of_year_to_month_day(int day_of_year, int year, int &month, int &day);
/// Calculate day of week for any date (0 = Sunday)
/// Uses a simplified algorithm that works for years 1970-2099
int day_of_week(int year, int month, int day);
/// Get the number of days in a month
int days_in_month(int year, int month);
/// Check if a year is a leap year
bool is_leap_year(int year);
/// Convert epoch to year/month/day/hour/min/sec (UTC)
void epoch_to_tm_utc(time_t epoch, struct tm *out_tm);
/// Calculate the epoch timestamp for a DST transition in a given year.
/// @param year The year (e.g., 2026)
/// @param rule The DST rule (month, week, day_of_week, time)
/// @param base_offset_seconds The timezone offset to apply (std or dst depending on context)
/// @return Unix epoch timestamp of the transition
time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds);
} // namespace internal
} // namespace esphome::time
#endif // USE_TIME_TIMEZONE

View File

@@ -14,8 +14,8 @@
#include <sys/time.h>
#endif
#include <cerrno>
#include <cinttypes>
#include <cstdlib>
namespace esphome::time {
@@ -23,33 +23,9 @@ static const char *const TAG = "time";
RealTimeClock::RealTimeClock() = default;
ESPTime __attribute__((noinline)) RealTimeClock::now() {
#ifdef USE_TIME_TIMEZONE
time_t epoch = this->timestamp_now();
struct tm local_tm;
if (epoch_to_local_tm(epoch, get_global_tz(), &local_tm)) {
return ESPTime::from_c_tm(&local_tm, epoch);
}
// Fallback to UTC if parsing failed
return ESPTime::from_epoch_utc(epoch);
#else
return ESPTime::from_epoch_local(this->timestamp_now());
#endif
}
void RealTimeClock::dump_config() {
#ifdef USE_TIME_TIMEZONE
const auto &tz = get_global_tz();
// POSIX offset is positive west, negate for conventional UTC+X display
int std_h = -tz.std_offset_seconds / 3600;
int std_m = (std::abs(tz.std_offset_seconds) % 3600) / 60;
if (tz.has_dst()) {
int dst_h = -tz.dst_offset_seconds / 3600;
int dst_m = (std::abs(tz.dst_offset_seconds) % 3600) / 60;
ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d (DST UTC%+d:%02d)", std_h, std_m, dst_h, dst_m);
} else {
ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_h, std_m);
}
ESP_LOGCONFIG(TAG, "Timezone: '%s'", this->timezone_.c_str());
#endif
auto time = this->now();
ESP_LOGCONFIG(TAG, "Current time: %04d-%02d-%02d %02d:%02d:%02d", time.year, time.month, time.day_of_month, time.hour,
@@ -96,6 +72,11 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
ret = settimeofday(&timev, nullptr);
}
#ifdef USE_TIME_TIMEZONE
// Move timezone back to local timezone.
this->apply_timezone_();
#endif
if (ret != 0) {
ESP_LOGW(TAG, "setimeofday() failed with code %d", ret);
}
@@ -108,29 +89,9 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
}
#ifdef USE_TIME_TIMEZONE
void RealTimeClock::apply_timezone_(const char *tz) {
ParsedTimezone parsed{};
// Handle null or empty input - use UTC
if (tz == nullptr || *tz == '\0') {
set_global_tz(parsed);
return;
}
#ifdef USE_HOST
// On host platform, also set TZ environment variable for libc compatibility
setenv("TZ", tz, 1);
void RealTimeClock::apply_timezone_() {
setenv("TZ", this->timezone_.c_str(), 1);
tzset();
#endif
// Parse the POSIX TZ string using our custom parser
if (!parse_posix_tz(tz, parsed)) {
ESP_LOGW(TAG, "Failed to parse timezone: %s", tz);
// parsed stays as default (UTC) on failure
}
// Set global timezone for all time conversions
set_global_tz(parsed);
}
#endif

View File

@@ -6,9 +6,6 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/time.h"
#ifdef USE_TIME_TIMEZONE
#include "posix_tz.h"
#endif
namespace esphome::time {
@@ -23,31 +20,26 @@ class RealTimeClock : public PollingComponent {
explicit RealTimeClock();
#ifdef USE_TIME_TIMEZONE
/// Set the time zone from a POSIX TZ string.
void set_timezone(const char *tz) { this->apply_timezone_(tz); }
/// Set the time zone from a character buffer with known length.
/// The buffer does not need to be null-terminated.
void set_timezone(const char *tz, size_t len) {
if (tz == nullptr) {
this->apply_timezone_(nullptr);
return;
}
// Stack buffer - TZ strings from tzdata are typically short (< 50 chars)
char buf[128];
if (len >= sizeof(buf))
len = sizeof(buf) - 1;
memcpy(buf, tz, len);
buf[len] = '\0';
this->apply_timezone_(buf);
/// Set the time zone.
void set_timezone(const std::string &tz) {
this->timezone_ = tz;
this->apply_timezone_();
}
/// Set the time zone from a std::string.
void set_timezone(const std::string &tz) { this->apply_timezone_(tz.c_str()); }
/// Set the time zone from raw buffer, only if it differs from the current one.
void set_timezone(const char *tz, size_t len) {
if (this->timezone_.length() != len || memcmp(this->timezone_.c_str(), tz, len) != 0) {
this->timezone_.assign(tz, len);
this->apply_timezone_();
}
}
/// Get the time zone currently in use.
std::string get_timezone() { return this->timezone_; }
#endif
/// Get the time in the currently defined timezone.
ESPTime now();
ESPTime now() { return ESPTime::from_epoch_local(this->timestamp_now()); }
/// Get the time without any time zone or DST corrections.
ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); }
@@ -66,7 +58,8 @@ class RealTimeClock : public PollingComponent {
void synchronize_epoch_(uint32_t epoch);
#ifdef USE_TIME_TIMEZONE
void apply_timezone_(const char *tz);
std::string timezone_{};
void apply_timezone_();
#endif
CallbackManager<void()> time_sync_callback_;

View File

@@ -53,4 +53,4 @@ async def to_code(config):
"lib_ignore", ["ESPAsyncTCP", "AsyncTCP", "AsyncTCP_RP2040W"]
)
# https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json
cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.5")
cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.6")

View File

@@ -210,7 +210,7 @@ void Application::loop() {
#ifdef USE_ESP32
esp_chip_info_t chip_info;
esp_chip_info(&chip_info);
ESP_LOGI(TAG, "ESP32 Chip: %s r%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100,
ESP_LOGI(TAG, "ESP32 Chip: %s rev%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100,
chip_info.revision % 100, chip_info.cores);
#if defined(USE_ESP32_VARIANT_ESP32) && !defined(USE_ESP32_MIN_CHIP_REVISION_SET)
// Suggest optimization for chips that don't need the PSRAM cache workaround

View File

@@ -2,6 +2,7 @@
#include "helpers.h"
#include <algorithm>
#include <cinttypes>
namespace esphome {
@@ -66,121 +67,56 @@ std::string ESPTime::strftime(const char *format) {
std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); }
// Helper to parse exactly N digits, returns false if not enough digits
static bool parse_digits(const char *&p, const char *end, int count, uint16_t &value) {
value = 0;
for (int i = 0; i < count; i++) {
if (p >= end || *p < '0' || *p > '9')
return false;
value = value * 10 + (*p - '0');
p++;
}
return true;
}
// Helper to check for expected character
static bool expect_char(const char *&p, const char *end, char expected) {
if (p >= end || *p != expected)
return false;
p++;
return true;
}
bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) {
// Supported formats:
// YYYY-MM-DD HH:MM:SS (19 chars)
// YYYY-MM-DD HH:MM (16 chars)
// YYYY-MM-DD (10 chars)
// HH:MM:SS (8 chars)
// HH:MM (5 chars)
uint16_t year;
uint8_t month;
uint8_t day;
uint8_t hour;
uint8_t minute;
uint8_t second;
int num;
const int ilen = static_cast<int>(len);
if (time_to_parse == nullptr || len == 0)
if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu:%02hhu %n", &year, &month, &day, // NOLINT
&hour, // NOLINT
&minute, // NOLINT
&second, &num) == 6 && // NOLINT
num == ilen) {
esp_time.year = year;
esp_time.month = month;
esp_time.day_of_month = day;
esp_time.hour = hour;
esp_time.minute = minute;
esp_time.second = second;
} else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu %n", &year, &month, &day, // NOLINT
&hour, // NOLINT
&minute, &num) == 5 && // NOLINT
num == ilen) {
esp_time.year = year;
esp_time.month = month;
esp_time.day_of_month = day;
esp_time.hour = hour;
esp_time.minute = minute;
esp_time.second = 0;
} else if (sscanf(time_to_parse, "%02hhu:%02hhu:%02hhu %n", &hour, &minute, &second, &num) == 3 && // NOLINT
num == ilen) {
esp_time.hour = hour;
esp_time.minute = minute;
esp_time.second = second;
} else if (sscanf(time_to_parse, "%02hhu:%02hhu %n", &hour, &minute, &num) == 2 && // NOLINT
num == ilen) {
esp_time.hour = hour;
esp_time.minute = minute;
esp_time.second = 0;
} else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %n", &year, &month, &day, &num) == 3 && // NOLINT
num == ilen) {
esp_time.year = year;
esp_time.month = month;
esp_time.day_of_month = day;
} else {
return false;
const char *p = time_to_parse;
const char *end = time_to_parse + len;
uint16_t v1, v2, v3, v4, v5, v6;
// Try date formats first (start with 4-digit year)
if (len >= 10 && time_to_parse[4] == '-') {
// YYYY-MM-DD...
if (!parse_digits(p, end, 4, v1))
return false;
if (!expect_char(p, end, '-'))
return false;
if (!parse_digits(p, end, 2, v2))
return false;
if (!expect_char(p, end, '-'))
return false;
if (!parse_digits(p, end, 2, v3))
return false;
esp_time.year = v1;
esp_time.month = v2;
esp_time.day_of_month = v3;
if (p == end) {
// YYYY-MM-DD (date only)
return true;
}
if (!expect_char(p, end, ' '))
return false;
// Continue with time part: HH:MM[:SS]
if (!parse_digits(p, end, 2, v4))
return false;
if (!expect_char(p, end, ':'))
return false;
if (!parse_digits(p, end, 2, v5))
return false;
esp_time.hour = v4;
esp_time.minute = v5;
if (p == end) {
// YYYY-MM-DD HH:MM
esp_time.second = 0;
return true;
}
if (!expect_char(p, end, ':'))
return false;
if (!parse_digits(p, end, 2, v6))
return false;
esp_time.second = v6;
return p == end; // YYYY-MM-DD HH:MM:SS
}
// Try time-only formats (HH:MM[:SS])
if (len >= 5 && time_to_parse[2] == ':') {
if (!parse_digits(p, end, 2, v1))
return false;
if (!expect_char(p, end, ':'))
return false;
if (!parse_digits(p, end, 2, v2))
return false;
esp_time.hour = v1;
esp_time.minute = v2;
if (p == end) {
// HH:MM
esp_time.second = 0;
return true;
}
if (!expect_char(p, end, ':'))
return false;
if (!parse_digits(p, end, 2, v3))
return false;
esp_time.second = v3;
return p == end; // HH:MM:SS
}
return false;
return true;
}
void ESPTime::increment_second() {
@@ -257,67 +193,27 @@ void ESPTime::recalc_timestamp_utc(bool use_day_of_year) {
}
void ESPTime::recalc_timestamp_local() {
#ifdef USE_TIME_TIMEZONE
// Calculate timestamp as if fields were UTC
this->recalc_timestamp_utc(false);
if (this->timestamp == -1) {
return; // Invalid time
}
struct tm tm;
// Now convert from local to UTC by adding the offset
// POSIX: local = utc - offset, so utc = local + offset
const auto &tz = time::get_global_tz();
tm.tm_year = this->year - 1900;
tm.tm_mon = this->month - 1;
tm.tm_mday = this->day_of_month;
tm.tm_hour = this->hour;
tm.tm_min = this->minute;
tm.tm_sec = this->second;
tm.tm_isdst = -1;
if (!tz.has_dst()) {
// No DST - just apply standard offset
this->timestamp += tz.std_offset_seconds;
return;
}
// Try both interpretations to match libc mktime() with tm_isdst=-1
// For ambiguous times (fall-back repeated hour), prefer standard time
// For invalid times (spring-forward skipped hour), libc normalizes forward
time_t utc_if_dst = this->timestamp + tz.dst_offset_seconds;
time_t utc_if_std = this->timestamp + tz.std_offset_seconds;
bool dst_valid = time::is_in_dst(utc_if_dst, tz);
bool std_valid = !time::is_in_dst(utc_if_std, tz);
if (dst_valid && std_valid) {
// Ambiguous time (repeated hour during fall-back) - prefer standard time
this->timestamp = utc_if_std;
} else if (dst_valid) {
// Only DST interpretation is valid
this->timestamp = utc_if_dst;
} else if (std_valid) {
// Only standard interpretation is valid
this->timestamp = utc_if_std;
} else {
// Invalid time (skipped hour during spring-forward)
// libc normalizes forward: 02:30 CST -> 08:30 UTC -> 03:30 CDT
// Using std offset achieves this since the UTC result falls during DST
this->timestamp = utc_if_std;
}
#else
// No timezone support - treat as UTC
this->recalc_timestamp_utc(false);
#endif
this->timestamp = mktime(&tm);
}
int32_t ESPTime::timezone_offset() {
#ifdef USE_TIME_TIMEZONE
time_t now = ::time(nullptr);
const auto &tz = time::get_global_tz();
// POSIX offset is positive west, but we return offset to add to UTC to get local
// So we negate the POSIX offset
if (time::is_in_dst(now, tz)) {
return -tz.dst_offset_seconds;
}
return -tz.std_offset_seconds;
#else
// No timezone support - no offset
return 0;
#endif
struct tm local_tm = *::localtime(&now);
local_tm.tm_isdst = 0; // Cause mktime to ignore daylight saving time because we want to include it in the offset.
time_t local_time = mktime(&local_tm);
struct tm utc_tm = *::gmtime(&now);
time_t utc_time = mktime(&utc_tm);
return static_cast<int32_t>(local_time - utc_time);
}
bool ESPTime::operator<(const ESPTime &other) const { return this->timestamp < other.timestamp; }

View File

@@ -7,10 +7,6 @@
#include <span>
#include <string>
#ifdef USE_TIME_TIMEZONE
#include "esphome/components/time/posix_tz.h"
#endif
namespace esphome {
template<typename T> bool increment_time_value(T &current, uint16_t begin, uint16_t end);
@@ -109,17 +105,11 @@ struct ESPTime {
* @return The generated ESPTime
*/
static ESPTime from_epoch_local(time_t epoch) {
#ifdef USE_TIME_TIMEZONE
struct tm local_tm;
if (time::epoch_to_local_tm(epoch, time::get_global_tz(), &local_tm)) {
return ESPTime::from_c_tm(&local_tm, epoch);
struct tm *c_tm = ::localtime(&epoch);
if (c_tm == nullptr) {
return ESPTime{}; // Return an invalid ESPTime
}
// Fallback to UTC if conversion failed
return ESPTime::from_epoch_utc(epoch);
#else
// No timezone support - return UTC (no TZ configured, localtime would return UTC anyway)
return ESPTime::from_epoch_utc(epoch);
#endif
return ESPTime::from_c_tm(c_tm, epoch);
}
/** Convert an UTC epoch timestamp to a UTC time ESPTime instance.
*

View File

@@ -114,7 +114,7 @@ lib_deps =
ESP8266WiFi ; wifi (Arduino built-in)
Update ; ota (Arduino built-in)
ESP32Async/ESPAsyncTCP@2.0.0 ; async_tcp
ESP32Async/ESPAsyncWebServer@3.9.5 ; web_server_base
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
makuna/NeoPixelBus@2.7.3 ; neopixelbus
ESP8266HTTPClient ; http_request (Arduino built-in)
ESP8266mDNS ; mdns (Arduino built-in)
@@ -202,7 +202,7 @@ lib_deps =
${common:arduino.lib_deps}
ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp
bblanchon/ArduinoJson@7.4.2 ; json
ESP32Async/ESPAsyncWebServer@3.9.5 ; web_server_base
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
build_flags =
${common:arduino.build_flags}
-DUSE_RP2040
@@ -218,7 +218,7 @@ framework = arduino
lib_compat_mode = soft
lib_deps =
bblanchon/ArduinoJson@7.4.2 ; json
ESP32Async/ESPAsyncWebServer@3.9.5 ; web_server_base
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.2 ; wireguard
build_flags =
${common:arduino.build_flags}

View File

@@ -66,7 +66,6 @@ def create_test_config(config_name: str, includes: list[str]) -> dict:
],
"build_flags": [
"-Og", # optimize for debug
"-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing
],
"debug_build_flags": [ # only for debug builds
"-g3", # max debug info

View File

@@ -0,0 +1,10 @@
substitutions:
dc_pin: GPIO15
cs_pin: GPIO5
enable_pin: GPIO4
reset_pin: GPIO16
packages:
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
<<: !include common.yaml

File diff suppressed because it is too large Load Diff