mirror of
https://github.com/esphome/esphome.git
synced 2026-01-19 17:46:23 -07:00
Compare commits
26 Commits
memory_api
...
skip_wifi_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00dc93f70 | ||
|
|
7e1bd289b3 | ||
|
|
cf373edd81 | ||
|
|
0d7fbb79b3 | ||
|
|
c87771184e | ||
|
|
1185abadc1 | ||
|
|
6f6c65509d | ||
|
|
f50ffb2b92 | ||
|
|
4892bfb6e4 | ||
|
|
586e82bfa5 | ||
|
|
512a7df007 | ||
|
|
0e60aefdec | ||
|
|
e0ce66e011 | ||
|
|
75b8279361 | ||
|
|
6fce0a6104 | ||
|
|
b10a87c1b0 | ||
|
|
f2505ce453 | ||
|
|
ec7143d835 | ||
|
|
d77f9c96b9 | ||
|
|
4329794924 | ||
|
|
99b0b974ad | ||
|
|
9dafafa07c | ||
|
|
200cd8cace | ||
|
|
b8c00e6452 | ||
|
|
1d081fd510 | ||
|
|
80fda97c60 |
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, []):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1
tests/components/hmac_md5/test.host.yaml
Normal file
1
tests/components/hmac_md5/test.host.yaml
Normal file
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user