Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy

This commit is contained in:
kbx81
2026-02-26 20:55:50 -06:00
79 changed files with 3078 additions and 309 deletions

View File

@@ -1 +1 @@
5eb1e5852765114ad06533220d3160b6c23f5ccefc4de41828699de5dfff5ad6
b97e16a84153b2a4cfc51137cd6121db3c32374504b2bea55144413b3e573052

View File

@@ -6,8 +6,9 @@
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Developer breaking change (an API change that could break external components)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
- [ ] Code quality improvements to existing code or addition of tests
- [ ] Other

View File

@@ -27,6 +27,7 @@ module.exports = {
'new-feature',
'breaking-change',
'developer-breaking-change',
'undocumented-api-change',
'code-quality',
'deprecated-component'
],

View File

@@ -238,6 +238,7 @@ async function detectPRTemplateCheckboxes(context) {
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
{ pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' }
];

View File

@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.2
rev: v0.15.3
hooks:
# Run the linter.
- id: ruff

View File

@@ -993,6 +993,7 @@ enum ClimateAction {
CLIMATE_ACTION_IDLE = 4;
CLIMATE_ACTION_DRYING = 5;
CLIMATE_ACTION_FAN = 6;
CLIMATE_ACTION_DEFROSTING = 7;
}
enum ClimatePreset {
CLIMATE_PRESET_NONE = 0;

View File

@@ -116,6 +116,7 @@ enum ClimateAction : uint32_t {
CLIMATE_ACTION_IDLE = 4,
CLIMATE_ACTION_DRYING = 5,
CLIMATE_ACTION_FAN = 6,
CLIMATE_ACTION_DEFROSTING = 7,
};
enum ClimatePreset : uint32_t {
CLIMATE_PRESET_NONE = 0,

View File

@@ -321,6 +321,8 @@ template<> const char *proto_enum_to_string<enums::ClimateAction>(enums::Climate
return "CLIMATE_ACTION_DRYING";
case enums::CLIMATE_ACTION_FAN:
return "CLIMATE_ACTION_FAN";
case enums::CLIMATE_ACTION_DEFROSTING:
return "CLIMATE_ACTION_DEFROSTING";
default:
return "UNKNOWN";
}

View File

@@ -15,7 +15,7 @@ class APIConnection;
return this->client_->schedule_message_(entity, ResponseType::MESSAGE_TYPE, ResponseType::ESTIMATED_SIZE); \
}
class ListEntitiesIterator : public ComponentIterator {
class ListEntitiesIterator final : public ComponentIterator {
public:
ListEntitiesIterator(APIConnection *client);
#ifdef USE_BINARY_SENSOR

View File

@@ -16,7 +16,7 @@ class APIConnection;
return this->client_->send_##entity_type##_state(entity); \
}
class InitialStateIterator : public ComponentIterator {
class InitialStateIterator final : public ComponentIterator {
public:
InitialStateIterator(APIConnection *client);
#ifdef USE_BINARY_SENSOR

View File

@@ -6,7 +6,7 @@
namespace esphome::captive_portal {
#ifdef USE_CAPTIVE_PORTAL_GZIP
const uint8_t INDEX_GZ[] PROGMEM = {
constexpr uint8_t INDEX_GZ[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e,
0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36,
0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf,
@@ -86,7 +86,7 @@ const uint8_t INDEX_GZ[] PROGMEM = {
0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00};
#else // Brotli (default, smaller)
const uint8_t INDEX_BR[] PROGMEM = {
constexpr uint8_t INDEX_BR[] PROGMEM = {
0x1b, 0xf8, 0x0a, 0x00, 0x64, 0x5a, 0xd3, 0xfa, 0xe7, 0xf3, 0x62, 0xd8, 0x06, 0x1b, 0xe9, 0x6a, 0x8a, 0x81, 0x2b,
0xb5, 0x49, 0x14, 0x37, 0xdc, 0x9e, 0x1a, 0xcb, 0x56, 0x87, 0xfb, 0xff, 0xf7, 0x73, 0x75, 0x12, 0x0a, 0xd6, 0x48,
0x84, 0xc6, 0x21, 0xa4, 0x6d, 0xb5, 0x71, 0xef, 0x13, 0xbe, 0x4e, 0x54, 0xf1, 0x64, 0x8f, 0x3f, 0xcc, 0x9a, 0x78,

View File

@@ -242,6 +242,9 @@ void CC1101Component::begin_tx() {
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
}
// Transition through IDLE to bypass CCA (Clear Channel Assessment) which can
// block TX entry when strobing from RX, and to ensure FS_AUTOCAL calibration
this->enter_idle_();
if (!this->enter_tx_()) {
ESP_LOGW(TAG, "Failed to enter TX state!");
}
@@ -252,6 +255,8 @@ void CC1101Component::begin_rx() {
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT);
}
// Transition through IDLE to ensure FS_AUTOCAL calibration occurs
this->enter_idle_();
if (!this->enter_rx_()) {
ESP_LOGW(TAG, "Failed to enter RX state!");
}

View File

@@ -10,8 +10,10 @@ const LogString *climate_mode_to_string(ClimateMode mode) {
return ClimateModeStrings::get_log_str(static_cast<uint8_t>(mode), ClimateModeStrings::LAST_INDEX);
}
// Climate action strings indexed by ClimateAction enum (0,2-6): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN
PROGMEM_STRING_TABLE(ClimateActionStrings, "OFF", "UNKNOWN", "COOLING", "HEATING", "IDLE", "DRYING", "FAN", "UNKNOWN");
// Climate action strings indexed by ClimateAction enum (0,2-7): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN,
// DEFROSTING
PROGMEM_STRING_TABLE(ClimateActionStrings, "OFF", "UNKNOWN", "COOLING", "HEATING", "IDLE", "DRYING", "FAN",
"DEFROSTING", "UNKNOWN");
const LogString *climate_action_to_string(ClimateAction action) {
return ClimateActionStrings::get_log_str(static_cast<uint8_t>(action), ClimateActionStrings::LAST_INDEX);

View File

@@ -41,6 +41,8 @@ enum ClimateAction : uint8_t {
CLIMATE_ACTION_DRYING = 5,
/// The climate device is in fan only mode
CLIMATE_ACTION_FAN = 6,
/// The climate device is defrosting
CLIMATE_ACTION_DEFROSTING = 7,
};
/// NOTE: If adding values, update ClimateFanModeMask in climate_traits.h to use the new last value

View File

@@ -13,22 +13,63 @@ esp_ldo_ns = cg.esphome_ns.namespace("esp_ldo")
EspLdo = esp_ldo_ns.class_("EspLdo", cg.Component)
AdjustAction = esp_ldo_ns.class_("AdjustAction", Action)
CHANNELS = (3, 4)
CHANNELS = (1, 2, 3, 4)
CHANNELS_INTERNAL = (1, 2)
CONF_ADJUSTABLE = "adjustable"
CONF_ALLOW_INTERNAL_CHANNEL = "allow_internal_channel"
CONF_PASSTHROUGH = "passthrough"
adjusted_ids = set()
def validate_ldo_voltage(value):
if isinstance(value, str) and value.lower() == CONF_PASSTHROUGH:
return CONF_PASSTHROUGH
value = cv.voltage(value)
if 0.5 <= value <= 2.7:
return value
raise cv.Invalid(
f"LDO voltage must be in range 0.5V-2.7V or 'passthrough' (bypass mode), got {value}V"
)
def validate_ldo_config(config):
channel = config[CONF_CHANNEL]
allow_internal = config[CONF_ALLOW_INTERNAL_CHANNEL]
if allow_internal and channel not in CHANNELS_INTERNAL:
raise cv.Invalid(
f"'{CONF_ALLOW_INTERNAL_CHANNEL}' is only valid for internal channels (1, 2). "
f"Channel {channel} is a user-configurable channel — its usage depends on your board schematic.",
path=[CONF_ALLOW_INTERNAL_CHANNEL],
)
if channel in CHANNELS_INTERNAL and not allow_internal:
raise cv.Invalid(
f"LDO channel {channel} is normally used internally by the chip (flash/PSRAM). "
f"Set '{CONF_ALLOW_INTERNAL_CHANNEL}: true' to confirm you know what you are doing.",
path=[CONF_CHANNEL],
)
if config[CONF_VOLTAGE] == CONF_PASSTHROUGH and config[CONF_ADJUSTABLE]:
raise cv.Invalid(
"Passthrough mode passes the supply voltage directly to the output and does not support "
"runtime voltage adjustment.",
path=[CONF_ADJUSTABLE],
)
return config
CONFIG_SCHEMA = cv.All(
cv.ensure_list(
cv.COMPONENT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(EspLdo),
cv.Required(CONF_VOLTAGE): cv.All(
cv.voltage, cv.float_range(min=0.5, max=2.7)
),
cv.Required(CONF_CHANNEL): cv.one_of(*CHANNELS, int=True),
cv.Optional(CONF_ADJUSTABLE, default=False): cv.boolean,
}
cv.All(
cv.COMPONENT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(EspLdo),
cv.Required(CONF_VOLTAGE): validate_ldo_voltage,
cv.Required(CONF_CHANNEL): cv.one_of(*CHANNELS, int=True),
cv.Optional(CONF_ADJUSTABLE, default=False): cv.boolean,
cv.Optional(CONF_ALLOW_INTERNAL_CHANNEL, default=False): cv.boolean,
}
),
validate_ldo_config,
)
),
cv.only_on_esp32,
@@ -40,7 +81,11 @@ async def to_code(configs):
for config in configs:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_CHANNEL])
await cg.register_component(var, config)
cg.add(var.set_voltage(config[CONF_VOLTAGE]))
voltage = config[CONF_VOLTAGE]
if voltage == CONF_PASSTHROUGH:
cg.add(var.set_voltage(3300))
else:
cg.add(var.set_voltage(int(round(voltage * 1000))))
cg.add(var.set_adjustable(config[CONF_ADJUSTABLE]))

View File

@@ -10,32 +10,34 @@ static const char *const TAG = "esp_ldo";
void EspLdo::setup() {
esp_ldo_channel_config_t config{};
config.chan_id = this->channel_;
config.voltage_mv = (int) (this->voltage_ * 1000.0f);
config.voltage_mv = this->voltage_mv_;
config.flags.adjustable = this->adjustable_;
auto err = esp_ldo_acquire_channel(&config, &this->handle_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to acquire LDO channel %d with voltage %fV", this->channel_, this->voltage_);
ESP_LOGE(TAG, "Failed to acquire LDO channel %d with voltage %dmV", this->channel_, this->voltage_mv_);
this->mark_failed(LOG_STR("Failed to acquire LDO channel"));
} else {
ESP_LOGD(TAG, "Acquired LDO channel %d with voltage %fV", this->channel_, this->voltage_);
ESP_LOGD(TAG, "Acquired LDO channel %d with voltage %dmV", this->channel_, this->voltage_mv_);
}
}
void EspLdo::dump_config() {
ESP_LOGCONFIG(TAG,
"ESP LDO Channel %d:\n"
" Voltage: %fV\n"
" Voltage: %dmV\n"
" Adjustable: %s",
this->channel_, this->voltage_, YESNO(this->adjustable_));
this->channel_, this->voltage_mv_, YESNO(this->adjustable_));
}
void EspLdo::adjust_voltage(float voltage) {
if (!std::isfinite(voltage) || voltage < 0.5f || voltage > 2.7f) {
ESP_LOGE(TAG, "Invalid voltage %fV for LDO channel %d", voltage, this->channel_);
ESP_LOGE(TAG, "Invalid voltage %fV for LDO channel %d (must be 0.5V-2.7V)", voltage, this->channel_);
return;
}
auto erro = esp_ldo_channel_adjust_voltage(this->handle_, (int) (voltage * 1000.0f));
if (erro != ESP_OK) {
ESP_LOGE(TAG, "Failed to adjust LDO channel %d to voltage %fV: %s", this->channel_, voltage, esp_err_to_name(erro));
int voltage_mv = (int) roundf(voltage * 1000.0f);
auto err = esp_ldo_channel_adjust_voltage(this->handle_, voltage_mv);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to adjust LDO channel %d to voltage %dmV: %s", this->channel_, voltage_mv,
esp_err_to_name(err));
}
}

View File

@@ -15,7 +15,7 @@ class EspLdo : public Component {
void dump_config() override;
void set_adjustable(bool adjustable) { this->adjustable_ = adjustable; }
void set_voltage(float voltage) { this->voltage_ = voltage; }
void set_voltage(int voltage_mv) { this->voltage_mv_ = voltage_mv; }
void adjust_voltage(float voltage);
float get_setup_priority() const override {
return setup_priority::BUS; // LDO setup should be done early
@@ -23,7 +23,7 @@ class EspLdo : public Component {
protected:
int channel_;
float voltage_{2.7};
int voltage_mv_{2700};
bool adjustable_{false};
esp_ldo_channel_handle_t handle_{};
};

View File

@@ -12,7 +12,7 @@
namespace esphome {
/// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA.
class ESPHomeOTAComponent : public ota::OTAComponent {
class ESPHomeOTAComponent final : public ota::OTAComponent {
public:
enum class OTAState : uint8_t {
IDLE,

View File

@@ -22,7 +22,7 @@ enum OtaHttpRequestError : uint8_t {
OTA_CONNECTION_ERROR = 0x12,
};
class OtaHttpRequestComponent : public ota::OTAComponent, public Parented<HttpRequestComponent> {
class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<HttpRequestComponent> {
public:
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }

View File

@@ -27,6 +27,7 @@ from esphome.storage_json import StorageJSON
from . import gpio # noqa
from .const import (
COMPONENT_BK72XX,
CONF_GPIO_RECOVER,
CONF_LOGLEVEL,
CONF_SDK_SILENT,
@@ -453,7 +454,14 @@ async def component_to_code(config):
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "soft")
# include <Arduino.h> in every file
cg.add_platformio_option("build_src_flags", "-include Arduino.h")
build_src_flags = "-include Arduino.h"
if FAMILY_COMPONENT[config[CONF_FAMILY]] == COMPONENT_BK72XX:
# LibreTiny forces -O1 globally for BK72xx because the Beken SDK
# has issues with higher optimization levels. However, ESPHome code
# works fine with -Os (used on every other platform), so override
# it for project source files only. GCC uses the last -O flag.
build_src_flags += " -Os"
cg.add_platformio_option("build_src_flags", build_src_flags)
# dummy version code
cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)"))
# decrease web server stack size (16k words -> 4k words)

View File

@@ -4,8 +4,6 @@
#include <Arduino.h>
namespace esphome {
namespace libretiny {} // namespace libretiny
} // namespace esphome
namespace esphome::libretiny {} // namespace esphome::libretiny
#endif // USE_LIBRETINY

View File

@@ -3,8 +3,7 @@
#include "gpio_arduino.h"
#include "esphome/core/log.h"
namespace esphome {
namespace libretiny {
namespace esphome::libretiny {
static const char *const TAG = "lt.gpio";
@@ -77,7 +76,9 @@ void ArduinoInternalGPIOPin::detach_interrupt() const {
detachInterrupt(pin_); // NOLINT
}
} // namespace libretiny
} // namespace esphome::libretiny
namespace esphome {
using namespace libretiny;

View File

@@ -3,8 +3,7 @@
#ifdef USE_LIBRETINY
#include "esphome/core/hal.h"
namespace esphome {
namespace libretiny {
namespace esphome::libretiny {
class ArduinoInternalGPIOPin : public InternalGPIOPin {
public:
@@ -31,7 +30,6 @@ class ArduinoInternalGPIOPin : public InternalGPIOPin {
gpio::Flags flags_{};
};
} // namespace libretiny
} // namespace esphome
} // namespace esphome::libretiny
#endif // USE_LIBRETINY

View File

@@ -4,8 +4,7 @@
#include "esphome/core/log.h"
namespace esphome {
namespace libretiny {
namespace esphome::libretiny {
static const char *const TAG = "lt.component";
@@ -15,6 +14,9 @@ void LTComponent::dump_config() {
" Version: %s\n"
" Loglevel: %u",
LT_BANNER_STR + 10, LT_LOGLEVEL);
#if defined(__OPTIMIZE_SIZE__) && __OPTIMIZE_LEVEL__ > 0 && __OPTIMIZE_LEVEL__ <= 3
ESP_LOGCONFIG(TAG, " Optimization: -Os, SDK: -O" STRINGIFY_MACRO(__OPTIMIZE_LEVEL__));
#endif
#ifdef USE_TEXT_SENSOR
if (this->version_ != nullptr) {
@@ -25,7 +27,6 @@ void LTComponent::dump_config() {
float LTComponent::get_setup_priority() const { return setup_priority::LATE; }
} // namespace libretiny
} // namespace esphome
} // namespace esphome::libretiny
#endif // USE_LIBRETINY

View File

@@ -12,8 +12,7 @@
#include "esphome/components/text_sensor/text_sensor.h"
#endif
namespace esphome {
namespace libretiny {
namespace esphome::libretiny {
class LTComponent : public Component {
public:
@@ -30,7 +29,6 @@ class LTComponent : public Component {
#endif // USE_TEXT_SENSOR
};
} // namespace libretiny
} // namespace esphome
} // namespace esphome::libretiny
#endif // USE_LIBRETINY

View File

@@ -8,8 +8,7 @@
#include <cstring>
#include <memory>
namespace esphome {
namespace libretiny {
namespace esphome::libretiny {
static const char *const TAG = "lt.preferences";
@@ -194,7 +193,9 @@ void setup_preferences() {
global_preferences = &s_preferences;
}
} // namespace libretiny
} // namespace esphome::libretiny
namespace esphome {
ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)

View File

@@ -2,12 +2,10 @@
#ifdef USE_LIBRETINY
namespace esphome {
namespace libretiny {
namespace esphome::libretiny {
void setup_preferences();
} // namespace libretiny
} // namespace esphome
} // namespace esphome::libretiny
#endif // USE_LIBRETINY

View File

@@ -141,7 +141,7 @@ enum UARTSelection : uint8_t {
* 2. Works with ESP-IDF's pthread implementation that uses a linked list for TLS variables
* 3. Avoids the limitations of the fixed FreeRTOS task local storage slots
*/
class Logger : public Component {
class Logger final : public Component {
public:
explicit Logger(uint32_t baud_rate);
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
@@ -481,7 +481,7 @@ class Logger : public Component {
};
extern Logger *global_logger; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
class LoggerMessageTrigger : public Trigger<uint8_t, const char *, const char *> {
class LoggerMessageTrigger final : public Trigger<uint8_t, const char *, const char *> {
public:
explicit LoggerMessageTrigger(Logger *parent, uint8_t level) : level_(level) {
parent->add_log_callback(this,

View File

@@ -40,7 +40,7 @@ struct MDNSService {
FixedVector<MDNSTXTRecord> txt_records;
};
class MDNSComponent : public Component {
class MDNSComponent final : public Component {
public:
void setup() override;
void dump_config() override;

View File

@@ -2,7 +2,11 @@ from esphome.components.mipi import DriverChip
import esphome.config_validation as cv
# fmt: off
DriverChip(
# Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1
# Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage
JD9365_10_1_DSI_TOUCH_A = DriverChip(
"WAVESHARE-P4-NANO-10.1",
height=1280,
width=800,
@@ -52,6 +56,15 @@ DriverChip(
],
)
# Standalone display
# Product page: https://www.waveshare.com/wiki/10.1-DSI-TOUCH-A
JD9365_10_1_DSI_TOUCH_A.extend(
"WAVESHARE-10.1-DSI-TOUCH-A",
)
# Source for parameters and initsequence:
# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703
# Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO
DriverChip(
"WAVESHARE-P4-86-PANEL",
height=720,
@@ -95,6 +108,9 @@ DriverChip(
],
)
# Source for parameters and initsequence:
# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B
DriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B",
height=600,
@@ -121,7 +137,10 @@ DriverChip(
],
)
DriverChip(
# Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C
JD9365_3_4_DSI_TOUCH_C = DriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C",
height=800,
width=800,
@@ -170,7 +189,16 @@ DriverChip(
],
)
DriverChip(
# Standalone display
# Product page: https://www.waveshare.com/wiki/3.4-DSI-TOUCH-C
JD9365_3_4_DSI_TOUCH_C.extend(
"WAVESHARE-3.4-DSI-TOUCH-C",
)
# Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C
JD9365_4_DSI_TOUCH_C = DriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C",
height=720,
width=720,
@@ -218,3 +246,108 @@ DriverChip(
(0xE0, 0x00), # select userpage
]
)
# Standalone display
# Product page: https://www.waveshare.com/wiki/4-DSI-TOUCH-C
JD9365_4_DSI_TOUCH_C.extend(
"WAVESHARE-4-DSI-TOUCH-C",
)
# Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
# Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A
DriverChip(
"WAVESHARE-8-DSI-TOUCH-A",
height=1280,
width=800,
hsync_back_porch=20,
hsync_pulse_width=20,
hsync_front_porch=40,
vsync_back_porch=12,
vsync_pulse_width=4,
vsync_front_porch=30,
pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0xE0, 0x00), # select userpage
(0xE1, 0x93), (0xE2, 0x65), (0xE3, 0xF8),
(0x80, 0x01), # Select number of lanes (2)
(0xE0, 0x01), # select page 1
(0x00, 0x00), (0x01, 0x4E), (0x03, 0x00), (0x04, 0x65), (0x0C, 0x74), (0x17, 0x00), (0x18, 0xB7), (0x19, 0x00),
(0x1A, 0x00), (0x1B, 0xB7), (0x1C, 0x00), (0x24, 0xFE), (0x37, 0x19), (0x38, 0x05), (0x39, 0x00), (0x3A, 0x01),
(0x3B, 0x01), (0x3C, 0x70), (0x3D, 0xFF), (0x3E, 0xFF), (0x3F, 0xFF), (0x40, 0x06), (0x41, 0xA0), (0x43, 0x1E),
(0x44, 0x0F), (0x45, 0x28), (0x4B, 0x04), (0x55, 0x02), (0x56, 0x01), (0x57, 0xA9), (0x58, 0x0A), (0x59, 0x0A),
(0x5A, 0x37), (0x5B, 0x19), (0x5D, 0x78), (0x5E, 0x63), (0x5F, 0x54), (0x60, 0x49), (0x61, 0x45), (0x62, 0x38),
(0x63, 0x3D), (0x64, 0x28), (0x65, 0x43), (0x66, 0x41), (0x67, 0x43), (0x68, 0x62), (0x69, 0x50), (0x6A, 0x57),
(0x6B, 0x49), (0x6C, 0x44), (0x6D, 0x37), (0x6E, 0x23), (0x6F, 0x10), (0x70, 0x78), (0x71, 0x63), (0x72, 0x54),
(0x73, 0x49), (0x74, 0x45), (0x75, 0x38), (0x76, 0x3D), (0x77, 0x28), (0x78, 0x43), (0x79, 0x41), (0x7A, 0x43),
(0x7B, 0x62), (0x7C, 0x50), (0x7D, 0x57), (0x7E, 0x49), (0x7F, 0x44), (0x80, 0x37), (0x81, 0x23), (0x82, 0x10),
(0xE0, 0x02), # select page 2
(0x00, 0x47), (0x01, 0x47), (0x02, 0x45), (0x03, 0x45), (0x04, 0x4B), (0x05, 0x4B), (0x06, 0x49), (0x07, 0x49),
(0x08, 0x41), (0x09, 0x1F), (0x0A, 0x1F), (0x0B, 0x1F), (0x0C, 0x1F), (0x0D, 0x1F), (0x0E, 0x1F), (0x0F, 0x5F),
(0x10, 0x5F), (0x11, 0x57), (0x12, 0x77), (0x13, 0x35), (0x14, 0x1F), (0x15, 0x1F), (0x16, 0x46), (0x17, 0x46),
(0x18, 0x44), (0x19, 0x44), (0x1A, 0x4A), (0x1B, 0x4A), (0x1C, 0x48), (0x1D, 0x48), (0x1E, 0x40), (0x1F, 0x1F),
(0x20, 0x1F), (0x21, 0x1F), (0x22, 0x1F), (0x23, 0x1F), (0x24, 0x1F), (0x25, 0x5F), (0x26, 0x5F), (0x27, 0x57),
(0x28, 0x77), (0x29, 0x35), (0x2A, 0x1F), (0x2B, 0x1F), (0x58, 0x40), (0x59, 0x00), (0x5A, 0x00), (0x5B, 0x10),
(0x5C, 0x06), (0x5D, 0x40), (0x5E, 0x01), (0x5F, 0x02), (0x60, 0x30), (0x61, 0x01), (0x62, 0x02), (0x63, 0x03),
(0x64, 0x6B), (0x65, 0x05), (0x66, 0x0C), (0x67, 0x73), (0x68, 0x09), (0x69, 0x03), (0x6A, 0x56), (0x6B, 0x08),
(0x6C, 0x00), (0x6D, 0x04), (0x6E, 0x04), (0x6F, 0x88), (0x70, 0x00), (0x71, 0x00), (0x72, 0x06), (0x73, 0x7B),
(0x74, 0x00), (0x75, 0xF8), (0x76, 0x00), (0x77, 0xD5), (0x78, 0x2E), (0x79, 0x12), (0x7A, 0x03), (0x7B, 0x00),
(0x7C, 0x00), (0x7D, 0x03), (0x7E, 0x7B),
(0xE0, 0x04), # select page 4
(0x00, 0x0E), (0x02, 0xB3), (0x09, 0x60), (0x0E, 0x2A), (0x36, 0x59), (0x37, 0x58), (0x2B, 0x0F),
(0xE0, 0x00), # select userpage
]
)
# Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c
# Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A
DriverChip(
"WAVESHARE-7-DSI-TOUCH-A",
height=1280,
width=720,
hsync_back_porch=239,
hsync_pulse_width=50,
hsync_front_porch=33,
vsync_back_porch=20,
vsync_pulse_width=30,
vsync_front_porch=2,
pclk_frequency="80MHz",
lane_bit_rate="1000Mbps",
no_transform=True,
color_order="RGB",
initsequence=[
(0xFF, 0x98, 0x81, 0x03),
(0x01, 0x00), (0x02, 0x00), (0x03, 0x73), (0x04, 0x00), (0x05, 0x00), (0x06, 0x0A), (0x07, 0x00), (0x08, 0x00),
(0x09, 0x61), (0x0A, 0x00), (0x0B, 0x00), (0x0C, 0x01), (0x0D, 0x00), (0x0E, 0x00), (0x0F, 0x61), (0x10, 0x61),
(0x11, 0x00), (0x12, 0x00), (0x13, 0x00), (0x14, 0x00), (0x15, 0x00), (0x16, 0x00), (0x17, 0x00), (0x18, 0x00),
(0x19, 0x00), (0x1A, 0x00), (0x1B, 0x00), (0x1C, 0x00), (0x1D, 0x00), (0x1E, 0x40), (0x1F, 0x80), (0x20, 0x06),
(0x21, 0x01), (0x22, 0x00), (0x23, 0x00), (0x24, 0x00), (0x25, 0x00), (0x26, 0x00), (0x27, 0x00), (0x28, 0x33),
(0x29, 0x03), (0x2A, 0x00), (0x2B, 0x00), (0x2C, 0x00), (0x2D, 0x00), (0x2E, 0x00), (0x2F, 0x00), (0x30, 0x00),
(0x31, 0x00), (0x32, 0x00), (0x33, 0x00), (0x34, 0x04), (0x35, 0x00), (0x36, 0x00), (0x37, 0x00), (0x38, 0x3C),
(0x39, 0x00), (0x3A, 0x00), (0x3B, 0x00), (0x3C, 0x00), (0x3D, 0x00), (0x3E, 0x00), (0x3F, 0x00), (0x40, 0x00),
(0x41, 0x00), (0x42, 0x00), (0x43, 0x00), (0x44, 0x00), (0x50, 0x10), (0x51, 0x32), (0x52, 0x54), (0x53, 0x76),
(0x54, 0x98), (0x55, 0xBA), (0x56, 0x10), (0x57, 0x32), (0x58, 0x54), (0x59, 0x76), (0x5A, 0x98), (0x5B, 0xBA),
(0x5C, 0xDC), (0x5D, 0xFE), (0x5E, 0x00), (0x5F, 0x0E), (0x60, 0x0F), (0x61, 0x0C), (0x62, 0x0D), (0x63, 0x06),
(0x64, 0x07), (0x65, 0x02), (0x66, 0x02), (0x67, 0x02), (0x68, 0x02), (0x69, 0x01), (0x6A, 0x00), (0x6B, 0x02),
(0x6C, 0x15), (0x6D, 0x14), (0x6E, 0x02), (0x6F, 0x02), (0x70, 0x02), (0x71, 0x02), (0x72, 0x02), (0x73, 0x02),
(0x74, 0x02), (0x75, 0x0E), (0x76, 0x0F), (0x77, 0x0C), (0x78, 0x0D), (0x79, 0x06), (0x7A, 0x07), (0x7B, 0x02),
(0x7C, 0x02), (0x7D, 0x02), (0x7E, 0x02), (0x7F, 0x01), (0x80, 0x00), (0x81, 0x02), (0x82, 0x14), (0x83, 0x15),
(0x84, 0x02), (0x85, 0x02), (0x86, 0x02), (0x87, 0x02), (0x88, 0x02), (0x89, 0x02), (0x8A, 0x02),
(0xFF, 0x98, 0x81, 0x04),
(0x38, 0x01), (0x39, 0x00), (0x6C, 0x15), (0x6E, 0x2A), (0x6F, 0x33), (0x3A, 0x94), (0x8D, 0x14), (0x87, 0xBA),
(0x26, 0x76), (0xB2, 0xD1), (0xB5, 0x06), (0x3B, 0x98),
(0xFF, 0x98, 0x81, 0x01),
(0x22, 0x0A), (0x31, 0x00), (0x53, 0x71), (0x55, 0x8F), (0x40, 0x33), (0x50, 0x96), (0x51, 0x96), (0x60, 0x23),
(0xA0, 0x08), (0xA1, 0x1D), (0xA2, 0x2A), (0xA3, 0x10), (0xA4, 0x15), (0xA5, 0x28), (0xA6, 0x1C), (0xA7, 0x1D),
(0xA8, 0x7E), (0xA9, 0x1D), (0xAA, 0x29), (0xAB, 0x6B), (0xAC, 0x1A), (0xAD, 0x18), (0xAE, 0x4B), (0xAF, 0x20),
(0xB0, 0x27), (0xB1, 0x50), (0xB2, 0x64), (0xB3, 0x39), (0xC0, 0x08), (0xC1, 0x1D), (0xC2, 0x2A), (0xC3, 0x10),
(0xC4, 0x15), (0xC5, 0x28), (0xC6, 0x1C), (0xC7, 0x1D), (0xC8, 0x7E), (0xC9, 0x1D), (0xCA, 0x29), (0xCB, 0x6B),
(0xCC, 0x1A), (0xCD, 0x18), (0xCE, 0x4B), (0xCF, 0x20), (0xD0, 0x27), (0xD1, 0x50), (0xD2, 0x64), (0xD3, 0x39),
(0xFF, 0x98, 0x81, 0x00),
(0x3A, 0x77), (0x36, 0x00), (0x35, 0x00), (0x35, 0x00),
],
)

View File

@@ -20,9 +20,10 @@ static ProgmemStr climate_mode_to_mqtt_str(ClimateMode mode) {
return ClimateMqttModeStrings::get_progmem_str(static_cast<uint8_t>(mode), ClimateMqttModeStrings::LAST_INDEX);
}
// Climate action MQTT strings indexed by ClimateAction enum (0,2-6): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN
// Climate action MQTT strings indexed by ClimateAction enum (0,2-7): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN,
// DEFROSTING
PROGMEM_STRING_TABLE(ClimateMqttActionStrings, "off", "unknown", "cooling", "heating", "idle", "drying", "fan",
"unknown");
"defrosting", "unknown");
static ProgmemStr climate_action_to_mqtt_str(ClimateAction action) {
return ClimateMqttActionStrings::get_progmem_str(static_cast<uint8_t>(action), ClimateMqttActionStrings::LAST_INDEX);

View File

@@ -7,7 +7,7 @@
namespace esphome {
namespace ota {
class ArduinoLibreTinyOTABackend : public OTABackend {
class ArduinoLibreTinyOTABackend final : public OTABackend {
public:
OTAResponseTypes begin(size_t image_size) override;
void set_update_md5(const char *md5) override;

View File

@@ -9,7 +9,7 @@
namespace esphome {
namespace ota {
class ArduinoRP2040OTABackend : public OTABackend {
class ArduinoRP2040OTABackend final : public OTABackend {
public:
OTAResponseTypes begin(size_t image_size) override;
void set_update_md5(const char *md5) override;

View File

@@ -12,7 +12,7 @@ namespace esphome::ota {
/// OTA backend for ESP8266 using native SDK functions.
/// This implementation bypasses the Arduino Updater library to save ~228 bytes of RAM
/// by not having a global Update object in .bss.
class ESP8266OTABackend : public OTABackend {
class ESP8266OTABackend final : public OTABackend {
public:
OTAResponseTypes begin(size_t image_size) override;
void set_update_md5(const char *md5) override;

View File

@@ -10,7 +10,7 @@
namespace esphome {
namespace ota {
class IDFOTABackend : public OTABackend {
class IDFOTABackend final : public OTABackend {
public:
OTAResponseTypes begin(size_t image_size) override;
void set_update_md5(const char *md5) override;

View File

@@ -7,7 +7,7 @@ namespace esphome::ota {
/// Stub OTA backend for host platform - allows compilation but does not implement OTA.
/// All operations return error codes immediately. This enables configurations with
/// OTA triggers to compile for host platform during development.
class HostOTABackend : public OTABackend {
class HostOTABackend final : public OTABackend {
public:
OTAResponseTypes begin(size_t image_size) override;
void set_update_md5(const char *md5) override;

View File

@@ -91,18 +91,18 @@ def _parse_platform_version(value):
# The default/recommended arduino framework version
# - https://github.com/earlephilhower/arduino-pico/releases
# - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 9, 4)
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 5, 0)
# The raspberrypi platform version to use for arduino frameworks
# - https://github.com/maxgerhardt/platform-raspberrypi/tags
RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.2.0-gcc12"
RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460"
def _arduino_check_versions(value):
value = value.copy()
lookups = {
"dev": (cv.Version(3, 9, 4), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(3, 9, 4), None),
"dev": (cv.Version(5, 5, 0), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(5, 5, 0), None),
"recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None),
}

View File

@@ -106,7 +106,7 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
sio_hw->gpio_oe_set = arg->mask;
} else if (flags & gpio::FLAG_INPUT) {
sio_hw->gpio_oe_clr = arg->mask;
hw_write_masked(&padsbank0_hw->io[arg->pin],
hw_write_masked(&pads_bank0_hw->io[arg->pin],
(bool_to_bit(flags & gpio::FLAG_PULLUP) << PADS_BANK0_GPIO0_PUE_LSB) |
(bool_to_bit(flags & gpio::FLAG_PULLDOWN) << PADS_BANK0_GPIO0_PDE_LSB),
PADS_BANK0_GPIO0_PUE_BITS | PADS_BANK0_GPIO0_PDE_BITS);

View File

@@ -8,7 +8,7 @@
namespace esphome::safe_mode {
class SafeModeTrigger : public Trigger<> {
class SafeModeTrigger final : public Trigger<> {
public:
explicit SafeModeTrigger(SafeModeComponent *parent) {
parent->add_on_safe_mode_callback([this]() { trigger(); });

View File

@@ -15,7 +15,7 @@ namespace esphome::safe_mode {
constexpr uint32_t RTC_KEY = 233825507UL;
/// SafeModeComponent provides a safe way to recover from repeated boot failures
class SafeModeComponent : public Component {
class SafeModeComponent final : public Component {
public:
bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after);

View File

@@ -603,7 +603,7 @@ DELTA_SCHEMA = cv.Any(
def _get_delta(value):
if isinstance(value, str):
assert value.endswith("%")
return 0.0, float(value[:-1])
return 0.0, float(value[:-1]) / 100.0
return value, 0.0

View File

@@ -134,6 +134,8 @@ def require_wake_loop_threadsafe() -> None:
IMPORTANT: This is for background thread context only, NOT ISR context.
Socket operations are not safe to call from ISR handlers.
On ESP32, FreeRTOS task notifications are used instead (no socket needed).
Example:
from esphome.components import socket
@@ -147,8 +149,10 @@ def require_wake_loop_threadsafe() -> None:
):
CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True
cg.add_define("USE_WAKE_LOOP_THREADSAFE")
# Consume 1 socket for the shared wake notification socket
consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({})
if not CORE.is_esp32:
# Only non-ESP32 platforms need a UDP socket for wake notifications.
# ESP32 uses FreeRTOS task notifications instead (no socket needed).
consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({})
CONFIG_SCHEMA = cv.Schema(

View File

@@ -71,7 +71,7 @@ class Socket {
int get_fd() const { return -1; }
#endif
/// Check if socket has data ready to read
/// Check if socket has data ready to read. Must only be called from the main loop thread.
/// For select()-based sockets: non-virtual, checks Application's select() results
/// For LWIP raw TCP sockets: virtual, checks internal buffer state
#ifdef USE_SOCKET_SELECT_SUPPORT

View File

@@ -84,32 +84,30 @@ SprinklerValveOperator::SprinklerValveOperator(SprinklerValve *valve, Sprinkler
: controller_(controller), valve_(valve) {}
void SprinklerValveOperator::loop() {
// Use wrapping subtraction so 32-bit millis() rollover is handled correctly:
// (now - start) yields the true elapsed time even across the 49.7-day boundary.
uint32_t now = App.get_loop_component_start_time();
if (now >= this->start_millis_) { // dummy check
switch (this->state_) {
case STARTING:
if (now > (this->start_millis_ + this->start_delay_)) {
this->run_(); // start_delay_ has been exceeded, so ensure both valves are on and update the state
}
break;
switch (this->state_) {
case STARTING:
if ((now - *this->start_millis_) > this->start_delay_) {
this->run_(); // start_delay_ has been exceeded, so ensure both valves are on and update the state
}
break;
case ACTIVE:
if (now > (this->start_millis_ + this->start_delay_ + this->run_duration_)) {
this->stop(); // start_delay_ + run_duration_ has been exceeded, start shutting down
}
break;
case ACTIVE:
if ((now - *this->start_millis_) > (this->start_delay_ + this->run_duration_)) {
this->stop(); // start_delay_ + run_duration_ has been exceeded, start shutting down
}
break;
case STOPPING:
if (now > (this->stop_millis_ + this->stop_delay_)) {
this->kill_(); // stop_delay_has been exceeded, ensure all valves are off
}
break;
case STOPPING:
if ((now - *this->stop_millis_) > this->stop_delay_) {
this->kill_(); // stop_delay_has been exceeded, ensure all valves are off
}
break;
default:
break;
}
} else { // perhaps millis() rolled over...or something else is horribly wrong!
this->stop(); // bail out (TODO: handle this highly unlikely situation better...)
default:
break;
}
}
@@ -124,11 +122,11 @@ void SprinklerValveOperator::set_valve(SprinklerValve *valve) {
if (this->state_ != IDLE) { // Only kill if not already idle
this->kill_(); // ensure everything is off before we let go!
}
this->state_ = IDLE; // reset state
this->run_duration_ = 0; // reset to ensure the valve isn't started without updating it
this->start_millis_ = 0; // reset because (new) valve has not been started yet
this->stop_millis_ = 0; // reset because (new) valve has not been started yet
this->valve_ = valve; // finally, set the pointer to the new valve
this->state_ = IDLE; // reset state
this->run_duration_ = 0; // reset to ensure the valve isn't started without updating it
this->start_millis_.reset(); // reset because (new) valve has not been started yet
this->stop_millis_.reset(); // reset because (new) valve has not been started yet
this->valve_ = valve; // finally, set the pointer to the new valve
}
}
@@ -162,7 +160,7 @@ void SprinklerValveOperator::start() {
} else {
this->run_(); // there is no start_delay_, so just start the pump and valve
}
this->stop_millis_ = 0;
this->stop_millis_.reset();
this->start_millis_ = millis(); // save the time the start request was made
}
@@ -189,22 +187,25 @@ void SprinklerValveOperator::stop() {
uint32_t SprinklerValveOperator::run_duration() { return this->run_duration_ / 1000; }
uint32_t SprinklerValveOperator::time_remaining() {
if (this->start_millis_ == 0) {
if (!this->start_millis_.has_value()) {
return this->run_duration(); // hasn't been started yet
}
if (this->stop_millis_) {
if (this->stop_millis_ - this->start_millis_ >= this->start_delay_ + this->run_duration_) {
if (this->stop_millis_.has_value()) {
uint32_t elapsed = *this->stop_millis_ - *this->start_millis_;
if (elapsed >= this->start_delay_ + this->run_duration_) {
return 0; // valve was active for more than its configured duration, so we are done
} else {
// we're stopped; return time remaining
return (this->run_duration_ - (this->stop_millis_ - this->start_millis_)) / 1000;
}
if (elapsed <= this->start_delay_) {
return this->run_duration_ / 1000; // stopped during start delay, full run duration remains
}
return (this->run_duration_ - (elapsed - this->start_delay_)) / 1000;
}
auto completed_millis = this->start_millis_ + this->start_delay_ + this->run_duration_;
if (completed_millis > millis()) {
return (completed_millis - millis()) / 1000; // running now
uint32_t elapsed = millis() - *this->start_millis_;
uint32_t total_duration = this->start_delay_ + this->run_duration_;
if (elapsed < total_duration) {
return (total_duration - elapsed) / 1000; // running now
}
return 0; // run completed
}
@@ -593,7 +594,7 @@ void Sprinkler::set_repeat(optional<uint32_t> repeat) {
if (this->repeat_number_ == nullptr) {
return;
}
if (this->repeat_number_->state == repeat.value()) {
if (this->repeat_number_->state == repeat.value_or(0)) {
return;
}
auto call = this->repeat_number_->make_call();
@@ -793,7 +794,7 @@ void Sprinkler::start_single_valve(const optional<size_t> valve_number, optional
void Sprinkler::queue_valve(optional<size_t> valve_number, optional<uint32_t> run_duration) {
if (valve_number.has_value()) {
if (this->is_a_valid_valve(valve_number.value()) && (this->queued_valves_.size() < this->max_queue_size_)) {
SprinklerQueueItem item{valve_number.value(), run_duration.value()};
SprinklerQueueItem item{valve_number.value(), run_duration.value_or(0)};
this->queued_valves_.insert(this->queued_valves_.begin(), item);
ESP_LOGD(TAG, "Valve %zu placed into queue with run duration of %" PRIu32 " seconds", valve_number.value_or(0),
run_duration.value_or(0));
@@ -1080,7 +1081,7 @@ uint32_t Sprinkler::total_cycle_time_enabled_incomplete_valves() {
}
}
if (incomplete_valve_count >= enabled_valve_count) {
if (incomplete_valve_count > 0 && incomplete_valve_count >= enabled_valve_count) {
incomplete_valve_count--;
}
if (incomplete_valve_count) {

View File

@@ -141,8 +141,8 @@ class SprinklerValveOperator {
uint32_t start_delay_{0};
uint32_t stop_delay_{0};
uint32_t run_duration_{0};
uint64_t start_millis_{0};
uint64_t stop_millis_{0};
optional<uint32_t> start_millis_{};
optional<uint32_t> stop_millis_{};
Sprinkler *controller_{nullptr};
SprinklerValve *valve_{nullptr};
SprinklerState state_{IDLE};

View File

@@ -0,0 +1,488 @@
#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 {
// Remove before 2026.9.0: parse_uint, skip_tz_name, parse_offset, parse_dst_rule,
// and parse_transition_time are only used by parse_posix_tz() (bridge code).
static uint32_t parse_uint(const char *&p) {
uint32_t value = 0;
while (std::isdigit(static_cast<unsigned char>(*p))) {
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)) && year < 2200) {
days -= diy;
year++;
}
while (days < 0 && year > 1900) {
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);
}
}
// Remove before 2026.9.0: This parser is bridge code for backward compatibility with
// older Home Assistant clients that send the timezone as a POSIX TZ string instead of
// the pre-parsed ParsedTimezone protobuf struct. Once all clients send the struct
// directly, this function and the parsing helpers above (skip_tz_name, parse_offset,
// parse_dst_rule, parse_transition_time) can be removed.
// See https://github.com/esphome/backlog/issues/91
bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) {
if (!tz_string || !*tz_string) {
return false;
}
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,144 @@
#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.
///
/// @deprecated Remove before 2026.9.0 (bridge code for backward compatibility).
/// This parser only exists so that older Home Assistant clients that send the timezone
/// as a string (instead of the pre-parsed ParsedTimezone protobuf struct) can still
/// set the timezone on the device. Once all clients are updated to send the struct
/// directly, this function and all internal parsing helpers will be removed.
/// See https://github.com/esphome/backlog/issues/91
///
/// Supports formats like:
/// - "EST5" (simple offset, no DST)
/// - "EST5EDT,M3.2.0,M11.1.0" (with DST, M-format rules)
/// - "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.
// Remove before 2026.9.0: skip_tz_name, parse_offset, parse_dst_rule are only
// used by parse_posix_tz() which is bridge code for backward compatibility.
// The remaining helpers (epoch_to_tm_utc, day_of_week, days_in_month, etc.)
// are used by the conversion functions and will stay.
namespace internal {
/// 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,33 @@ 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') {
// Skip if already UTC
if (!get_global_tz().has_dst() && get_global_tz().std_offset_seconds == 0) {
return;
}
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);
return;
}
// 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
LazyCallbackManager<void()> time_sync_callback_;

View File

@@ -115,8 +115,8 @@ void RP2040UartComponent::setup() {
if (tx_hw == -1 || rx_hw == -1 || tx_hw != rx_hw) {
ESP_LOGV(TAG, "Using SerialPIO");
pin_size_t tx = this->tx_pin_ == nullptr ? SerialPIO::NOPIN : this->tx_pin_->get_pin();
pin_size_t rx = this->rx_pin_ == nullptr ? SerialPIO::NOPIN : this->rx_pin_->get_pin();
pin_size_t tx = this->tx_pin_ == nullptr ? NOPIN : this->tx_pin_->get_pin();
pin_size_t rx = this->rx_pin_ == nullptr ? NOPIN : this->rx_pin_->get_pin();
auto *serial = new SerialPIO(tx, rx, this->rx_buffer_size_); // NOLINT(cppcoreguidelines-owning-memory)
serial->begin(this->baud_rate_, config);
if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted())

View File

@@ -14,8 +14,6 @@ ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, AsyncEventSource
ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es)
: web_server_(ws), events_(es) {}
#endif
ListEntitiesIterator::~ListEntitiesIterator() {}
#ifdef USE_BINARY_SENSOR
bool ListEntitiesIterator::on_binary_sensor(binary_sensor::BinarySensor *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::binary_sensor_all_json_generator);

View File

@@ -17,14 +17,13 @@ class DeferredUpdateEventSource;
#endif
class WebServer;
class ListEntitiesIterator : public ComponentIterator {
class ListEntitiesIterator final : public ComponentIterator {
public:
#ifdef USE_ESP32
ListEntitiesIterator(const WebServer *ws, esphome::web_server_idf::AsyncEventSource *es);
#elif defined(USE_ARDUINO)
ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es);
#endif
virtual ~ListEntitiesIterator();
#ifdef USE_BINARY_SENSOR
bool on_binary_sensor(binary_sensor::BinarySensor *obj) override;
#endif

View File

@@ -9,7 +9,7 @@
namespace esphome::web_server {
class WebServerOTAComponent : public ota::OTAComponent {
class WebServerOTAComponent final : public ota::OTAComponent {
public:
void setup() override;
void dump_config() override;

View File

@@ -9,7 +9,7 @@
namespace esphome::web_server {
#ifdef USE_WEBSERVER_GZIP
const uint8_t INDEX_GZ[] PROGMEM = {
constexpr uint8_t INDEX_GZ[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x7d, 0xd9, 0x72, 0xdb, 0x48, 0xb6, 0xe0, 0xf3,
0xd4, 0x57, 0x40, 0x28, 0xb5, 0x8c, 0x2c, 0x26, 0xc1, 0x45, 0x92, 0x2d, 0x83, 0x4a, 0xb2, 0x65, 0xd9, 0xd5, 0x76,
0x97, 0xb7, 0xb6, 0xec, 0xda, 0x58, 0x6c, 0x09, 0x02, 0x92, 0x44, 0x96, 0x41, 0x80, 0x05, 0x24, 0xb5, 0x14, 0x89,
@@ -698,7 +698,7 @@ const uint8_t INDEX_GZ[] PROGMEM = {
0x01, 0x65, 0x21, 0x07, 0x4b, 0xe3, 0x97, 0x00, 0x00};
#else // Brotli (default, smaller)
const uint8_t INDEX_BR[] PROGMEM = {
constexpr uint8_t INDEX_BR[] PROGMEM = {
0x1b, 0xe2, 0x97, 0xa3, 0x90, 0xa2, 0x95, 0x55, 0x51, 0x04, 0x1b, 0x07, 0x80, 0x20, 0x79, 0x0e, 0x50, 0xab, 0x02,
0xdb, 0x98, 0x16, 0xf4, 0x7b, 0x22, 0xa3, 0x4d, 0xd3, 0x86, 0xc1, 0x26, 0x48, 0x49, 0x60, 0xbe, 0xb3, 0xc9, 0xa1,
0x8c, 0x96, 0x10, 0x1b, 0x21, 0xcf, 0x48, 0x68, 0xce, 0x10, 0x34, 0x32, 0x7c, 0xbf, 0x71, 0x7b, 0x03, 0x8f, 0xdd,

View File

@@ -9,7 +9,7 @@
namespace esphome::web_server {
#ifdef USE_WEBSERVER_GZIP
const uint8_t INDEX_GZ[] PROGMEM = {
constexpr uint8_t INDEX_GZ[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xcc, 0xbd, 0x7b, 0x7f, 0x1a, 0xb9, 0xb2, 0x28, 0xfa,
0xf7, 0x3d, 0x9f, 0xc2, 0xee, 0x9d, 0xf1, 0xb4, 0x8c, 0x68, 0x03, 0x36, 0x8e, 0xd3, 0x58, 0xe6, 0xe4, 0x39, 0xc9,
0x3c, 0x92, 0x4c, 0x9c, 0x64, 0x26, 0xc3, 0xb0, 0x33, 0xa2, 0x11, 0xa0, 0xa4, 0x91, 0x98, 0x96, 0x88, 0xed, 0x01,
@@ -4107,7 +4107,7 @@ const uint8_t INDEX_GZ[] PROGMEM = {
0xe8, 0xcd, 0xfe, 0x2c, 0x9d, 0x07, 0xfd, 0xff, 0x05, 0x64, 0x23, 0xa6, 0xdb, 0x06, 0x7b, 0x03, 0x00};
#else // Brotli (default, smaller)
const uint8_t INDEX_BR[] PROGMEM = {
constexpr uint8_t INDEX_BR[] PROGMEM = {
0x5b, 0x05, 0x7b, 0x53, 0xc1, 0xb6, 0x69, 0x3d, 0x41, 0xeb, 0x04, 0x30, 0xf6, 0xd6, 0x77, 0x35, 0xdb, 0xa3, 0x08,
0x36, 0x0e, 0x04, 0x80, 0x90, 0x4f, 0xf1, 0xb2, 0x21, 0xa4, 0x82, 0xee, 0x00, 0xaa, 0x20, 0x7f, 0x3b, 0xff, 0x00,
0xaa, 0x9a, 0x73, 0x74, 0x8c, 0xe1, 0xa6, 0x1f, 0xa0, 0xa2, 0x59, 0xf5, 0xaa, 0x92, 0x79, 0x50, 0x43, 0x1f, 0xe8,

View File

@@ -107,7 +107,7 @@ enum JsonDetail { DETAIL_ALL, DETAIL_STATE };
using message_generator_t = json::SerializationBuffer<>(WebServer *, void *);
class DeferredUpdateEventSourceList;
class DeferredUpdateEventSource : public AsyncEventSource {
class DeferredUpdateEventSource final : public AsyncEventSource {
friend class DeferredUpdateEventSourceList;
/*
@@ -163,7 +163,7 @@ class DeferredUpdateEventSource : public AsyncEventSource {
void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0);
};
class DeferredUpdateEventSourceList : public std::list<DeferredUpdateEventSource *> {
class DeferredUpdateEventSourceList final : public std::list<DeferredUpdateEventSource *> {
protected:
void on_client_connect_(DeferredUpdateEventSource *source);
void on_client_disconnect_(DeferredUpdateEventSource *source);
@@ -187,7 +187,7 @@ class DeferredUpdateEventSourceList : public std::list<DeferredUpdateEventSource
* under the '/light/...', '/sensor/...', ... URLs. A full documentation for this API
* can be found under https://esphome.io/web-api/.
*/
class WebServer : public Controller, public Component, public AsyncWebHandler {
class WebServer final : public Controller, public Component, public AsyncWebHandler {
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
friend class DeferredUpdateEventSourceList;
#endif

View File

@@ -921,7 +921,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c
});
// Use heap buffer - 1460 bytes is too large for the httpd task stack
auto buffer = std::make_unique<char[]>(MULTIPART_CHUNK_SIZE);
auto buffer = std::make_unique_for_overwrite<char[]>(MULTIPART_CHUNK_SIZE);
size_t bytes_since_yield = 0;
for (size_t remaining = r->content_len; remaining > 0;) {

View File

@@ -3,6 +3,7 @@
#include <cassert>
#include <cinttypes>
#include <cmath>
#include <type_traits>
#ifdef USE_ESP32
#if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1)
@@ -1334,20 +1335,61 @@ void WiFiComponent::start_scanning() {
// Using insertion sort instead of std::stable_sort saves flash memory
// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas)
// IMPORTANT: This sort is stable (preserves relative order of equal elements)
//
// Uses raw memcpy instead of copy assignment to avoid CompactString's
// destructor/constructor overhead (heap delete[]/new[] for long SSIDs).
// Copy assignment calls ~CompactString() then placement-new for every shift,
// which means delete[]/new[] per shift for heap-allocated SSIDs. With 70+
// networks (e.g., captive portal showing full scan results), this caused
// event loop blocking from hundreds of heap operations in a tight loop.
//
// This is safe because we're permuting elements within the same array —
// each slot is overwritten exactly once, so no ownership duplication occurs.
// All members of WiFiScanResult are either trivially copyable (bssid, channel,
// rssi, priority, flags) or CompactString, which stores either inline data or
// a heap pointer — never a self-referential pointer (unlike std::string's SSO
// on some implementations). This was not possible before PR#13472 replaced
// std::string with CompactString, since std::string's internal layout is
// implementation-defined and may use self-referential pointers.
//
// TODO: If C++ standardizes std::trivially_relocatable, add the assertion for
// WiFiScanResult/CompactString here to formally express the memcpy safety guarantee.
template<typename VectorType> static void insertion_sort_scan_results(VectorType &results) {
// memcpy-based sort requires no self-referential pointers or virtual dispatch.
// These static_asserts guard the assumptions. If any fire, the memcpy sort
// must be reviewed for safety before updating the expected values.
//
// No vtable pointers (memcpy would corrupt vptr)
static_assert(!std::is_polymorphic<WiFiScanResult>::value, "WiFiScanResult must not have vtable");
static_assert(!std::is_polymorphic<CompactString>::value, "CompactString must not have vtable");
// Standard layout ensures predictable memory layout with no virtual bases
// and no mixed-access-specifier reordering
static_assert(std::is_standard_layout<WiFiScanResult>::value, "WiFiScanResult must be standard layout");
static_assert(std::is_standard_layout<CompactString>::value, "CompactString must be standard layout");
// Size checks catch added/removed fields that may need safety review
static_assert(sizeof(WiFiScanResult) == 32, "WiFiScanResult size changed - verify memcpy sort is still safe");
static_assert(sizeof(CompactString) == 20, "CompactString size changed - verify memcpy sort is still safe");
// Alignment must match for reinterpret_cast of key_buf to be valid
static_assert(alignof(WiFiScanResult) <= alignof(std::max_align_t), "WiFiScanResult alignment exceeds max_align_t");
const size_t size = results.size();
constexpr size_t elem_size = sizeof(WiFiScanResult);
// Suppress warnings for intentional memcpy on non-trivially-copyable type.
// Safety is guaranteed by the static_asserts above and the permutation invariant.
// NOLINTNEXTLINE(bugprone-undefined-memory-manipulation)
auto *memcpy_fn = &memcpy;
for (size_t i = 1; i < size; i++) {
// Make a copy to avoid issues with move semantics during comparison
WiFiScanResult key = results[i];
alignas(WiFiScanResult) uint8_t key_buf[elem_size];
memcpy_fn(key_buf, &results[i], elem_size);
const auto &key = *reinterpret_cast<const WiFiScanResult *>(key_buf);
int32_t j = i - 1;
// Move elements that are worse than key to the right
// For stability, we only move if key is strictly better than results[j]
while (j >= 0 && wifi_scan_result_is_better(key, results[j])) {
results[j + 1] = results[j];
memcpy_fn(&results[j + 1], &results[j], elem_size);
j--;
}
results[j + 1] = key;
memcpy_fn(&results[j + 1], key_buf, elem_size);
}
}

View File

@@ -10,6 +10,7 @@
#include <span>
#include <string>
#include <type_traits>
#include <vector>
#ifdef USE_LIBRETINY
@@ -223,6 +224,14 @@ class CompactString {
};
static_assert(sizeof(CompactString) == 20, "CompactString must be exactly 20 bytes");
// CompactString is not trivially copyable (non-trivial destructor/copy for heap case).
// However, its layout has no self-referential pointers: storage_[] contains either inline
// data or an external heap pointer — never a pointer to itself. This is unlike libstdc++
// std::string SSO where _M_p points to _M_local_buf within the same object.
// This property allows memcpy-based permutation sorting where each element ends up in
// exactly one slot (no ownership duplication). These asserts document that layout property.
static_assert(std::is_standard_layout<CompactString>::value, "CompactString must be standard layout");
static_assert(!std::is_polymorphic<CompactString>::value, "CompactString must not have vtable");
class WiFiAP {
friend class WiFiComponent;

View File

@@ -78,8 +78,13 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
return false;
#endif
auto ret = WiFi.begin(ap.ssid_.c_str(), ap.password_.c_str());
if (ret != WL_CONNECTED)
// Use beginNoBlock to avoid WiFi.begin()'s additional 2x timeout wait loop on top of
// CYW43::begin()'s internal blocking join. CYW43::begin() blocks for up to 10 seconds
// (default timeout) to complete the join - this is required because the LwipIntfDev netif
// setup depends on begin() succeeding. beginNoBlock() skips the outer wait loop, saving
// up to 20 additional seconds of blocking per attempt.
auto ret = WiFi.beginNoBlock(ap.ssid_.c_str(), ap.password_.c_str());
if (ret == WL_IDLE_STATUS)
return false;
return true;
@@ -116,13 +121,19 @@ const char *get_disconnect_reason_str(uint8_t reason) {
}
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() {
int status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA);
// Use cyw43_wifi_link_status instead of cyw43_tcpip_link_status because the Arduino
// framework's __wrap_cyw43_cb_tcpip_init is a no-op — the SDK's internal netif
// (cyw43_state.netif[]) is never initialized. cyw43_tcpip_link_status checks that netif's
// flags and would only fall through to cyw43_wifi_link_status when the flags aren't set.
// Using cyw43_wifi_link_status directly gives us the actual WiFi radio join state.
int status = cyw43_wifi_link_status(&cyw43_state, CYW43_ITF_STA);
switch (status) {
case CYW43_LINK_JOIN:
case CYW43_LINK_NOIP:
// WiFi joined, check if we have an IP address via the Arduino framework's WiFi class
if (WiFi.status() == WL_CONNECTED) {
return WiFiSTAConnectStatus::CONNECTED;
}
return WiFiSTAConnectStatus::CONNECTING;
case CYW43_LINK_UP:
return WiFiSTAConnectStatus::CONNECTED;
case CYW43_LINK_FAIL:
case CYW43_LINK_BADAUTH:
return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED;
@@ -139,18 +150,24 @@ int WiFiComponent::s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *r
void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) {
s_scan_result_count++;
const char *ssid_cstr = reinterpret_cast<const char *>(result->ssid);
// CYW43 scan results have ssid as a 32-byte buffer that is NOT null-terminated.
// Use ssid_len to create a properly terminated copy for string operations.
uint8_t len = std::min(result->ssid_len, static_cast<uint8_t>(sizeof(result->ssid)));
char ssid_buf[33]; // 32 max + null terminator
memcpy(ssid_buf, result->ssid, len);
ssid_buf[len] = '\0';
// Skip networks that don't match any configured network (unless full results needed)
if (!this->needs_full_scan_results_() && !this->matches_configured_network_(ssid_cstr, result->bssid)) {
this->log_discarded_scan_result_(ssid_cstr, result->bssid, result->rssi, result->channel);
if (!this->needs_full_scan_results_() && !this->matches_configured_network_(ssid_buf, result->bssid)) {
this->log_discarded_scan_result_(ssid_buf, result->bssid, result->rssi, result->channel);
return;
}
bssid_t bssid;
std::copy(result->bssid, result->bssid + 6, bssid.begin());
WiFiScanResult res(bssid, ssid_cstr, strlen(ssid_cstr), result->channel, result->rssi,
result->auth_mode != CYW43_AUTH_OPEN, ssid_cstr[0] == '\0');
WiFiScanResult res(bssid, ssid_buf, len, result->channel, result->rssi, result->auth_mode != CYW43_AUTH_OPEN,
len == 0);
if (std::find(this->scan_result_.begin(), this->scan_result_.end(), res) == this->scan_result_.end()) {
this->scan_result_.push_back(res);
}
@@ -167,7 +184,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
ESP_LOGV(TAG, "cyw43_wifi_scan failed");
}
return err == 0;
return true;
}
#ifdef USE_WIFI_AP
@@ -212,8 +228,10 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t *
#endif // USE_WIFI_AP
bool WiFiComponent::wifi_disconnect_() {
int err = cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA);
return err == 0;
// Use Arduino WiFi.disconnect() instead of raw cyw43_wifi_leave() to properly
// clean up the lwIP netif, DHCP client, and internal Arduino state.
WiFi.disconnect();
return true;
}
bssid_t WiFiComponent::wifi_bssid() {
@@ -269,9 +287,10 @@ void WiFiComponent::wifi_loop_() {
// Poll for connection state changes
// The arduino-pico WiFi library doesn't have event callbacks like ESP8266/ESP32,
// so we need to poll the link status to detect state changes
auto status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA);
bool is_connected = (status == CYW43_LINK_UP);
// so we need to poll the link status to detect state changes.
// Use WiFi.connected() which checks both the WiFi link and IP address via the
// Arduino framework's own netif (not the SDK's uninitialized one).
bool is_connected = WiFi.connected();
// Detect connection state change
if (is_connected && !s_sta_was_connected) {

View File

@@ -9,6 +9,9 @@
#endif
#ifdef USE_ESP32
#include <esp_chip_info.h>
#include "esphome/core/lwip_fast_select.h"
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#endif
#include "esphome/core/version.h"
#include "esphome/core/hal.h"
@@ -144,8 +147,14 @@ void Application::setup() {
clear_setup_priority_overrides();
#endif
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
// Set up wake socket for waking main loop from tasks
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32)
// Initialize fast select: saves main loop task handle for xTaskNotifyGive wake.
// Always init on ESP32 — the fast path (rcvevent reads + ulTaskNotifyTake) is used
// unconditionally when USE_SOCKET_SELECT_SUPPORT is enabled.
esphome_lwip_fast_select_init();
#endif
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
// Set up wake socket for waking main loop from tasks (non-ESP32 only)
this->setup_wake_loop_threadsafe_();
#endif
@@ -523,7 +532,7 @@ void Application::enable_pending_loops_() {
}
void Application::before_loop_tasks_(uint32_t loop_start_time) {
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
// Drain wake notifications first to clear socket for next wake
this->drain_wake_notifications_();
#endif
@@ -576,11 +585,15 @@ bool Application::register_socket_fd(int fd) {
#endif
this->socket_fds_.push_back(fd);
#ifdef USE_ESP32
// Hook the socket's netconn callback for instant wake on receive events
esphome_lwip_hook_socket(fd);
#else
this->socket_fds_changed_ = true;
if (fd > this->max_fd_) {
this->max_fd_ = fd;
}
#endif
return true;
}
@@ -595,12 +608,14 @@ void Application::unregister_socket_fd(int fd) {
if (this->socket_fds_[i] != fd)
continue;
// Swap with last element and pop - O(1) removal since order doesn't matter
// Swap with last element and pop - O(1) removal since order doesn't matter.
// No need to unhook the netconn callback on ESP32 — all LwIP sockets share
// the same static event_callback, and the socket will be closed by the caller.
if (i < this->socket_fds_.size() - 1)
this->socket_fds_[i] = this->socket_fds_.back();
this->socket_fds_.pop_back();
#ifndef USE_ESP32
this->socket_fds_changed_ = true;
// Only recalculate max_fd if we removed the current max
if (fd == this->max_fd_) {
this->max_fd_ = -1;
@@ -609,6 +624,7 @@ void Application::unregister_socket_fd(int fd) {
this->max_fd_ = sock_fd;
}
}
#endif
return;
}
}
@@ -616,16 +632,41 @@ void Application::unregister_socket_fd(int fd) {
#endif
void Application::yield_with_select_(uint32_t delay_ms) {
// Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run
// since select() with 0 timeout only polls without yielding.
#ifdef USE_SOCKET_SELECT_SUPPORT
if (!this->socket_fds_.empty()) {
// Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run.
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32)
// ESP32 fast path: reads rcvevent directly via lwip_socket_dbg_get_socket() (~215 ns per socket).
// Safe because this runs on the main loop which owns socket lifetime (create, read, close).
if (delay_ms == 0) [[unlikely]] {
yield();
return;
}
// Check if any socket already has pending data before sleeping.
// If a socket still has unread data (rcvevent > 0) but the task notification was already
// consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency.
// This scan preserves select() semantics: return immediately when any fd is ready.
for (int fd : this->socket_fds_) {
if (esphome_lwip_socket_has_data(fd)) {
yield();
return;
}
}
// Sleep with instant wake via FreeRTOS task notification.
// Woken by: callback wrapper (socket data arrives), wake_loop_threadsafe() (other tasks), or timeout.
// Without USE_WAKE_LOOP_THREADSAFE, only hooked socket callbacks wake the task —
// background tasks won't call wake, so this degrades to a pure timeout (same as old select path).
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms));
#elif defined(USE_SOCKET_SELECT_SUPPORT)
// Non-ESP32 select() path (LibreTiny bk72xx/rtl87xx, host platform).
// ESP32 is excluded by the #if above — both BSD_SOCKETS and LWIP_SOCKETS on ESP32
// use LwIP under the hood, so the fast path handles all ESP32 socket implementations.
if (!this->socket_fds_.empty()) [[likely]] {
// Update fd_set if socket list has changed
if (this->socket_fds_changed_) {
if (this->socket_fds_changed_) [[unlikely]] {
FD_ZERO(&this->base_read_fds_);
// fd bounds are already validated in register_socket_fd() or guaranteed by platform design:
// - ESP32: LwIP guarantees fd < FD_SETSIZE by design (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS)
// - Other platforms: register_socket_fd() validates fd < FD_SETSIZE
// fd bounds are validated in register_socket_fd()
for (int fd : this->socket_fds_) {
FD_SET(fd, &this->base_read_fds_);
}
@@ -641,7 +682,7 @@ void Application::yield_with_select_(uint32_t delay_ms) {
tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000;
// Call select with timeout
#if defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || (defined(USE_ESP32) && defined(USE_SOCKET_IMPL_BSD_SOCKETS))
#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS
int ret = lwip_select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv);
#else
int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv);
@@ -651,19 +692,18 @@ void Application::yield_with_select_(uint32_t delay_ms) {
// ret < 0: error (except EINTR which is normal)
// ret > 0: socket(s) have data ready - normal and expected
// ret == 0: timeout occurred - normal and expected
if (ret < 0 && errno != EINTR) {
// Actual error - log and fall back to delay
ESP_LOGW(TAG, "select() failed with errno %d", errno);
delay(delay_ms);
if (ret >= 0 || errno == EINTR) [[likely]] {
// Yield if zero timeout since select(0) only polls without yielding
if (delay_ms == 0) [[unlikely]] {
yield();
}
return;
}
// When delay_ms is 0, we need to yield since select(0) doesn't yield
if (delay_ms == 0) {
yield();
}
} else {
// No sockets registered, use regular delay
delay(delay_ms);
// select() error - log and fall through to delay()
ESP_LOGW(TAG, "select() failed with errno %d", errno);
}
// No sockets registered or select() failed - use regular delay
delay(delay_ms);
#elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)
// No select support but can wake on socket activity via esp_schedule()
socket::socket_delay(delay_ms);
@@ -673,9 +713,25 @@ void Application::yield_with_select_(uint32_t delay_ms) {
#endif
}
Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// App storage — asm label shares the linker symbol with "extern Application App".
// char[] is trivially destructible, so no __cxa_atexit or destructor chain is emitted.
// Constructed via placement new in the generated setup().
#ifndef __GXX_ABI_VERSION
#error "Application placement new requires Itanium C++ ABI (GCC/Clang)"
#endif
static_assert(std::is_default_constructible<Application>::value, "Application must be default-constructible");
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
alignas(Application) char app_storage[sizeof(Application)] asm("_ZN7esphome3AppE");
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#ifdef USE_ESP32
void Application::wake_loop_threadsafe() {
// Direct FreeRTOS task notification — <1 us, task context only (NOT ISR-safe)
esphome_lwip_wake_main_loop();
}
#else // !USE_ESP32
void Application::setup_wake_loop_threadsafe_() {
// Create UDP socket for wake notifications
this->wake_socket_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
@@ -742,6 +798,8 @@ void Application::wake_loop_threadsafe() {
lwip_send(this->wake_socket_fd_, &dummy, 1, 0);
}
}
#endif // USE_ESP32
#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
void Application::get_build_time_string(std::span<char, BUILD_TIME_STR_SIZE> buffer) {

View File

@@ -24,10 +24,14 @@
#endif
#ifdef USE_SOCKET_SELECT_SUPPORT
#ifdef USE_ESP32
#include "esphome/core/lwip_fast_select.h"
#else
#include <sys/select.h>
#ifdef USE_WAKE_LOOP_THREADSAFE
#include <lwip/sockets.h>
#endif
#endif
#endif // USE_SOCKET_SELECT_SUPPORT
#ifdef USE_BINARY_SENSOR
@@ -491,15 +495,12 @@ class Application {
/// @return true if registration was successful, false if fd exceeds limits
bool register_socket_fd(int fd);
void unregister_socket_fd(int fd);
/// Check if there's data available on a socket without blocking
/// This function is thread-safe for reading, but should be called after select() has run
/// The read_fds_ is only modified by select() in the main loop
bool is_socket_ready(int fd) const { return fd >= 0 && this->is_socket_ready_(fd); }
#ifdef USE_WAKE_LOOP_THREADSAFE
/// Wake the main event loop from a FreeRTOS task
/// Thread-safe, can be called from task context to immediately wake select()
/// IMPORTANT: NOT safe to call from ISR context (socket operations not ISR-safe)
/// Wake the main event loop from another FreeRTOS task.
/// Thread-safe, but must only be called from task context (NOT ISR-safe).
/// On ESP32: uses xTaskNotifyGive (<1 us)
/// On other platforms: uses UDP loopback socket
void wake_loop_threadsafe();
#endif
#endif
@@ -510,10 +511,14 @@ class Application {
#ifdef USE_SOCKET_SELECT_SUPPORT
/// Fast path for Socket::ready() via friendship - skips negative fd check.
/// Safe because: fd was validated in register_socket_fd() at registration time,
/// and Socket::ready() only calls this when loop_monitored_ is true (registration succeeded).
/// FD_ISSET may include its own upper bounds check depending on platform.
/// Main loop only — on ESP32, reads rcvevent via lwip_socket_dbg_get_socket()
/// which has no refcount; safe only because the main loop owns socket lifetime
/// (creates, reads, and closes sockets on the same thread).
#ifdef USE_ESP32
bool is_socket_ready_(int fd) const { return esphome_lwip_socket_has_data(fd); }
#else
bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); }
#endif
#endif
void register_component_(Component *comp);
@@ -541,7 +546,7 @@ class Application {
/// Perform a delay while also monitoring socket file descriptors for readiness
void yield_with_select_(uint32_t delay_ms);
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
void setup_wake_loop_threadsafe_(); // Create wake notification socket
inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined)
#endif
@@ -571,7 +576,7 @@ class Application {
FixedVector<Component *> looping_components_{};
#ifdef USE_SOCKET_SELECT_SUPPORT
std::vector<int> socket_fds_; // Vector of all monitored socket file descriptors
#ifdef USE_WAKE_LOOP_THREADSAFE
#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks
#endif
#endif
@@ -584,7 +589,7 @@ class Application {
uint32_t last_loop_{0};
uint32_t loop_component_start_time_{0};
#ifdef USE_SOCKET_SELECT_SUPPORT
#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32)
int max_fd_{-1}; // Highest file descriptor number for select()
#endif
@@ -600,14 +605,14 @@ class Application {
bool in_loop_{false};
volatile bool has_pending_enable_loop_requests_{false};
#ifdef USE_SOCKET_SELECT_SUPPORT
#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32)
bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes
#endif
#ifdef USE_SOCKET_SELECT_SUPPORT
// Variable-sized members
#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32)
// Variable-sized members (not needed on ESP32 — is_socket_ready_ reads rcvevent directly)
fd_set read_fds_{}; // Working fd_set: populated by select()
fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes
fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_
#endif
// StaticVectors (largest members - contain actual array data inline)
@@ -694,7 +699,7 @@ class Application {
/// Global storage of Application pointer - only one Application can exist.
extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
// Inline implementations for hot-path functions
// drain_wake_notifications_() is called on every loop iteration
@@ -704,8 +709,8 @@ static constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16;
inline void Application::drain_wake_notifications_() {
// Called from main loop to drain any pending wake notifications
// Must check is_socket_ready() to avoid blocking on empty socket
if (this->wake_socket_fd_ >= 0 && this->is_socket_ready(this->wake_socket_fd_)) {
// Must check is_socket_ready_() to avoid blocking on empty socket
if (this->wake_socket_fd_ >= 0 && this->is_socket_ready_(this->wake_socket_fd_)) {
char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE];
// Drain all pending notifications with non-blocking reads
// Multiple wake events may have triggered multiple writes, so drain until EWOULDBLOCK
@@ -716,6 +721,6 @@ inline void Application::drain_wake_notifications_() {
}
}
}
#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
} // namespace esphome

View File

@@ -512,6 +512,9 @@ async def to_code(config: ConfigType) -> None:
cg.add_global(cg.RawExpression("using std::min"))
cg.add_global(cg.RawExpression("using std::max"))
# Construct App via placement new — see application.cpp for storage details
cg.add_global(cg.RawStatement("#include <new>"))
cg.add(cg.RawExpression("new (&App) Application()"))
cg.add(
cg.App.pre_setup(
config[CONF_NAME],

View File

@@ -0,0 +1,216 @@
// Fast socket monitoring for ESP32 (ESP-IDF LwIP)
// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications.
//
// This must be a .c file (not .cpp) because:
// 1. lwip/priv/sockets_priv.h conflicts with C++ compilation units that include bootloader headers
// 2. The netconn callback is a C function pointer
//
// defines.h is force-included by the build system (-include flag), providing USE_ESP32 etc.
//
// Thread safety analysis
// ======================
// Three threads interact with this code:
// 1. Main loop task — calls init, has_data, hook
// 2. LwIP TCP/IP task — calls event_callback (reads s_original_callback; writes rcvevent
// via the original callback under SYS_ARCH_PROTECT/UNPROTECT mutex)
// 3. Background tasks — call wake_main_loop
//
// LwIP source references (ESP-IDF v5.5.2, commit 30aaf64524):
// sockets.c: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/api/sockets.c
// - event_callback (static, same for all sockets): L327
// - DEFAULT_SOCKET_EVENTCB = event_callback: L328
// - tryget_socket_unconn_nouse (direct array lookup): L450
// - lwip_socket_dbg_get_socket (thin wrapper): L461
// - All socket types use DEFAULT_SOCKET_EVENTCB: L1741, L1748, L1759
// - event_callback definition: L2538
// - SYS_ARCH_PROTECT before rcvevent switch: L2578
// - sock->rcvevent++ (NETCONN_EVT_RCVPLUS case): L2582
// - SYS_ARCH_UNPROTECT after switch: L2615
// sys.h: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/include/lwip/sys.h
// - SYS_ARCH_PROTECT calls sys_arch_protect(): L495
// - SYS_ARCH_UNPROTECT calls sys_arch_unprotect(): L506
// (ESP-IDF implements sys_arch_protect/unprotect as FreeRTOS mutex lock/unlock)
//
// Socket slot lifetime
// ====================
// This code reads struct lwip_sock fields without SYS_ARCH_PROTECT. The safety
// argument requires that the slot cannot be freed while we read it.
//
// In LwIP, the socket table is a static array and slots are only freed via:
// lwip_close() -> lwip_close_internal() -> free_socket_free_elements() -> free_socket()
// The TCP/IP thread does NOT call free_socket(). On link loss, RST, or timeout
// it frees the TCP PCB and signals the netconn (rcvevent++ to indicate EOF), but
// the netconn and lwip_sock slot remain allocated until the application calls
// lwip_close(). ESPHome removes the fd from the monitored set before calling
// lwip_close().
//
// Therefore lwip_socket_dbg_get_socket(fd) plus a volatile read of rcvevent
// (to prevent compiler reordering or caching) is safe as long as the application
// is single-writer for close. ESPHome guarantees this by design: all socket
// create/read/close happens on the main loop. fd numbers are not reused while
// the slot remains allocated, and the slot remains allocated until lwip_close().
// Any change in LwIP that allows free_socket() to be called outside lwip_close()
// would invalidate this assumption.
//
// LwIP source references for slot lifetime:
// sockets.c (same commit as above):
// - alloc_socket (slot allocation): L419
// - free_socket (slot deallocation): L384
// - free_socket_free_elements (called from lwip_close_internal): L393
// - lwip_close_internal (only caller of free_socket_free_elements): L2355
// - lwip_close (only caller of lwip_close_internal): L2450
//
// Shared state and safety rationale:
//
// s_main_loop_task (TaskHandle_t, 4 bytes):
// Written once by main loop in init(). Read by TCP/IP thread (in callback)
// and background tasks (in wake).
// Safe: write-once-then-read pattern. Socket hooks may run before init(),
// but the NULL check on s_main_loop_task in the callback provides correct
// degraded behavior — notifications are simply skipped until init() completes.
//
// s_original_callback (netconn_callback, 4-byte function pointer):
// Written by main loop in hook_socket() (only when NULL — set once).
// Read by TCP/IP thread in esphome_socket_event_callback().
// Safe: set-once pattern. The first hook_socket() captures the original callback.
// All subsequent hooks see it already set and skip the write. The TCP/IP thread
// only reads this after the callback pointer has been swapped (which happens after
// the write), so it always sees the initialized value.
//
// sock->conn->callback (netconn_callback, 4-byte function pointer):
// Written by main loop in hook_socket(). Never restored — all LwIP sockets share
// the same static event_callback (DEFAULT_SOCKET_EVENTCB), so the wrapper stays permanently.
// Read by TCP/IP thread when invoking the callback.
// Safe: 32-bit aligned pointer writes are atomic on Xtensa and RISC-V (ESP32).
// The TCP/IP thread will see either the old or new pointer atomically — never a
// torn value. Both the wrapper and original callbacks are valid at all times
// (the wrapper itself calls the original), so either value is correct.
//
// sock->rcvevent (s16_t, 2 bytes):
// Written by TCP/IP thread in event_callback under SYS_ARCH_PROTECT.
// Read by main loop in has_data() via volatile cast.
// Safe: SYS_ARCH_UNPROTECT releases a FreeRTOS mutex, which internally
// uses a critical section with memory barrier (rsync on dual-core Xtensa; on
// single-core builds the spinlock is compiled out, but cross-core visibility is
// not an issue). The volatile cast prevents the compiler
// from caching the read. Aligned 16-bit reads are single-instruction loads on
// Xtensa (L16SI) and RISC-V (LH), which cannot produce torn values.
//
// FreeRTOS task notification value:
// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks
// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake).
// Safe: FreeRTOS notification APIs are thread-safe by design (use internal
// critical sections). Multiple concurrent xTaskNotifyGive calls are safe —
// the notification count simply increments.
#ifdef USE_ESP32
// LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc.
#include <lwip/api.h>
#include <lwip/priv/sockets_priv.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include "esphome/core/lwip_fast_select.h"
#include <stddef.h>
// Compile-time verification of thread safety assumptions.
// On ESP32 (Xtensa/RISC-V), naturally-aligned reads/writes up to 32 bits are atomic.
// These asserts ensure our cross-thread shared state meets those requirements.
// Pointer types must fit in a single 32-bit store (atomic write)
_Static_assert(sizeof(TaskHandle_t) <= 4, "TaskHandle_t must be <= 4 bytes for atomic access");
_Static_assert(sizeof(netconn_callback) <= 4, "netconn_callback must be <= 4 bytes for atomic access");
// rcvevent must fit in a single atomic read
_Static_assert(sizeof(((struct lwip_sock *) 0)->rcvevent) <= 4, "rcvevent must be <= 4 bytes for atomic access");
// Struct member alignment — natural alignment guarantees atomicity on Xtensa/RISC-V.
// Misaligned access would not be atomic even if the size is <= 4 bytes.
_Static_assert(offsetof(struct netconn, callback) % sizeof(netconn_callback) == 0,
"netconn.callback must be naturally aligned for atomic access");
_Static_assert(offsetof(struct lwip_sock, rcvevent) % sizeof(((struct lwip_sock *) 0)->rcvevent) == 0,
"lwip_sock.rcvevent must be naturally aligned for atomic access");
// Task handle for the main loop — written once in init(), read from TCP/IP and background tasks.
static TaskHandle_t s_main_loop_task = NULL;
// Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task.
static netconn_callback s_original_callback = NULL;
// Wrapper callback: calls original event_callback + notifies main loop task.
// Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR).
static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) {
// Call original LwIP event_callback first — updates rcvevent/sendevent/errevent,
// signals any select() waiters. This preserves all LwIP behavior.
// s_original_callback is always valid here: hook_socket() sets it before swapping
// the callback pointer, so this wrapper cannot run until it's initialized.
s_original_callback(conn, evt, len);
// Wake the main loop task if sleeping in ulTaskNotifyTake().
// Only notify on receive events to avoid spurious wakeups from send-ready events.
// NETCONN_EVT_ERROR is deliberately omitted: LwIP signals errors via RCVPLUS
// (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions
// already wake the main loop through the RCVPLUS path.
if (evt == NETCONN_EVT_RCVPLUS) {
TaskHandle_t task = s_main_loop_task;
if (task != NULL) {
xTaskNotifyGive(task);
}
}
}
void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); }
// lwip_socket_dbg_get_socket() is a thin wrapper around the static
// tryget_socket_unconn_nouse() — a direct array lookup without the refcount
// that get_socket()/done_socket() uses. This is safe because:
// 1. The only path to free_socket() is lwip_close(), called exclusively from the main loop
// 2. The TCP/IP thread never frees socket slots (see "Socket slot lifetime" above)
// 3. Both has_data() reads and lwip_close() run on the main loop — no concurrent free
// If lwip_socket_dbg_get_socket() were ever removed, we could fall back to lwip_select().
// Returns the sock only if both the sock and its netconn are valid, NULL otherwise.
static inline struct lwip_sock *get_sock(int fd) {
struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd);
if (sock == NULL || sock->conn == NULL)
return NULL;
return sock;
}
bool esphome_lwip_socket_has_data(int fd) {
struct lwip_sock *sock = get_sock(fd);
if (sock == NULL)
return false;
// volatile prevents the compiler from caching/reordering this cross-thread read.
// The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a
// FreeRTOS mutex with a memory barrier (rsync on Xtensa), ensuring the value is
// visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH) on
// Xtensa/RISC-V and cannot produce torn values.
return *(volatile s16_t *) &sock->rcvevent > 0;
}
void esphome_lwip_hook_socket(int fd) {
struct lwip_sock *sock = get_sock(fd);
if (sock == NULL)
return;
// Save original callback once — all LwIP sockets share the same static event_callback
// (DEFAULT_SOCKET_EVENTCB in sockets.c, used for SOCK_RAW, SOCK_DGRAM, and SOCK_STREAM).
if (s_original_callback == NULL) {
s_original_callback = sock->conn->callback;
}
// Replace with our wrapper. Atomic on ESP32 (32-bit aligned pointer write).
// TCP/IP thread sees either old or new pointer — both are valid.
sock->conn->callback = esphome_socket_event_callback;
}
// Wake the main loop from another FreeRTOS task. NOT ISR-safe.
void esphome_lwip_wake_main_loop(void) {
TaskHandle_t task = s_main_loop_task;
if (task != NULL) {
xTaskNotifyGive(task);
}
}
#endif // USE_ESP32

View File

@@ -0,0 +1,33 @@
#pragma once
// Fast socket monitoring for ESP32 (ESP-IDF LwIP)
// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications.
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
/// Initialize fast select — must be called from the main loop task during setup().
/// Saves the current task handle for xTaskNotifyGive() wake notifications.
void esphome_lwip_fast_select_init(void);
/// Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket).
/// Uses lwip_socket_dbg_get_socket() — a direct array lookup without the refcount that
/// get_socket()/done_socket() uses. Safe because the caller owns the socket lifetime:
/// both has_data reads and socket close/unregister happen on the main loop thread.
bool esphome_lwip_socket_has_data(int fd);
/// Hook a socket's netconn callback to notify the main loop task on receive events.
/// Wraps the original event_callback with one that also calls xTaskNotifyGive().
/// Must be called from the main loop after socket creation.
void esphome_lwip_hook_socket(int fd);
/// Wake the main loop task from another FreeRTOS task — costs <1 us.
/// NOT ISR-safe — must only be called from task context.
void esphome_lwip_wake_main_loop(void);
#ifdef __cplusplus
}
#endif

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

@@ -193,10 +193,10 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script
extends = common:arduino
board_build.filesystem_size = 0.5m
platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.2.0-gcc12
platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460
platform_packages =
; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted
earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/3.9.4/rp2040-3.9.4.zip
earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.5.0/rp2040-5.5.0.zip
framework = arduino
lib_deps =

View File

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

View File

@@ -1,6 +1,6 @@
pylint==4.0.5
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.15.2 # also change in .pre-commit-config.yaml when updating
ruff==0.15.3 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit

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

@@ -3,10 +3,13 @@ esp_ldo:
channel: 3
voltage: 2.5V
adjustable: true
- id: ldo_4
- id: ldo_4_passthrough
channel: 4
voltage: 2.0V
setup_priority: 900
voltage: passthrough
- id: ldo_1_internal
channel: 1
voltage: 1.8V
allow_internal_channel: true
esphome:
on_boot:

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -1,9 +1,21 @@
from esphome.components import socket
from esphome.const import (
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
PLATFORM_ESP8266,
)
from esphome.core import CORE
def _setup_platform(platform=PLATFORM_ESP8266) -> None:
"""Set up CORE.data with a platform for testing."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
def test_require_wake_loop_threadsafe__first_call() -> None:
"""Test that first call sets up define and consumes socket."""
_setup_platform()
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
@@ -32,6 +44,7 @@ def test_require_wake_loop_threadsafe__idempotent() -> None:
def test_require_wake_loop_threadsafe__multiple_calls() -> None:
"""Test that multiple calls only set up once."""
_setup_platform()
# Call three times
CORE.config = {"openthread": True}
socket.require_wake_loop_threadsafe()
@@ -75,3 +88,29 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() -
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert "socket.wake_loop_threadsafe" not in udp_consumers
assert udp_consumers == initial_udp
def test_require_wake_loop_threadsafe__esp32_no_udp_socket() -> None:
"""Test that ESP32 uses task notifications instead of UDP socket."""
_setup_platform(PLATFORM_ESP32)
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
# Verify the define was added
assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True
assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines)
# Verify no UDP socket was consumed (ESP32 uses FreeRTOS task notifications)
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert "socket.wake_loop_threadsafe" not in udp_consumers
def test_require_wake_loop_threadsafe__non_esp32_consumes_udp_socket() -> None:
"""Test that non-ESP32 platforms consume a UDP socket for wake notifications."""
_setup_platform(PLATFORM_ESP8266)
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
# Verify UDP socket was consumed
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert udp_consumers.get("socket.wake_loop_threadsafe") == 1

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,11 @@ sensor:
id: source_sensor_4
accuracy_decimals: 1
- platform: template
name: "Source Sensor 5"
id: source_sensor_5
accuracy_decimals: 1
- platform: copy
source_id: source_sensor_1
name: "Filter Min"
@@ -69,6 +74,13 @@ sensor:
filters:
- delta: 0
- platform: copy
source_id: source_sensor_5
name: "Filter Percentage"
id: filter_percentage
filters:
- delta: 50%
script:
- id: test_filter_min
then:
@@ -154,6 +166,28 @@ script:
id: source_sensor_4
state: 2.0
- id: test_filter_percentage
then:
- sensor.template.publish:
id: source_sensor_5
state: 100.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 120.0 # Filtered out (delta=20, need >50)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 160.0 # Passes (delta=60 > 50% of 100=50)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 200.0 # Filtered out (delta=40, need >50% of 160=80)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 250.0 # Passes (delta=90 > 80)
button:
- platform: template
name: "Test Filter Min"
@@ -178,3 +212,9 @@ button:
id: btn_filter_zero_delta
on_press:
- script.execute: test_filter_zero_delta
- platform: template
name: "Test Filter Percentage"
id: btn_filter_percentage
on_press:
- script.execute: test_filter_percentage

View File

@@ -24,12 +24,14 @@ async def test_sensor_filters_delta(
"filter_max": [],
"filter_baseline_max": [],
"filter_zero_delta": [],
"filter_percentage": [],
}
filter_min_done = loop.create_future()
filter_max_done = loop.create_future()
filter_baseline_max_done = loop.create_future()
filter_zero_delta_done = loop.create_future()
filter_percentage_done = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, SensorState) or state.missing_state:
@@ -66,6 +68,12 @@ async def test_sensor_filters_delta(
and not filter_zero_delta_done.done()
):
filter_zero_delta_done.set_result(True)
elif (
sensor_name == "filter_percentage"
and len(sensor_values[sensor_name]) == 3
and not filter_percentage_done.done()
):
filter_percentage_done.set_result(True)
async with (
run_compiled(yaml_config),
@@ -80,6 +88,7 @@ async def test_sensor_filters_delta(
"filter_max": "Filter Max",
"filter_baseline_max": "Filter Baseline Max",
"filter_zero_delta": "Filter Zero Delta",
"filter_percentage": "Filter Percentage",
},
)
@@ -98,13 +107,14 @@ async def test_sensor_filters_delta(
"Test Filter Max": "filter_max",
"Test Filter Baseline Max": "filter_baseline_max",
"Test Filter Zero Delta": "filter_zero_delta",
"Test Filter Percentage": "filter_percentage",
}
buttons = {}
for entity in entities:
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
buttons[button_name_map[entity.name]] = entity.key
assert len(buttons) == 4, f"Expected 3 buttons, found {len(buttons)}"
assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}"
# Test 1: Min
sensor_values["filter_min"].clear()
@@ -161,3 +171,18 @@ async def test_sensor_filters_delta(
assert sensor_values["filter_zero_delta"] == pytest.approx(expected), (
f"Test 4 failed: expected {expected}, got {sensor_values['filter_zero_delta']}"
)
# Test 5: Percentage (delta: 50%)
sensor_values["filter_percentage"].clear()
client.button_command(buttons["filter_percentage"])
try:
await asyncio.wait_for(filter_percentage_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 5 timed out. Values: {sensor_values['filter_percentage']}"
)
expected = [100.0, 160.0, 250.0]
assert sensor_values["filter_percentage"] == pytest.approx(expected), (
f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}"
)