Compare commits

...

26 Commits

Author SHA1 Message Date
J. Nick Koston
b00dc93f70 [wifi_signal] Skip publishing disconnected RSSI value 2025-12-14 09:54:26 -06:00
J. Nick Koston
7e1bd289b3 Merge branch 'fix-polling-component-schema' into integration 2025-12-14 09:38:15 -06:00
J. Nick Koston
cf373edd81 Merge branch 'lwip_raw_tcp_impl_getpeername' into integration 2025-12-14 09:38:07 -06:00
J. Nick Koston
0d7fbb79b3 Merge branch 'events_trusted_domains' into integration 2025-12-14 09:38:01 -06:00
J. Nick Koston
c87771184e Merge branch 'fix-epaper-spi-update-interval-never' into integration 2025-12-14 09:37:56 -06:00
J. Nick Koston
1185abadc1 Merge branch 'always_lru_enable_idf_web_server' into integration 2025-12-14 09:37:49 -06:00
J. Nick Koston
6f6c65509d [web_server_idf] Always enable LRU purge to prevent socket exhaustion 2025-12-14 09:37:11 -06:00
J. Nick Koston
f50ffb2b92 cover 2025-12-14 09:09:24 -06:00
J. Nick Koston
4892bfb6e4 [dashboard] Add ESPHOME_TRUSTED_DOMAINS support to events WebSocket 2025-12-14 09:00:27 -06:00
Jonathan Swoboda
586e82bfa5 [core] Fix polling_component_schema and use SCHEDULER_DONT_RUN constant
- Fix polling_component_schema to use update_interval validator when
  default_update_interval is None (was using None as validator)
- Replace hardcoded 4294967295 with SCHEDULER_DONT_RUN constant in
  update_interval function

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-14 09:41:56 -05:00
J. Nick Koston
512a7df007 [socket] Fix getpeername() returning local address instead of remote in LWIP raw TCP 2025-12-13 22:49:05 -06:00
J. Nick Koston
0e60aefdec Merge remote-tracking branch 'upstream/dev' into integration 2025-12-13 22:15:41 -06:00
J. Nick Koston
e0ce66e011 [core] Fix CORE.raw_config not updated after package merge (#12456) 2025-12-13 07:38:31 -06:00
J. Nick Koston
75b8279361 Merge remote-tracking branch 'upstream/dev' into integration 2025-12-12 21:03:28 -06:00
David Woodhouse
6fce0a6104 Add host platform support to MD5 component (#12458) 2025-12-13 02:50:34 +00:00
J. Nick Koston
b10a87c1b0 Merge branch 'factory_reset_waste' into integration 2025-12-12 20:14:42 -06:00
J. Nick Koston
f2505ce453 tidy 2025-12-12 20:14:31 -06:00
J. Nick Koston
ec7143d835 Merge branch 'factory_reset_waste' into integration 2025-12-12 20:09:19 -06:00
J. Nick Koston
d77f9c96b9 [factory_reset] Optimize memory by storing interval as uint16_t seconds 2025-12-12 20:08:00 -06:00
J. Nick Koston
4329794924 Merge branch 'ota_align_password' into integration 2025-12-12 19:39:06 -06:00
J. Nick Koston
99b0b974ad [esphome] Improve OTA field alignment to save 4 bytes on 32-bit 2025-12-12 19:38:44 -06:00
J. Nick Koston
9dafafa07c Merge branch 'memory_api' into integration 2025-12-12 19:25:14 -06:00
J. Nick Koston
200cd8cace Merge branch 'str_helpers' into integration 2025-12-12 19:25:09 -06:00
Jonathan Swoboda
b8c00e6452 Merge branch 'dev' into fix-epaper-spi-update-interval-never 2025-12-12 17:25:02 -05:00
Jonathan Swoboda
1d081fd510 [epaper_spi] Fix update_interval: never validation error
- Add full_display_schema() function to display component to allow
  configurable default update_interval
- Fix epaper_spi to use 60s default update_interval instead of 1s
- Fix minimum update_interval validation to allow "never" value
- Keep FULL_DISPLAY_SCHEMA constant for backward compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 16:59:50 -05:00
J. Nick Koston
80fda97c60 [core] Refactor str_snake_case and str_sanitize to use constexpr helpers 2025-12-12 13:13:07 -06:00
22 changed files with 307 additions and 124 deletions

View File

@@ -65,12 +65,6 @@ void CaptivePortal::start() {
this->base_->init();
if (!this->initialized_) {
this->base_->add_handler(this);
#ifdef USE_ESP32
// Enable LRU socket purging to handle captive portal detection probe bursts
// OS captive portal detection makes many simultaneous HTTP requests which can
// exhaust sockets. LRU purging automatically closes oldest idle connections.
this->base_->get_server()->set_lru_purge_enable(true);
#endif
}
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();

View File

@@ -40,10 +40,6 @@ class CaptivePortal : public AsyncWebHandler, public Component {
void end() {
this->active_ = false;
this->disable_loop(); // Stop processing DNS requests
#ifdef USE_ESP32
// Disable LRU socket purging now that captive portal is done
this->base_->get_server()->set_lru_purge_enable(false);
#endif
this->base_->deinit();
if (this->dns_server_ != nullptr) {
this->dns_server_->stop();

View File

@@ -63,11 +63,13 @@ def validate_auto_clear(value):
return cv.boolean(value)
BASIC_DISPLAY_SCHEMA = cv.Schema(
{
cv.Exclusive(CONF_LAMBDA, CONF_LAMBDA): cv.lambda_,
}
).extend(cv.polling_component_schema("1s"))
def basic_display_schema(default_update_interval: str = "1s") -> cv.Schema:
"""Create a basic display schema with configurable default update interval."""
return cv.Schema(
{
cv.Exclusive(CONF_LAMBDA, CONF_LAMBDA): cv.lambda_,
}
).extend(cv.polling_component_schema(default_update_interval))
def _validate_test_card(config):
@@ -81,34 +83,41 @@ def _validate_test_card(config):
return config
FULL_DISPLAY_SCHEMA = BASIC_DISPLAY_SCHEMA.extend(
{
cv.Optional(CONF_ROTATION): validate_rotation,
cv.Exclusive(CONF_PAGES, CONF_LAMBDA): cv.All(
cv.ensure_list(
def full_display_schema(default_update_interval: str = "1s") -> cv.Schema:
"""Create a full display schema with configurable default update interval."""
schema = basic_display_schema(default_update_interval).extend(
{
cv.Optional(CONF_ROTATION): validate_rotation,
cv.Exclusive(CONF_PAGES, CONF_LAMBDA): cv.All(
cv.ensure_list(
{
cv.GenerateID(): cv.declare_id(DisplayPage),
cv.Required(CONF_LAMBDA): cv.lambda_,
}
),
cv.Length(min=1),
),
cv.Optional(CONF_ON_PAGE_CHANGE): automation.validate_automation(
{
cv.GenerateID(): cv.declare_id(DisplayPage),
cv.Required(CONF_LAMBDA): cv.lambda_,
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayOnPageChangeTrigger
),
cv.Optional(CONF_FROM): cv.use_id(DisplayPage),
cv.Optional(CONF_TO): cv.use_id(DisplayPage),
}
),
cv.Length(min=1),
),
cv.Optional(CONF_ON_PAGE_CHANGE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayOnPageChangeTrigger
),
cv.Optional(CONF_FROM): cv.use_id(DisplayPage),
cv.Optional(CONF_TO): cv.use_id(DisplayPage),
}
),
cv.Optional(
CONF_AUTO_CLEAR_ENABLED, default=CONF_UNSPECIFIED
): validate_auto_clear,
cv.Optional(CONF_SHOW_TEST_CARD): cv.boolean,
}
)
FULL_DISPLAY_SCHEMA.add_extra(_validate_test_card)
cv.Optional(
CONF_AUTO_CLEAR_ENABLED, default=CONF_UNSPECIFIED
): validate_auto_clear,
cv.Optional(CONF_SHOW_TEST_CARD): cv.boolean,
}
)
schema.add_extra(_validate_test_card)
return schema
BASIC_DISPLAY_SCHEMA = basic_display_schema("1s")
FULL_DISPLAY_SCHEMA = full_display_schema("1s")
async def setup_display_core_(var, config):

View File

@@ -31,6 +31,7 @@ from esphome.const import (
CONF_TRANSFORM,
CONF_UPDATE_INTERVAL,
CONF_WIDTH,
SCHEDULER_DONT_RUN,
)
from esphome.cpp_generator import RawExpression
from esphome.final_validate import full_config
@@ -72,12 +73,10 @@ TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY}
def model_schema(config):
model = MODELS[config[CONF_MODEL]]
class_name = epaper_spi_ns.class_(model.class_name, EPaperBase)
minimum_update_interval = update_interval(
model.get_default(CONF_MINIMUM_UPDATE_INTERVAL, "1s")
)
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
return (
display.FULL_DISPLAY_SCHEMA.extend(
display.full_display_schema("60s")
.extend(
spi.spi_device_schema(
cs_pin_required=False,
default_mode="MODE0",
@@ -94,9 +93,6 @@ def model_schema(config):
{
cv.Optional(CONF_ROTATION, default=0): validate_rotation,
cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True),
cv.Optional(CONF_UPDATE_INTERVAL, default=cv.UNDEFINED): cv.All(
update_interval, cv.Range(min=minimum_update_interval)
),
cv.Optional(CONF_TRANSFORM): cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
@@ -150,15 +146,22 @@ def _final_validate(config):
global_config = full_config.get()
from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN
if CONF_LAMBDA not in config and CONF_PAGES not in config:
if LVGL_DOMAIN in global_config:
if CONF_UPDATE_INTERVAL not in config:
config[CONF_UPDATE_INTERVAL] = update_interval("never")
else:
# If no drawing methods are configured, and LVGL is not enabled, show a test card
config[CONF_SHOW_TEST_CARD] = True
elif CONF_UPDATE_INTERVAL not in config:
config[CONF_UPDATE_INTERVAL] = update_interval("1min")
# If no drawing methods are configured, and LVGL is not enabled, show a test card
if (
CONF_LAMBDA not in config
and CONF_PAGES not in config
and LVGL_DOMAIN not in global_config
):
config[CONF_SHOW_TEST_CARD] = True
interval = config[CONF_UPDATE_INTERVAL]
if interval != SCHEDULER_DONT_RUN:
model = MODELS[config[CONF_MODEL]]
minimum = update_interval(model.get_default(CONF_MINIMUM_UPDATE_INTERVAL, "1s"))
if interval < minimum:
raise cv.Invalid(
f"update_interval must be at least {minimum} for {model.name}, got {interval}"
)
return config

View File

@@ -80,6 +80,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent {
#ifdef USE_OTA_PASSWORD
std::string password_;
std::unique_ptr<uint8_t[]> auth_buf_;
#endif // USE_OTA_PASSWORD
std::unique_ptr<socket::Socket> server_;
@@ -93,7 +94,6 @@ class ESPHomeOTAComponent : public ota::OTAComponent {
uint8_t handshake_buf_pos_{0};
uint8_t ota_features_{0};
#ifdef USE_OTA_PASSWORD
std::unique_ptr<uint8_t[]> auth_buf_;
uint8_t auth_buf_pos_{0};
uint8_t auth_type_{0}; // Store auth type to know which hasher to use
#endif // USE_OTA_PASSWORD

View File

@@ -50,7 +50,9 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(): cv.declare_id(FactoryResetComponent),
cv.Optional(CONF_MAX_DELAY, default="10s"): cv.All(
cv.positive_time_period_seconds,
cv.Range(min=cv.TimePeriod(milliseconds=1000)),
cv.Range(
min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(seconds=65535)
),
),
cv.Optional(CONF_RESETS_REQUIRED): cv.positive_not_null_int,
cv.Optional(CONF_ON_INCREMENT): validate_automation(
@@ -82,7 +84,7 @@ async def to_code(config):
var = cg.new_Pvariable(
config[CONF_ID],
reset_count,
config[CONF_MAX_DELAY].total_milliseconds,
config[CONF_MAX_DELAY].total_seconds,
)
await cg.register_component(var, config)
for conf in config.get(CONF_ON_INCREMENT, []):

View File

@@ -8,8 +8,7 @@
#if !defined(USE_RP2040) && !defined(USE_HOST)
namespace esphome {
namespace factory_reset {
namespace esphome::factory_reset {
static const char *const TAG = "factory_reset";
static const uint32_t POWER_CYCLES_KEY = 0xFA5C0DE;
@@ -33,10 +32,10 @@ void FactoryResetComponent::dump_config() {
this->flash_.load(&count);
ESP_LOGCONFIG(TAG, "Factory Reset by Reset:");
ESP_LOGCONFIG(TAG,
" Max interval between resets %" PRIu32 " seconds\n"
" Max interval between resets: %u seconds\n"
" Current count: %u\n"
" Factory reset after %u resets",
this->max_interval_ / 1000, count, this->required_count_);
this->max_interval_, count, this->required_count_);
}
void FactoryResetComponent::save_(uint8_t count) {
@@ -61,8 +60,8 @@ void FactoryResetComponent::setup() {
}
this->save_(count);
ESP_LOGD(TAG, "Power on reset detected, incremented count to %u", count);
this->set_timeout(this->max_interval_, [this]() {
ESP_LOGD(TAG, "No reset in the last %" PRIu32 " seconds, resetting count", this->max_interval_ / 1000);
this->set_timeout(static_cast<uint32_t>(this->max_interval_) * 1000, [this]() {
ESP_LOGD(TAG, "No reset in the last %u seconds, resetting count", this->max_interval_);
this->save_(0); // reset count
});
} else {
@@ -70,7 +69,6 @@ void FactoryResetComponent::setup() {
}
}
} // namespace factory_reset
} // namespace esphome
} // namespace esphome::factory_reset
#endif // !defined(USE_RP2040) && !defined(USE_HOST)

View File

@@ -9,12 +9,11 @@
#include <esp_system.h>
#endif
namespace esphome {
namespace factory_reset {
namespace esphome::factory_reset {
class FactoryResetComponent : public Component {
public:
FactoryResetComponent(uint8_t required_count, uint32_t max_interval)
: required_count_(required_count), max_interval_(max_interval) {}
FactoryResetComponent(uint8_t required_count, uint16_t max_interval)
: max_interval_(max_interval), required_count_(required_count) {}
void dump_config() override;
void setup() override;
@@ -26,9 +25,9 @@ class FactoryResetComponent : public Component {
~FactoryResetComponent() = default;
void save_(uint8_t count);
ESPPreferenceObject flash_{}; // saves the number of fast power cycles
uint8_t required_count_; // The number of boot attempts before fast boot is enabled
uint32_t max_interval_; // max interval between power cycles
CallbackManager<void(uint8_t, uint8_t)> increment_callback_{};
uint16_t max_interval_; // max interval between power cycles in seconds
uint8_t required_count_; // The number of boot attempts before fast boot is enabled
};
class FastBootTrigger : public Trigger<uint8_t, uint8_t> {
@@ -37,7 +36,6 @@ class FastBootTrigger : public Trigger<uint8_t, uint8_t> {
parent->add_increment_callback([this](uint8_t current, uint8_t target) { this->trigger(current, target); });
}
};
} // namespace factory_reset
} // namespace esphome
} // namespace esphome::factory_reset
#endif // !defined(USE_RP2040) && !defined(USE_HOST)

View File

@@ -1,7 +1,17 @@
import esphome.codegen as cg
from esphome.core import CORE
from esphome.helpers import IS_MACOS
CODEOWNERS = ["@esphome/core"]
async def to_code(config):
cg.add_define("USE_MD5")
# Add OpenSSL library for host platform
if CORE.is_host:
if IS_MACOS:
# macOS needs special handling for Homebrew OpenSSL
cg.add_build_flag("-I/opt/homebrew/opt/openssl/include")
cg.add_build_flag("-L/opt/homebrew/opt/openssl/lib")
cg.add_build_flag("-lcrypto")

View File

@@ -39,6 +39,44 @@ void MD5Digest::add(const uint8_t *data, size_t len) { br_md5_update(&this->ctx_
void MD5Digest::calculate() { br_md5_out(&this->ctx_, this->digest_); }
#endif // USE_RP2040
#ifdef USE_HOST
MD5Digest::~MD5Digest() {
if (this->ctx_) {
EVP_MD_CTX_free(this->ctx_);
}
}
void MD5Digest::init() {
if (this->ctx_) {
EVP_MD_CTX_free(this->ctx_);
}
this->ctx_ = EVP_MD_CTX_new();
EVP_DigestInit_ex(this->ctx_, EVP_md5(), nullptr);
this->calculated_ = false;
memset(this->digest_, 0, 16);
}
void MD5Digest::add(const uint8_t *data, size_t len) {
if (!this->ctx_) {
this->init();
}
EVP_DigestUpdate(this->ctx_, data, len);
}
void MD5Digest::calculate() {
if (!this->ctx_) {
this->init();
}
if (!this->calculated_) {
unsigned int len = 16;
EVP_DigestFinal_ex(this->ctx_, this->digest_, &len);
this->calculated_ = true;
}
}
#else
MD5Digest::~MD5Digest() = default;
#endif // USE_HOST
} // namespace md5
} // namespace esphome
#endif

View File

@@ -5,6 +5,10 @@
#include "esphome/core/hash_base.h"
#ifdef USE_HOST
#include <openssl/evp.h>
#endif
#ifdef USE_ESP32
#include "esp_rom_md5.h"
#define MD5_CTX_TYPE md5_context_t
@@ -31,7 +35,7 @@ namespace md5 {
class MD5Digest : public HashBase {
public:
MD5Digest() = default;
~MD5Digest() override = default;
~MD5Digest() override;
/// Initialize a new MD5 digest computation.
void init() override;
@@ -47,7 +51,12 @@ class MD5Digest : public HashBase {
size_t get_size() const override { return 16; }
protected:
#ifdef USE_HOST
EVP_MD_CTX *ctx_{nullptr};
bool calculated_{false};
#else
MD5_CTX_TYPE ctx_{};
#endif
};
} // namespace md5

View File

@@ -188,7 +188,7 @@ class LWIPRawImpl : public Socket {
errno = EINVAL;
return -1;
}
return this->ip2sockaddr_(&pcb_->local_ip, pcb_->local_port, name, addrlen);
return this->ip2sockaddr_(&pcb_->remote_ip, pcb_->remote_port, name, addrlen);
}
std::string getpeername() override {
if (pcb_ == nullptr) {

View File

@@ -117,18 +117,6 @@ void AsyncWebServer::end() {
}
}
void AsyncWebServer::set_lru_purge_enable(bool enable) {
if (this->lru_purge_enable_ == enable) {
return; // No change needed
}
this->lru_purge_enable_ = enable;
// If server is already running, restart it with new config
if (this->server_) {
this->end();
this->begin();
}
}
void AsyncWebServer::begin() {
if (this->server_) {
this->end();
@@ -136,8 +124,11 @@ void AsyncWebServer::begin() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = this->port_;
config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; };
// Enable LRU purging if requested (e.g., by captive portal to handle probe bursts)
config.lru_purge_enable = this->lru_purge_enable_;
// Always enable LRU purging to handle socket exhaustion gracefully.
// When max sockets is reached, the oldest connection is closed to make room for new ones.
// This prevents "httpd_accept_conn: error in accept (23)" errors.
// See: https://github.com/esphome/esphome/issues/12464
config.lru_purge_enable = true;
// Use custom close function that shuts down before closing to prevent lwIP race conditions
config.close_fn = AsyncWebServer::safe_close_with_shutdown;
if (httpd_start(&this->server_, &config) == ESP_OK) {

View File

@@ -199,13 +199,11 @@ class AsyncWebServer {
return *handler;
}
void set_lru_purge_enable(bool enable);
httpd_handle_t get_server() { return this->server_; }
protected:
uint16_t port_{};
httpd_handle_t server_{};
bool lru_purge_enable_{false};
static esp_err_t request_handler(httpd_req_t *r);
static esp_err_t request_post_handler(httpd_req_t *r);
esp_err_t request_handler_(AsyncWebServerRequest *request) const;

View File

@@ -16,7 +16,12 @@ class WiFiSignalSensor : public sensor::Sensor, public PollingComponent {
#ifdef USE_WIFI_LISTENERS
void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); }
#endif
void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); }
void update() override {
int8_t rssi = wifi::global_wifi_component->wifi_rssi();
if (rssi != wifi::WIFI_RSSI_DISCONNECTED) {
this->publish_state(rssi);
}
}
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }

View File

@@ -1010,14 +1010,14 @@ def validate_config(
result.add_error(err)
return result
CORE.raw_config = config
# 1.1. Merge packages
if CONF_PACKAGES in config:
from esphome.components.packages import merge_packages
config = merge_packages(config)
CORE.raw_config = config
# 1.2. Resolve !extend and !remove and check for REPLACEME
# After this step, there will not be any Extend or Remove values in the config anymore
try:

View File

@@ -71,6 +71,7 @@ from esphome.const import (
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
SCHEDULER_DONT_RUN,
TYPE_GIT,
TYPE_LOCAL,
VALID_SUBSTITUTIONS_CHARACTERS,
@@ -894,7 +895,7 @@ def time_period_in_minutes_(value):
def update_interval(value):
if value == "never":
return 4294967295 # uint32_t max
return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN)
return positive_time_period_milliseconds(value)
@@ -2009,7 +2010,7 @@ def polling_component_schema(default_update_interval):
if default_update_interval is None:
return COMPONENT_SCHEMA.extend(
{
Required(CONF_UPDATE_INTERVAL): default_update_interval,
Required(CONF_UPDATE_INTERVAL): update_interval,
}
)
assert isinstance(default_update_interval, str)

View File

@@ -188,22 +188,27 @@ template<int (*fn)(int)> std::string str_ctype_transform(const std::string &str)
}
std::string str_lower_case(const std::string &str) { return str_ctype_transform<std::tolower>(str); }
std::string str_upper_case(const std::string &str) { return str_ctype_transform<std::toupper>(str); }
// Convert char to snake_case: lowercase and spaces to underscores
static constexpr char to_snake_case_char(char c) {
return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c;
}
// Sanitize char: keep alphanumerics, dashes, underscores; replace others with underscore
static constexpr char to_sanitized_char(char c) {
return (c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) ? c : '_';
}
std::string str_snake_case(const std::string &str) {
std::string result;
result.resize(str.length());
std::transform(str.begin(), str.end(), result.begin(), ::tolower);
std::replace(result.begin(), result.end(), ' ', '_');
std::string result = str;
for (char &c : result) {
c = to_snake_case_char(c);
}
return result;
}
std::string str_sanitize(const std::string &str) {
std::string out = str;
std::replace_if(
out.begin(), out.end(),
[](const char &c) {
return c != '-' && c != '_' && (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z');
},
'_');
return out;
std::string result = str;
for (char &c : result) {
c = to_sanitized_char(c);
}
return result;
}
std::string str_snprintf(const char *fmt, size_t len, ...) {
std::string str;

View File

@@ -164,8 +164,24 @@ def websocket_method(name):
return wrap
class CheckOriginMixin:
"""Mixin to handle WebSocket origin checks for reverse proxy setups."""
def check_origin(self, origin: str) -> bool:
if "ESPHOME_TRUSTED_DOMAINS" not in os.environ:
return super().check_origin(origin)
trusted_domains = [
s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",")
]
url = urlparse(origin)
if url.hostname in trusted_domains:
return True
_LOGGER.info("check_origin %s, domain is not trusted", origin)
return False
@websocket_class
class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler):
"""Base class for ESPHome websocket commands."""
def __init__(
@@ -183,18 +199,6 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
# use Popen() with a reading thread instead
self._use_popen = os.name == "nt"
def check_origin(self, origin):
if "ESPHOME_TRUSTED_DOMAINS" not in os.environ:
return super().check_origin(origin)
trusted_domains = [
s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",")
]
url = urlparse(origin)
if url.hostname in trusted_domains:
return True
_LOGGER.info("check_origin %s, domain is not trusted", origin)
return False
def open(self, *args: str, **kwargs: str) -> None:
"""Handle new WebSocket connection."""
# Ensure messages from the subprocess are sent immediately
@@ -601,7 +605,7 @@ DASHBOARD_SUBSCRIBER = DashboardSubscriber()
@websocket_class
class DashboardEventsWebSocket(tornado.websocket.WebSocketHandler):
class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler):
"""WebSocket handler for real-time dashboard events."""
_event_listeners: list[Callable[[], None]] | None = None

View File

@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages
import esphome.config as config_module
from esphome.config import resolve_extend_remove
from esphome.config_helpers import Extend, Remove
import esphome.config_validation as cv
@@ -33,6 +34,7 @@ from esphome.const import (
CONF_VARS,
CONF_WIFI,
)
from esphome.core import CORE
from esphome.util import OrderedDict
# Test strings
@@ -991,3 +993,35 @@ def test_package_merge_invalid(invalid_package) -> None:
with pytest.raises(cv.Invalid):
merge_packages(config)
def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None:
"""Test that CORE.raw_config contains esphome section from merged package.
This is a regression test for the bug where CORE.raw_config was set before
packages were merged, causing KeyError when components accessed
CORE.raw_config[CONF_ESPHOME] and the esphome section came from a package.
"""
# Create a config where esphome section comes from a package
test_config = OrderedDict()
test_config[CONF_PACKAGES] = {
"base": {
CONF_ESPHOME: {CONF_NAME: TEST_DEVICE_NAME},
}
}
test_config["esp32"] = {"board": "esp32dev"}
# Set up CORE for the test
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("# test config")
CORE.reset()
CORE.config_path = test_yaml
# Call validate_config - this should merge packages and set CORE.raw_config
config_module.validate_config(test_config, {})
# Verify that CORE.raw_config contains the esphome section from the package
assert CONF_ESPHOME in CORE.raw_config, (
"CORE.raw_config should contain esphome section after package merge"
)
assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME

View File

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

View File

@@ -1567,3 +1567,90 @@ async def test_dashboard_yaml_loading_with_packages_and_secrets(
# If we get here, secret resolution worked!
assert "esphome" in config
assert config["esphome"]["name"] == "test-download-secrets"
@pytest.mark.asyncio
async def test_websocket_check_origin_default_same_origin(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket uses default same-origin check when ESPHOME_TRUSTED_DOMAINS not set."""
# Ensure ESPHOME_TRUSTED_DOMAINS is not set
env = os.environ.copy()
env.pop("ESPHOME_TRUSTED_DOMAINS", None)
with patch.dict(os.environ, env, clear=True):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
# Same origin should work (default Tornado behavior)
request = HTTPRequest(
url, headers={"Origin": f"http://127.0.0.1:{dashboard.port}"}
)
ws = await websocket_connect(request)
try:
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
finally:
ws.close()
@pytest.mark.asyncio
async def test_websocket_check_origin_trusted_domain(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket accepts connections from trusted domains."""
with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
request = HTTPRequest(url, headers={"Origin": "https://trusted.example.com"})
ws = await websocket_connect(request)
try:
# Should receive initial state
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
finally:
ws.close()
@pytest.mark.asyncio
async def test_websocket_check_origin_untrusted_domain(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket rejects connections from untrusted domains."""
with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
request = HTTPRequest(url, headers={"Origin": "https://untrusted.example.com"})
with pytest.raises(HTTPClientError) as exc_info:
await websocket_connect(request)
# Should get HTTP 403 Forbidden due to origin check failure
assert exc_info.value.code == 403
@pytest.mark.asyncio
async def test_websocket_check_origin_multiple_trusted_domains(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket accepts connections from multiple trusted domains."""
with patch.dict(
os.environ,
{"ESPHOME_TRUSTED_DOMAINS": "first.example.com, second.example.com"},
):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
# Test second domain in list (with space after comma)
request = HTTPRequest(url, headers={"Origin": "https://second.example.com"})
ws = await websocket_connect(request)
try:
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
finally:
ws.close()