Compare commits

..

64 Commits

Author SHA1 Message Date
J. Nick Koston
2b70e62df5 tweak 2026-02-01 12:48:21 +01:00
J. Nick Koston
dcab2def05 no scanf float 2026-02-01 12:47:14 +01:00
J. Nick Koston
849df4b2a8 no host 2026-01-30 03:26:03 -06:00
J. Nick Koston
5f7582ffdb override localtime() to use our timezone
By providing our own localtime() and localtime_r() implementations,
user lambdas calling ::localtime() continue to work correctly without
needing migration. This eliminates the breaking change while still
achieving the memory savings.
2026-01-30 03:25:21 -06:00
J. Nick Koston
dcd0f53027 fix clang-tidy warnings
- Add NOLINT for intentional global mutable state
- Simplify boolean return in parse_posix_tz
- Add USE_TIME_TIMEZONE define for tests
- Add NOLINT for Google Test SetUp/TearDown methods
2026-01-30 02:51:36 -06:00
J. Nick Koston
b5e073bf7f clarify comment about days_to_year_start 2026-01-30 01:52:05 -06:00
J. Nick Koston
cde2199b64 more cover 2026-01-30 01:46:57 -06:00
J. Nick Koston
a1eef9870c cleanup 2026-01-30 01:28:23 -06:00
J. Nick Koston
19e9ab253e cleanup 2026-01-30 01:24:48 -06:00
J. Nick Koston
e3a99f12e4 more edge cases 2026-01-30 01:22:32 -06:00
J. Nick Koston
d31a860bf2 fix, macos and linux disagree on ambig time 2026-01-30 01:18:16 -06:00
J. Nick Koston
cfea3472bd cleanups 2026-01-30 01:11:31 -06:00
J. Nick Koston
31859a3eb5 fix 2026-01-30 01:10:43 -06:00
J. Nick Koston
9f3e5f990f cleanups 2026-01-30 01:09:30 -06:00
J. Nick Koston
f317f58545 cleanups 2026-01-30 01:09:06 -06:00
J. Nick Koston
01c23eace3 cleanups 2026-01-30 01:06:46 -06:00
J. Nick Koston
9b8556c2b2 fix 2026-01-30 01:03:42 -06:00
J. Nick Koston
9628c213b5 make human readable 2026-01-30 01:01:21 -06:00
J. Nick Koston
07a71c412d make human readable 2026-01-30 01:00:07 -06:00
J. Nick Koston
0d736e4143 fix 2026-01-30 00:41:53 -06:00
J. Nick Koston
a93e3b6fa0 ambig time 2026-01-30 00:38:29 -06:00
J. Nick Koston
22ab20ba4c aioesphomeapi and esphome both always have M format, it was overkill 2026-01-30 00:36:17 -06:00
J. Nick Koston
6ee51b0159 remove crazy over definsive edge cases that the bot wants -- they never happen and just make things larger 2026-01-30 00:25:42 -06:00
J. Nick Koston
e2b3186731 remove crazy over definsive edge cases that the bot wants -- they never happen and just make things larger 2026-01-30 00:23:09 -06:00
J. Nick Koston
31aa58c45d bot review 2026-01-30 00:12:46 -06:00
J. Nick Koston
a757cb3c91 bot review 2026-01-30 00:03:28 -06:00
J. Nick Koston
91ad54d864 bot review 2026-01-30 00:03:13 -06:00
J. Nick Koston
3703755e03 more fixes 2026-01-29 23:59:39 -06:00
J. Nick Koston
c1d380dee4 more fixes 2026-01-29 23:58:07 -06:00
J. Nick Koston
b2120609b9 bot review 2026-01-29 23:54:14 -06:00
J. Nick Koston
9e6e8a7ecb bot review 2026-01-29 23:51:50 -06:00
J. Nick Koston
de06b36544 bot review 2026-01-29 23:50:37 -06:00
J. Nick Koston
695df9b979 bot review 2026-01-29 23:49:07 -06:00
J. Nick Koston
aa91cdd984 no setz 2026-01-29 23:47:28 -06:00
J. Nick Koston
284a9cdab6 must set TZ 2026-01-29 23:41:41 -06:00
J. Nick Koston
77ebfc8687 aioesphomeapi and esphome both always have M format, it was overkill 2026-01-29 23:34:59 -06:00
J. Nick Koston
899f2bbac5 aioesphomeapi and esphome both always have M format, it was overkill 2026-01-29 23:34:49 -06:00
J. Nick Koston
bb35e7b4b5 bad feedback from copilot 2026-01-29 23:31:09 -06:00
J. Nick Koston
64e4edd70f bad feedback from copilot 2026-01-29 23:30:33 -06:00
J. Nick Koston
300b7169ad cleanup 2026-01-29 23:29:10 -06:00
J. Nick Koston
1353dbc31e cleanup 2026-01-29 23:28:35 -06:00
J. Nick Koston
300eea034b handle trailing garbage 2026-01-29 23:26:53 -06:00
J. Nick Koston
90a06b5249 Merge branch 'dev' into posix_tz 2026-01-29 19:20:14 -10:00
J. Nick Koston
1b7b307d08 simplify 2026-01-29 22:57:17 -06:00
J. Nick Koston
a946aefbed more cover 2026-01-29 22:54:56 -06:00
J. Nick Koston
8708f96de4 less ram 2026-01-29 22:53:29 -06:00
J. Nick Koston
bd056b3b9e improve readability 2026-01-29 22:47:54 -06:00
J. Nick Koston
5d49c81e2d more cover 2026-01-29 22:42:33 -06:00
J. Nick Koston
bec7d6d223 tweak 2026-01-29 22:31:23 -06:00
J. Nick Koston
973105f2e5 tweak 2026-01-29 22:28:09 -06:00
J. Nick Koston
53fb876738 tests 2026-01-29 22:17:36 -06:00
J. Nick Koston
d2bc168f39 tweak 2026-01-29 22:07:34 -06:00
J. Nick Koston
34ec72ad49 tweak 2026-01-29 22:05:23 -06:00
J. Nick Koston
85c814b712 tweak 2026-01-29 22:02:46 -06:00
J. Nick Koston
fc951baebc tweak 2026-01-29 21:59:46 -06:00
J. Nick Koston
a1cdfe71de tweak 2026-01-29 21:54:40 -06:00
J. Nick Koston
c1971955a3 tweak 2026-01-29 21:53:43 -06:00
J. Nick Koston
e1df75fc9b tweak 2026-01-29 21:53:06 -06:00
J. Nick Koston
ea83330ab9 tweak 2026-01-29 21:52:24 -06:00
J. Nick Koston
4cdf0224ba tweak 2026-01-29 21:48:46 -06:00
J. Nick Koston
47f029b713 cover 2026-01-29 21:38:59 -06:00
J. Nick Koston
d45a20af83 tweak 2026-01-29 21:25:46 -06:00
J. Nick Koston
d37c37ef62 tweak 2026-01-29 21:19:00 -06:00
J. Nick Koston
aad3764806 posix_tz 2026-01-29 21:14:42 -06:00
34 changed files with 2323 additions and 721 deletions

View File

@@ -1 +1 @@
069fa9526c52f7c580a9ec17c7678d12f142221387e9b561c18f95394d4629a3
cf3d341206b4184ec8b7fe85141aef4fe4696aa720c3f8a06d4e57930574bdab

View File

@@ -1329,10 +1329,6 @@ async def to_code(config):
# Disable dynamic log level control to save memory
add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False)
# Disable per-tag log level filtering since dynamic level control is disabled above
# This saves ~250 bytes of RAM (tag cache) and associated code
add_idf_sdkconfig_option("CONFIG_LOG_TAG_LEVEL_IMPL_NONE", True)
# Reduce PHY TX power in the event of a brownout
add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True)

View File

@@ -205,6 +205,7 @@ 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",
],
)
@@ -342,3 +343,8 @@ 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

@@ -0,0 +1,46 @@
# 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

@@ -11,6 +11,12 @@ namespace i2c {
static const char *const TAG = "i2c";
void I2CBus::i2c_scan_() {
// suppress logs from the IDF I2C library during the scan
#if defined(USE_ESP32) && defined(USE_LOGGER)
auto previous = esp_log_level_get("*");
esp_log_level_set("*", ESP_LOG_NONE);
#endif
for (uint8_t address = 8; address != 120; address++) {
auto err = write_readv(address, nullptr, 0, nullptr, 0);
if (err == ERROR_OK) {
@@ -21,6 +27,9 @@ void I2CBus::i2c_scan_() {
// it takes 16sec to scan on nrf52. It prevents board reset.
arch_feed_wdt();
}
#if defined(USE_ESP32) && defined(USE_LOGGER)
esp_log_level_set("*", previous);
#endif
}
ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len) {

View File

@@ -114,6 +114,9 @@ void Logger::pre_setup() {
global_logger = this;
esp_log_set_vprintf(esp_idf_log_vprintf_);
if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) {
esp_log_level_set("*", ESP_LOG_VERBOSE);
}
ESP_LOGI(TAG, "Log initialized");
}

View File

@@ -1,39 +1,6 @@
#include "mipi_spi.h"
#include "esphome/core/log.h"
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
namespace esphome {
namespace mipi_spi {} // namespace mipi_spi
} // namespace esphome

View File

@@ -63,11 +63,6 @@ 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.
@@ -206,9 +201,37 @@ class MipiSpi : public display::Display,
}
void dump_config() override {
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);
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);
}
protected:

View File

@@ -643,34 +643,10 @@ static bool topic_match(const char *message, const char *subscription) {
}
void MQTTClientComponent::on_message(const std::string &topic, const std::string &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
for (auto &subscription : this->subscriptions_) {
if (topic_match(topic.c_str(), subscription.topic.c_str()))
subscription.callback(topic, payload);
}
}
// Setters

View File

@@ -181,20 +181,17 @@ 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::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
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.
return payload_length == 28 || payload_length == 20; // 2*13+2
case Type::PMS5003S:
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
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)
}
return false;
}
@@ -317,10 +314,9 @@ void PMSX003Component::parse_data_() {
}
// Firmware Version and Error Code
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];
if (this->type_ == Type::PMS5003ST) {
const uint8_t firmware_version = this->data_[36];
const uint8_t error_code = this->data_[37];
ESP_LOGD(TAG, "Got Firmware Version: 0x%02X, Error Code: 0x%02X", firmware_version, error_code);
}

View File

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

View File

@@ -40,127 +40,33 @@ pmsx003_ns = cg.esphome_ns.namespace("pmsx003")
PMSX003Component = pmsx003_ns.class_("PMSX003Component", uart.UARTDevice, cg.Component)
PMSX003Sensor = pmsx003_ns.class_("PMSX003Sensor", sensor.Sensor)
TYPE_PMS1003 = "PMS1003"
TYPE_PMS3003 = "PMS3003"
TYPE_PMSX003 = "PMSX003" # PMS5003, PMS6003, PMS7003, PMSA003 (NOT PMSA003I - see `pmsa003i` component)
TYPE_PMSX003 = "PMSX003"
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_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_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_FORMALDEHYDE: [TYPE_PMS5003S, TYPE_PMS5003ST],
CONF_TEMPERATURE: [TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_HUMIDITY: [TYPE_PMS5003T, TYPE_PMS5003ST],

View File

@@ -0,0 +1,481 @@
#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

@@ -0,0 +1,132 @@
#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,9 +23,33 @@ 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
ESP_LOGCONFIG(TAG, "Timezone: '%s'", this->timezone_.c_str());
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);
}
#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,
@@ -72,11 +96,6 @@ 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);
}
@@ -89,9 +108,29 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
}
#ifdef USE_TIME_TIMEZONE
void RealTimeClock::apply_timezone_() {
setenv("TZ", this->timezone_.c_str(), 1);
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);
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,6 +6,9 @@
#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 {
@@ -20,26 +23,31 @@ class RealTimeClock : public PollingComponent {
explicit RealTimeClock();
#ifdef USE_TIME_TIMEZONE
/// Set the time zone.
void set_timezone(const std::string &tz) {
this->timezone_ = tz;
this->apply_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 raw buffer, only if it differs from the current one.
/// 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 (this->timezone_.length() != len || memcmp(this->timezone_.c_str(), tz, len) != 0) {
this->timezone_.assign(tz, len);
this->apply_timezone_();
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);
}
/// Get the time zone currently in use.
std::string get_timezone() { return this->timezone_; }
/// Set the time zone from a std::string.
void set_timezone(const std::string &tz) { this->apply_timezone_(tz.c_str()); }
#endif
/// Get the time in the currently defined timezone.
ESPTime now() { return ESPTime::from_epoch_local(this->timestamp_now()); }
ESPTime now();
/// Get the time without any time zone or DST corrections.
ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); }
@@ -58,8 +66,7 @@ class RealTimeClock : public PollingComponent {
void synchronize_epoch_(uint32_t epoch);
#ifdef USE_TIME_TIMEZONE
std::string timezone_{};
void apply_timezone_();
void apply_timezone_(const char *tz);
#endif
CallbackManager<void()> time_sync_callback_;

View File

@@ -12,8 +12,8 @@ from esphome.components.packet_transport import (
)
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID
from esphome.core import ID
from esphome.cpp_generator import literal
from esphome.core import ID, Lambda
from esphome.cpp_generator import ExpressionStatement, MockObj
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["network"]
@@ -24,8 +24,6 @@ udp_ns = cg.esphome_ns.namespace("udp")
UDPComponent = udp_ns.class_("UDPComponent", cg.Component)
UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action)
trigger_args = cg.std_vector.template(cg.uint8)
trigger_argname = "data"
trigger_argtype = [(trigger_args, trigger_argname)]
CONF_ADDRESSES = "addresses"
CONF_LISTEN_ADDRESS = "listen_address"
@@ -113,14 +111,13 @@ async def to_code(config):
cg.add(var.set_addresses([str(addr) for addr in config[CONF_ADDRESSES]]))
if on_receive := config.get(CONF_ON_RECEIVE):
on_receive = on_receive[0]
trigger_id = cg.new_Pvariable(on_receive[CONF_TRIGGER_ID])
trigger = cg.new_Pvariable(on_receive[CONF_TRIGGER_ID])
trigger = await automation.build_automation(
trigger_id, trigger_argtype, on_receive
trigger, [(trigger_args, "data")], on_receive
)
trigger_lambda = await cg.process_lambda(
trigger.trigger(literal(trigger_argname)), trigger_argtype
)
cg.add(var.add_listener(trigger_lambda))
trigger = Lambda(str(ExpressionStatement(trigger.trigger(MockObj("data")))))
trigger = await cg.process_lambda(trigger, [(trigger_args, "data")])
cg.add(var.add_listener(trigger))
cg.add(var.set_should_listen())

View File

@@ -155,9 +155,8 @@ void USBCDCACMInstance::setup() {
return;
}
// Use a larger stack size for very verbose logging
constexpr size_t stack_size =
ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE ? USB_TX_TASK_STACK_SIZE_VV : USB_TX_TASK_STACK_SIZE;
// Use a larger stack size for (very) verbose logging
const size_t stack_size = esp_log_level_get(TAG) > ESP_LOG_DEBUG ? USB_TX_TASK_STACK_SIZE_VV : USB_TX_TASK_STACK_SIZE;
// Create a simple, unique task name per interface
char task_name[] = "usb_tx_0";

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.6")
cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.5")

View File

@@ -643,7 +643,7 @@ void WiFiComponent::restart_adapter() {
// through start_connecting() first. Without this clear, stale errors would
// trigger spurious "failed (callback)" logs. The canonical clear location
// is in start_connecting(); this is the only exception to that pattern.
this->error_from_callback_ = 0;
this->error_from_callback_ = false;
}
void WiFiComponent::loop() {
@@ -1063,7 +1063,7 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) {
// This is the canonical location for clearing the flag since all connection
// attempts go through start_connecting(). The only other clear is in
// restart_adapter() which enters COOLDOWN without calling start_connecting().
this->error_from_callback_ = 0;
this->error_from_callback_ = false;
if (!this->wifi_sta_connect_(ap)) {
ESP_LOGE(TAG, "wifi_sta_connect_ failed");
@@ -1468,11 +1468,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
}
if (this->error_from_callback_) {
// ESP8266: logging done in callback, listeners deferred via pending_.disconnect
// Other platforms: just log generic failure message
#ifndef USE_ESP8266
ESP_LOGW(TAG, "Connecting to network failed (callback)");
#endif
this->retry_connect();
return;
}

View File

@@ -58,12 +58,6 @@ static constexpr int8_t WIFI_RSSI_DISCONNECTED = -127;
/// Buffer size for SSID (IEEE 802.11 max 32 bytes + null terminator)
static constexpr size_t SSID_BUFFER_SIZE = 33;
#ifdef USE_ESP8266
/// Special disconnect reason for authmode downgrade (CVE-2020-12638 mitigation)
/// Not a real WiFi reason code - used internally for deferred logging
static constexpr uint8_t WIFI_DISCONNECT_REASON_AUTHMODE_DOWNGRADE = 254;
#endif
struct SavedWifiSettings {
char ssid[33];
char password[65];
@@ -596,9 +590,6 @@ class WiFiComponent : public Component {
void connect_soon_();
void wifi_loop_();
#ifdef USE_ESP8266
void process_pending_callbacks_();
#endif
bool wifi_mode_(optional<bool> sta, optional<bool> ap);
bool wifi_sta_pre_setup_();
bool wifi_apply_output_power_(float output_power);
@@ -713,26 +704,10 @@ class WiFiComponent : public Component {
uint8_t num_ipv6_addresses_{0};
#endif /* USE_NETWORK_IPV6 */
// 0 = no error, non-zero = disconnect reason code from callback
// This serves as both the error flag and stores the reason for deferred logging
uint8_t error_from_callback_{0};
#ifdef USE_ESP8266
// Pending listener callbacks from system context (ESP8266 only)
// ESP8266 callbacks run in SDK system context with ~2KB stack where
// calling arbitrary listener callbacks is unsafe. These flags defer
// listener notifications to wifi_loop_() which runs with full stack.
struct {
bool connect : 1; // STA connected, notify listeners
bool disconnect : 1; // STA disconnected, notify listeners
bool got_ip : 1; // Got IP, notify listeners
bool scan_complete : 1; // Scan complete, notify listeners
} pending_{};
#endif
// Group all boolean values together
bool has_ap_{false};
bool handled_connected_state_{false};
bool error_from_callback_{false};
bool scan_done_{false};
bool ap_setup_{false};
bool ap_started_{false};

View File

@@ -511,8 +511,21 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
it.channel);
#endif
s_sta_connected = true;
// Defer listener callbacks to main loop - system context has limited stack
global_wifi_component->pending_.connect = true;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
for (auto *listener : global_wifi_component->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(it.ssid, it.ssid_len), it.bssid);
}
#endif
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
if (const WiFiAP *config = global_wifi_component->get_selected_sta_();
config && config->get_manual_ip().has_value()) {
for (auto *listener : global_wifi_component->ip_state_listeners_) {
listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(),
global_wifi_component->get_dns_address(0), global_wifi_component->get_dns_address(1));
}
}
#endif
break;
}
case EVENT_STAMODE_DISCONNECTED: {
@@ -530,9 +543,17 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
}
s_sta_connected = false;
s_sta_connecting = false;
// Store reason as error flag; defer listener callbacks to main loop
global_wifi_component->error_from_callback_ = it.reason;
global_wifi_component->pending_.disconnect = true;
// IMPORTANT: Set error flag BEFORE notifying listeners.
// This ensures is_connected() returns false during listener callbacks,
// which is critical for proper reconnection logic (e.g., roaming).
global_wifi_component->error_from_callback_ = true;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
// Notify listeners AFTER setting error flag so they see correct state
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : global_wifi_component->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
#endif
break;
}
case EVENT_STAMODE_AUTHMODE_CHANGE: {
@@ -543,8 +564,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
// https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors
if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) {
ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting");
// we can't call retry_connect() from this context, so disconnect immediately
// and notify main thread with error_from_callback_
wifi_station_disconnect();
global_wifi_component->error_from_callback_ = WIFI_DISCONNECT_REASON_AUTHMODE_DOWNGRADE;
global_wifi_component->error_from_callback_ = true;
}
break;
}
@@ -555,8 +578,12 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
ESP_LOGV(TAG, "static_ip=%s gateway=%s netmask=%s", network::IPAddress(&it.ip).str_to(ip_buf),
network::IPAddress(&it.gw).str_to(gw_buf), network::IPAddress(&it.mask).str_to(mask_buf));
s_sta_got_ip = true;
// Defer listener callbacks to main loop - system context has limited stack
global_wifi_component->pending_.got_ip = true;
#ifdef USE_WIFI_IP_STATE_LISTENERS
for (auto *listener : global_wifi_component->ip_state_listeners_) {
listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(), global_wifi_component->get_dns_address(0),
global_wifi_component->get_dns_address(1));
}
#endif
break;
}
case EVENT_STAMODE_DHCP_TIMEOUT: {
@@ -766,7 +793,11 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
ESP_LOGV(TAG, "Scan complete: %zu found, %zu stored%s", total, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
this->scan_done_ = true;
this->pending_.scan_complete = true; // Defer listener callbacks to main loop
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
for (auto *listener : global_wifi_component->scan_results_listeners_) {
listener->on_wifi_scan_results(global_wifi_component->scan_result_);
}
#endif
}
#ifdef USE_WIFI_AP
@@ -952,59 +983,7 @@ network::IPAddress WiFiComponent::wifi_gateway_ip_() {
return network::IPAddress(&ip.gw);
}
network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_getserver(num)); }
void WiFiComponent::wifi_loop_() { this->process_pending_callbacks_(); }
void WiFiComponent::process_pending_callbacks_() {
// Notify listeners for connect event (logging already done in callback)
if (this->pending_.connect) {
this->pending_.connect = false;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
bssid_t bssid = this->wifi_bssid();
char ssid_buf[SSID_BUFFER_SIZE];
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(this->wifi_ssid_to(ssid_buf)), bssid);
}
#endif
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
}
#endif
}
// Notify listeners for disconnect event (logging already done in callback)
if (this->pending_.disconnect) {
this->pending_.disconnect = false;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
#endif
}
// Notify listeners for got IP event (logging already done in callback)
if (this->pending_.got_ip) {
this->pending_.got_ip = false;
#ifdef USE_WIFI_IP_STATE_LISTENERS
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
}
// Notify listeners for scan complete (logging already done in callback)
if (this->pending_.scan_complete) {
this->pending_.scan_complete = false;
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
#endif
}
}
void WiFiComponent::wifi_loop_() {}
} // namespace esphome::wifi
#endif

View File

@@ -774,7 +774,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
}
s_sta_connected = false;
s_sta_connecting = false;
error_from_callback_ = 1;
error_from_callback_ = true;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {

View File

@@ -494,7 +494,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
s_ignored_disconnect_count, get_disconnect_reason_str(it.reason));
s_sta_state = LTWiFiSTAState::ERROR_FAILED;
WiFi.disconnect();
this->error_from_callback_ = 1;
this->error_from_callback_ = true;
// Don't break - fall through to notify listeners
} else {
ESP_LOGV(TAG, "Ignoring disconnect event with empty ssid while connecting (reason=%s, count=%u)",
@@ -520,7 +520,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
reason == WIFI_REASON_NO_AP_FOUND || reason == WIFI_REASON_ASSOC_FAIL ||
reason == WIFI_REASON_HANDSHAKE_TIMEOUT) {
WiFi.disconnect();
this->error_from_callback_ = 1;
this->error_from_callback_ = true;
}
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
@@ -539,7 +539,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
if (it.old_mode != WIFI_AUTH_OPEN && it.new_mode == WIFI_AUTH_OPEN) {
ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting");
WiFi.disconnect();
this->error_from_callback_ = 1;
this->error_from_callback_ = true;
s_sta_state = LTWiFiSTAState::ERROR_FAILED;
}
break;

View File

@@ -278,13 +278,9 @@ LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)")
class Lambda:
def __init__(self, value):
from esphome.cpp_generator import Expression, statement
# pylint: disable=protected-access
if isinstance(value, Lambda):
self._value = value._value
elif isinstance(value, Expression):
self._value = str(statement(value))
else:
self._value = value
self._parts = None

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 rev%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100,
ESP_LOGI(TAG, "ESP32 Chip: %s r%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,7 +2,6 @@
#include "helpers.h"
#include <algorithm>
#include <cinttypes>
namespace esphome {
@@ -67,58 +66,123 @@ std::string ESPTime::strftime(const char *format) {
std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); }
bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) {
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 (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;
// 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)
if (time_to_parse == nullptr || len == 0)
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;
}
void ESPTime::increment_second() {
this->timestamp++;
if (!increment_time_value(this->second, 0, 60))
@@ -193,27 +257,67 @@ void ESPTime::recalc_timestamp_utc(bool use_day_of_year) {
}
void ESPTime::recalc_timestamp_local() {
struct tm tm;
#ifdef USE_TIME_TIMEZONE
// Calculate timestamp as if fields were UTC
this->recalc_timestamp_utc(false);
if (this->timestamp == -1) {
return; // Invalid time
}
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;
// 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();
this->timestamp = mktime(&tm);
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
}
int32_t ESPTime::timezone_offset() {
#ifdef USE_TIME_TIMEZONE
time_t now = ::time(nullptr);
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);
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
}
bool ESPTime::operator<(const ESPTime &other) const { return this->timestamp < other.timestamp; }

View File

@@ -7,6 +7,10 @@
#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);
@@ -105,11 +109,17 @@ struct ESPTime {
* @return The generated ESPTime
*/
static ESPTime from_epoch_local(time_t epoch) {
struct tm *c_tm = ::localtime(&epoch);
if (c_tm == nullptr) {
return ESPTime{}; // Return an invalid ESPTime
#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);
}
return ESPTime::from_c_tm(c_tm, epoch);
// 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
}
/** Convert an UTC epoch timestamp to a UTC time ESPTime instance.
*

View File

@@ -462,16 +462,6 @@ def statement(expression: Expression | Statement) -> Statement:
return ExpressionStatement(expression)
def literal(name: str) -> "MockObj":
"""Create a literal name that will appear in the generated code
not surrounded by quotes.
:param name: The name of the literal.
:return: The literal as a MockObj.
"""
return MockObj(name, "")
def variable(
id_: ID, rhs: SafeExpType, type_: "MockObj" = None, register=True
) -> "MockObj":
@@ -675,7 +665,7 @@ async def get_variable_with_full_id(id_: ID) -> tuple[ID, "MockObj"]:
async def process_lambda(
value: Lambda | Expression,
value: Lambda,
parameters: TemplateArgsType,
capture: str = "",
return_type: SafeExpType = None,
@@ -699,14 +689,6 @@ async def process_lambda(
if value is None:
return None
# Inadvertently passing a malformed parameters value will lead to the build process mysteriously hanging at the
# "Generating C++ source..." stage, so check here to save the developer's hair.
assert isinstance(parameters, list) and all(
isinstance(p, tuple) and len(p) == 2 for p in parameters
)
if isinstance(value, Expression):
value = Lambda(value)
parts = value.parts[:]
for i, id in enumerate(value.requires_ids):
full_id, var = await get_variable_with_full_id(id)

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.6 ; web_server_base
ESP32Async/ESPAsyncWebServer@3.9.5 ; 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.6 ; web_server_base
ESP32Async/ESPAsyncWebServer@3.9.5 ; 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.6 ; web_server_base
ESP32Async/ESPAsyncWebServer@3.9.5 ; web_server_base
droscy/esp_wireguard@0.4.2 ; wireguard
build_flags =
${common:arduino.build_flags}

View File

@@ -66,6 +66,7 @@ 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

@@ -1,10 +0,0 @@
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

View File

@@ -347,280 +347,3 @@ class TestMockObj:
assert isinstance(actual, cg.MockObj)
assert actual.base == "foo.eek"
assert actual.op == "."
class TestStatementFunction:
"""Tests for the statement() function."""
def test_statement__expression_converted_to_statement(self):
"""Test that expressions are converted to ExpressionStatement."""
expr = cg.RawExpression("foo()")
result = cg.statement(expr)
assert isinstance(result, cg.ExpressionStatement)
assert str(result) == "foo();"
def test_statement__statement_unchanged(self):
"""Test that statements are returned unchanged."""
stmt = cg.RawStatement("foo()")
result = cg.statement(stmt)
assert result is stmt
assert str(result) == "foo()"
def test_statement__expression_statement_unchanged(self):
"""Test that ExpressionStatement is returned unchanged."""
stmt = cg.ExpressionStatement(42)
result = cg.statement(stmt)
assert result is stmt
assert str(result) == "42;"
def test_statement__line_comment_unchanged(self):
"""Test that LineComment is returned unchanged."""
stmt = cg.LineComment("This is a comment")
result = cg.statement(stmt)
assert result is stmt
assert str(result) == "// This is a comment"
class TestLiteralFunction:
"""Tests for the literal() function."""
def test_literal__creates_mockobj(self):
"""Test that literal() creates a MockObj."""
result = cg.literal("MY_CONSTANT")
assert isinstance(result, cg.MockObj)
assert result.base == "MY_CONSTANT"
assert result.op == ""
def test_literal__string_representation(self):
"""Test that literal names appear unquoted in generated code."""
result = cg.literal("nullptr")
assert str(result) == "nullptr"
def test_literal__can_be_used_in_expressions(self):
"""Test that literals can be used as part of larger expressions."""
null_lit = cg.literal("nullptr")
expr = cg.CallExpression(cg.RawExpression("my_func"), null_lit)
assert str(expr) == "my_func(nullptr)"
def test_literal__common_cpp_literals(self):
"""Test common C++ literal values."""
test_cases = [
("nullptr", "nullptr"),
("true", "true"),
("false", "false"),
("NULL", "NULL"),
("NAN", "NAN"),
]
for name, expected in test_cases:
result = cg.literal(name)
assert str(result) == expected
class TestLambdaConstructor:
"""Tests for the Lambda class constructor in core/__init__.py."""
def test_lambda__from_string(self):
"""Test Lambda constructor with string argument."""
from esphome.core import Lambda
lambda_obj = Lambda("return x + 1;")
assert lambda_obj.value == "return x + 1;"
assert str(lambda_obj) == "return x + 1;"
def test_lambda__from_expression(self):
"""Test Lambda constructor with Expression argument."""
from esphome.core import Lambda
expr = cg.RawExpression("x + 1")
lambda_obj = Lambda(expr)
# Expression should be converted to statement (with semicolon)
assert lambda_obj.value == "x + 1;"
def test_lambda__from_lambda(self):
"""Test Lambda constructor with another Lambda argument."""
from esphome.core import Lambda
original = Lambda("return x + 1;")
copy = Lambda(original)
assert copy.value == original.value
assert copy.value == "return x + 1;"
def test_lambda__parts_parsing(self):
"""Test that Lambda correctly parses parts with id() references."""
from esphome.core import Lambda
lambda_obj = Lambda("return id(my_sensor).state;")
parts = lambda_obj.parts
# Parts should be split by LAMBDA_PROG regex: text, id, op, text
assert len(parts) == 4
assert parts[0] == "return "
assert parts[1] == "my_sensor"
assert parts[2] == "."
assert parts[3] == "state;"
def test_lambda__requires_ids(self):
"""Test that Lambda correctly extracts required IDs."""
from esphome.core import ID, Lambda
lambda_obj = Lambda("return id(sensor1).state + id(sensor2).value;")
ids = lambda_obj.requires_ids
assert len(ids) == 2
assert all(isinstance(id_obj, ID) for id_obj in ids)
assert ids[0].id == "sensor1"
assert ids[1].id == "sensor2"
def test_lambda__no_ids(self):
"""Test Lambda with no id() references."""
from esphome.core import Lambda
lambda_obj = Lambda("return 42;")
ids = lambda_obj.requires_ids
assert len(ids) == 0
def test_lambda__comment_removal(self):
"""Test that comments are removed when parsing parts."""
from esphome.core import Lambda
lambda_obj = Lambda("return id(sensor).state; // Get sensor state")
parts = lambda_obj.parts
# Comment should be replaced with space, not affect parsing
assert "my_sensor" not in str(parts)
def test_lambda__multiline_string(self):
"""Test Lambda with multiline string."""
from esphome.core import Lambda
code = """if (id(sensor).state > 0) {
return true;
}
return false;"""
lambda_obj = Lambda(code)
assert lambda_obj.value == code
assert "sensor" in [id_obj.id for id_obj in lambda_obj.requires_ids]
@pytest.mark.asyncio
class TestProcessLambda:
"""Tests for the process_lambda() async function."""
async def test_process_lambda__none_value(self):
"""Test that None returns None."""
result = await cg.process_lambda(None, [])
assert result is None
async def test_process_lambda__with_expression(self):
"""Test process_lambda with Expression argument."""
expr = cg.RawExpression("return x + 1")
result = await cg.process_lambda(expr, [(int, "x")])
assert isinstance(result, cg.LambdaExpression)
assert "x + 1" in str(result)
async def test_process_lambda__simple_lambda_no_ids(self):
"""Test process_lambda with simple Lambda without id() references."""
from esphome.core import Lambda
lambda_obj = Lambda("return x + 1;")
result = await cg.process_lambda(lambda_obj, [(int, "x")])
assert isinstance(result, cg.LambdaExpression)
# Should have parameter
lambda_str = str(result)
assert "int32_t x" in lambda_str
assert "return x + 1;" in lambda_str
async def test_process_lambda__with_return_type(self):
"""Test process_lambda with return type specified."""
from esphome.core import Lambda
lambda_obj = Lambda("return x > 0;")
result = await cg.process_lambda(lambda_obj, [(int, "x")], return_type=bool)
assert isinstance(result, cg.LambdaExpression)
lambda_str = str(result)
assert "-> bool" in lambda_str
async def test_process_lambda__with_capture(self):
"""Test process_lambda with capture specified."""
from esphome.core import Lambda
lambda_obj = Lambda("return captured + x;")
result = await cg.process_lambda(lambda_obj, [(int, "x")], capture="captured")
assert isinstance(result, cg.LambdaExpression)
lambda_str = str(result)
assert "[captured]" in lambda_str
async def test_process_lambda__empty_capture(self):
"""Test process_lambda with empty capture (stateless lambda)."""
from esphome.core import Lambda
lambda_obj = Lambda("return x + 1;")
result = await cg.process_lambda(lambda_obj, [(int, "x")], capture="")
assert isinstance(result, cg.LambdaExpression)
lambda_str = str(result)
assert "[]" in lambda_str
async def test_process_lambda__no_parameters(self):
"""Test process_lambda with no parameters."""
from esphome.core import Lambda
lambda_obj = Lambda("return 42;")
result = await cg.process_lambda(lambda_obj, [])
assert isinstance(result, cg.LambdaExpression)
lambda_str = str(result)
# Should have empty parameter list
assert "()" in lambda_str
async def test_process_lambda__multiple_parameters(self):
"""Test process_lambda with multiple parameters."""
from esphome.core import Lambda
lambda_obj = Lambda("return x + y + z;")
result = await cg.process_lambda(
lambda_obj, [(int, "x"), (float, "y"), (bool, "z")]
)
assert isinstance(result, cg.LambdaExpression)
lambda_str = str(result)
assert "int32_t x" in lambda_str
assert "float y" in lambda_str
assert "bool z" in lambda_str
async def test_process_lambda__parameter_validation(self):
"""Test that malformed parameters raise assertion error."""
from esphome.core import Lambda
lambda_obj = Lambda("return x;")
# Test invalid parameter format (not list of tuples)
with pytest.raises(AssertionError):
await cg.process_lambda(lambda_obj, "invalid")
# Test invalid tuple format (not 2-element tuples)
with pytest.raises(AssertionError):
await cg.process_lambda(lambda_obj, [(int, "x", "extra")])
# Test invalid tuple format (single element)
with pytest.raises(AssertionError):
await cg.process_lambda(lambda_obj, [(int,)])