mirror of
https://github.com/esphome/esphome.git
synced 2026-01-13 05:27:53 -07:00
Compare commits
72 Commits
lock_names
...
2025.11.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a593965372 | ||
|
|
4743e5592a | ||
|
|
464607011c | ||
|
|
16fe8f9e9e | ||
|
|
436d2c44e8 | ||
|
|
b213555dd2 | ||
|
|
b6336f9e63 | ||
|
|
fb7800a22f | ||
|
|
42811edeb4 | ||
|
|
8f20abebf6 | ||
|
|
7077488dc7 | ||
|
|
ef34239064 | ||
|
|
44148c0c6b | ||
|
|
1b53fcf634 | ||
|
|
b18e3d943a | ||
|
|
f0673f6304 | ||
|
|
320ba30d50 | ||
|
|
cfd88376b9 | ||
|
|
577a6b2941 | ||
|
|
de68b56c4a | ||
|
|
ccd23e692b | ||
|
|
1f5a44be3d | ||
|
|
1d1e47c757 | ||
| 3fbed1fa79 | |||
|
|
5c71520635 | ||
|
|
9d6c81ec23 | ||
|
|
73fa9230e6 | ||
|
|
48caff13c9 | ||
|
|
71bb94524e | ||
|
|
a3199792c6 | ||
|
|
50c1720c16 | ||
|
|
4115dd7222 | ||
|
|
d5e2543751 | ||
|
|
b4b34aee13 | ||
|
|
6645994700 | ||
|
|
ae140f52e3 | ||
|
|
46ae6d35a2 | ||
|
|
278f12fb99 | ||
|
|
acdcd56395 | ||
|
|
9289fc36f7 | ||
|
|
3775b54554 | ||
|
|
9186144dcd | ||
|
|
25bcd0ea25 | ||
|
|
50d08a2eba | ||
|
|
3a7a0c66ab | ||
|
|
83525b7a92 | ||
|
|
f31f023c89 | ||
|
|
f8efefffaa | ||
|
|
d698083ede | ||
|
|
11ba6440d7 | ||
|
|
89ee37a2d5 | ||
|
|
45b8c1e267 | ||
|
|
fbe091f167 | ||
|
|
625172e07d | ||
|
|
1e9c7d3c6d | ||
|
|
c2bc7b3cdc | ||
|
|
c75abfb894 | ||
|
|
1157b4aee8 | ||
|
|
71dc2d374d | ||
|
|
0a224f919b | ||
|
|
7ef4b4f3d9 | ||
|
|
13b875c763 | ||
|
|
dfd614c00c | ||
|
|
2681a14d05 | ||
|
|
f436f6ee2e | ||
|
|
f18bc62690 | ||
|
|
6db73df649 | ||
|
|
93215f1737 | ||
|
|
70aa94b8a4 | ||
|
|
e8998a79c7 | ||
|
|
3b25fdbc5f | ||
|
|
6c8577678c |
53
.github/workflows/release.yml
vendored
53
.github/workflows/release.yml
vendored
@@ -219,10 +219,19 @@ jobs:
|
||||
- init
|
||||
- deploy-manifest
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: home-assistant-addon
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }}
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
let description = "ESPHome";
|
||||
if (context.eventName == "release") {
|
||||
@@ -245,10 +254,19 @@ jobs:
|
||||
needs: [init]
|
||||
environment: ${{ needs.init.outputs.deploy_env }}
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: esphome-schema
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }}
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
@@ -259,3 +277,34 @@ jobs:
|
||||
version: "${{ needs.init.outputs.tag }}",
|
||||
}
|
||||
})
|
||||
|
||||
version-notifier:
|
||||
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- init
|
||||
- deploy-manifest
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: version-notifier
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
repo: "version-notifier",
|
||||
workflow_id: "notify.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
version: "${{ needs.init.outputs.tag }}",
|
||||
}
|
||||
})
|
||||
|
||||
2
Doxyfile
2
Doxyfile
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2025.11.0b3
|
||||
PROJECT_NUMBER = 2025.11.5
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
@@ -1319,7 +1319,7 @@ def parse_args(argv):
|
||||
"clean-all", help="Clean all build and platform files."
|
||||
)
|
||||
parser_clean_all.add_argument(
|
||||
"configuration", help="Your YAML configuration directory.", nargs="*"
|
||||
"configuration", help="Your YAML file or configuration directory.", nargs="*"
|
||||
)
|
||||
|
||||
parser_dashboard = subparsers.add_parser(
|
||||
|
||||
@@ -25,7 +25,8 @@ void ADE7953::setup() {
|
||||
this->ade_write_8(PGA_V_8, pga_v_);
|
||||
this->ade_write_8(PGA_IA_8, pga_ia_);
|
||||
this->ade_write_8(PGA_IB_8, pga_ib_);
|
||||
this->ade_write_32(AVGAIN_32, vgain_);
|
||||
this->ade_write_32(AVGAIN_32, avgain_);
|
||||
this->ade_write_32(BVGAIN_32, bvgain_);
|
||||
this->ade_write_32(AIGAIN_32, aigain_);
|
||||
this->ade_write_32(BIGAIN_32, bigain_);
|
||||
this->ade_write_32(AWGAIN_32, awgain_);
|
||||
@@ -34,7 +35,8 @@ void ADE7953::setup() {
|
||||
this->ade_read_8(PGA_V_8, &pga_v_);
|
||||
this->ade_read_8(PGA_IA_8, &pga_ia_);
|
||||
this->ade_read_8(PGA_IB_8, &pga_ib_);
|
||||
this->ade_read_32(AVGAIN_32, &vgain_);
|
||||
this->ade_read_32(AVGAIN_32, &avgain_);
|
||||
this->ade_read_32(BVGAIN_32, &bvgain_);
|
||||
this->ade_read_32(AIGAIN_32, &aigain_);
|
||||
this->ade_read_32(BIGAIN_32, &bigain_);
|
||||
this->ade_read_32(AWGAIN_32, &awgain_);
|
||||
@@ -63,13 +65,14 @@ void ADE7953::dump_config() {
|
||||
" PGA_V_8: 0x%X\n"
|
||||
" PGA_IA_8: 0x%X\n"
|
||||
" PGA_IB_8: 0x%X\n"
|
||||
" VGAIN_32: 0x%08jX\n"
|
||||
" AVGAIN_32: 0x%08jX\n"
|
||||
" BVGAIN_32: 0x%08jX\n"
|
||||
" AIGAIN_32: 0x%08jX\n"
|
||||
" BIGAIN_32: 0x%08jX\n"
|
||||
" AWGAIN_32: 0x%08jX\n"
|
||||
" BWGAIN_32: 0x%08jX",
|
||||
this->use_acc_energy_regs_, pga_v_, pga_ia_, pga_ib_, (uintmax_t) vgain_, (uintmax_t) aigain_,
|
||||
(uintmax_t) bigain_, (uintmax_t) awgain_, (uintmax_t) bwgain_);
|
||||
this->use_acc_energy_regs_, pga_v_, pga_ia_, pga_ib_, (uintmax_t) avgain_, (uintmax_t) bvgain_,
|
||||
(uintmax_t) aigain_, (uintmax_t) bigain_, (uintmax_t) awgain_, (uintmax_t) bwgain_);
|
||||
}
|
||||
|
||||
#define ADE_PUBLISH_(name, val, factor) \
|
||||
|
||||
@@ -46,7 +46,12 @@ class ADE7953 : public PollingComponent, public sensor::Sensor {
|
||||
void set_pga_ib(uint8_t pga_ib) { pga_ib_ = pga_ib; }
|
||||
|
||||
// Set input gains
|
||||
void set_vgain(uint32_t vgain) { vgain_ = vgain; }
|
||||
void set_vgain(uint32_t vgain) {
|
||||
// Datasheet says: "to avoid discrepancies in other registers,
|
||||
// if AVGAIN is set then BVGAIN should be set to the same value."
|
||||
avgain_ = vgain;
|
||||
bvgain_ = vgain;
|
||||
}
|
||||
void set_aigain(uint32_t aigain) { aigain_ = aigain; }
|
||||
void set_bigain(uint32_t bigain) { bigain_ = bigain; }
|
||||
void set_awgain(uint32_t awgain) { awgain_ = awgain; }
|
||||
@@ -100,7 +105,8 @@ class ADE7953 : public PollingComponent, public sensor::Sensor {
|
||||
uint8_t pga_v_;
|
||||
uint8_t pga_ia_;
|
||||
uint8_t pga_ib_;
|
||||
uint32_t vgain_;
|
||||
uint32_t avgain_;
|
||||
uint32_t bvgain_;
|
||||
uint32_t aigain_;
|
||||
uint32_t bigain_;
|
||||
uint32_t awgain_;
|
||||
|
||||
@@ -12,10 +12,11 @@ void AnalogThresholdBinarySensor::setup() {
|
||||
// TRUE state is defined to be when sensor is >= threshold
|
||||
// so when undefined sensor value initialize to FALSE
|
||||
if (std::isnan(sensor_value)) {
|
||||
this->raw_state_ = false;
|
||||
this->publish_initial_state(false);
|
||||
} else {
|
||||
this->publish_initial_state(sensor_value >=
|
||||
(this->lower_threshold_.value() + this->upper_threshold_.value()) / 2.0f);
|
||||
this->raw_state_ = sensor_value >= (this->lower_threshold_.value() + this->upper_threshold_.value()) / 2.0f;
|
||||
this->publish_initial_state(this->raw_state_);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +26,10 @@ void AnalogThresholdBinarySensor::set_sensor(sensor::Sensor *analog_sensor) {
|
||||
this->sensor_->add_on_state_callback([this](float sensor_value) {
|
||||
// if there is an invalid sensor reading, ignore the change and keep the current state
|
||||
if (!std::isnan(sensor_value)) {
|
||||
this->publish_state(sensor_value >=
|
||||
(this->state ? this->lower_threshold_.value() : this->upper_threshold_.value()));
|
||||
// Use raw_state_ for hysteresis logic, not this->state which is post-filter
|
||||
this->raw_state_ =
|
||||
sensor_value >= (this->raw_state_ ? this->lower_threshold_.value() : this->upper_threshold_.value());
|
||||
this->publish_state(this->raw_state_);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina
|
||||
sensor::Sensor *sensor_{nullptr};
|
||||
TemplatableValue<float> upper_threshold_{};
|
||||
TemplatableValue<float> lower_threshold_{};
|
||||
bool raw_state_{false}; // Pre-filter state for hysteresis logic
|
||||
};
|
||||
|
||||
} // namespace analog_threshold
|
||||
|
||||
@@ -36,13 +36,20 @@ void BinarySensor::publish_initial_state(bool new_state) {
|
||||
void BinarySensor::send_state_internal(bool new_state) {
|
||||
// copy the new state to the visible property for backwards compatibility, before any callbacks
|
||||
this->state = new_state;
|
||||
// Note that set_state_ de-dups and will only trigger callbacks if the state has actually changed
|
||||
if (this->set_state_(new_state)) {
|
||||
ESP_LOGD(TAG, "'%s': New state is %s", this->get_name().c_str(), ONOFF(new_state));
|
||||
// Note that set_new_state_ de-dups and will only trigger callbacks if the state has actually changed
|
||||
this->set_new_state(new_state);
|
||||
}
|
||||
|
||||
bool BinarySensor::set_new_state(const optional<bool> &new_state) {
|
||||
if (StatefulEntityBase::set_new_state(new_state)) {
|
||||
// weirdly, this file could be compiled even without USE_BINARY_SENSOR defined
|
||||
#if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY)
|
||||
ControllerRegistry::notify_binary_sensor_update(this);
|
||||
#endif
|
||||
ESP_LOGD(TAG, "'%s': %s", this->get_name().c_str(), ONOFFMAYBE(new_state));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void BinarySensor::add_filter(Filter *filter) {
|
||||
|
||||
@@ -63,6 +63,8 @@ class BinarySensor : public StatefulEntityBase<bool>, public EntityBase_DeviceCl
|
||||
|
||||
protected:
|
||||
Filter *filter_list_{nullptr};
|
||||
|
||||
bool set_new_state(const optional<bool> &new_state) override;
|
||||
};
|
||||
|
||||
class BinarySensorInitiallyOff : public BinarySensor {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import web_server_base
|
||||
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AP,
|
||||
CONF_ID,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
@@ -14,6 +17,10 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.coroutine import CoroPriority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def AUTO_LOAD() -> list[str]:
|
||||
@@ -50,6 +57,37 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
full_config = fv.full_config.get()
|
||||
wifi_conf = full_config.get("wifi")
|
||||
|
||||
if wifi_conf is None:
|
||||
# This shouldn't happen due to DEPENDENCIES = ["wifi"], but check anyway
|
||||
raise cv.Invalid("Captive portal requires the wifi component to be configured")
|
||||
|
||||
if CONF_AP not in wifi_conf:
|
||||
_LOGGER.warning(
|
||||
"Captive portal is enabled but no WiFi AP is configured. "
|
||||
"The captive portal will not be accessible. "
|
||||
"Add 'ap:' to your WiFi configuration to enable the captive portal."
|
||||
)
|
||||
|
||||
# Register socket needs for DNS server and additional HTTP connections
|
||||
# - 1 UDP socket for DNS server
|
||||
# - 3 additional TCP sockets for captive portal detection probes + configuration requests
|
||||
# OS captive portal detection makes multiple probe requests that stay in TIME_WAIT.
|
||||
# Need headroom for actual user configuration requests.
|
||||
# LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts.
|
||||
from esphome.components import socket
|
||||
|
||||
socket.consume_sockets(4, "captive_portal")(config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL)
|
||||
async def to_code(config):
|
||||
paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID])
|
||||
|
||||
@@ -50,8 +50,8 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
|
||||
ESP_LOGI(TAG, "Requested WiFi Settings Change:");
|
||||
ESP_LOGI(TAG, " SSID='%s'", ssid.c_str());
|
||||
ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str());
|
||||
wifi::global_wifi_component->save_wifi_sta(ssid, psk);
|
||||
wifi::global_wifi_component->start_scanning();
|
||||
// Defer save to main loop thread to avoid NVS operations from HTTP thread
|
||||
this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid, psk); });
|
||||
request->redirect(ESPHOME_F("/?save"));
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ 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,6 +40,10 @@ 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();
|
||||
|
||||
@@ -19,8 +19,9 @@ void CST816Touchscreen::continue_setup_() {
|
||||
case CST816T_CHIP_ID:
|
||||
break;
|
||||
default:
|
||||
ESP_LOGE(TAG, "Unknown chip ID: 0x%02X", this->chip_id_);
|
||||
this->status_set_error("Unknown chip ID");
|
||||
this->mark_failed();
|
||||
this->status_set_error(str_sprintf("Unknown chip ID 0x%02X", this->chip_id_).c_str());
|
||||
return;
|
||||
}
|
||||
this->write_byte(REG_IRQ_CTL, IRQ_EN_MOTION);
|
||||
|
||||
@@ -102,7 +102,7 @@ def customise_schema(config):
|
||||
"""
|
||||
config = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True),
|
||||
cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True, space="-"),
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(config)
|
||||
|
||||
@@ -32,11 +32,15 @@ class SpectraE6(EpaperModel):
|
||||
|
||||
spectra_e6 = SpectraE6("spectra-e6")
|
||||
|
||||
spectra_e6.extend(
|
||||
"Seeed-reTerminal-E1002",
|
||||
spectra_e6_7p3 = spectra_e6.extend(
|
||||
"7.3in-Spectra-E6",
|
||||
width=800,
|
||||
height=480,
|
||||
data_rate="20MHz",
|
||||
)
|
||||
|
||||
spectra_e6_7p3.extend(
|
||||
"Seeed-reTerminal-E1002",
|
||||
cs_pin=10,
|
||||
dc_pin=11,
|
||||
reset_pin=12,
|
||||
|
||||
@@ -22,7 +22,6 @@ ES8311_BITS_PER_SAMPLE_ENUM = {
|
||||
|
||||
es8311_mic_gain = es8311_ns.enum("ES8311MicGain")
|
||||
ES8311_MIC_GAIN_ENUM = {
|
||||
"MIN": es8311_mic_gain.ES8311_MIC_GAIN_MIN,
|
||||
"0DB": es8311_mic_gain.ES8311_MIC_GAIN_0DB,
|
||||
"6DB": es8311_mic_gain.ES8311_MIC_GAIN_6DB,
|
||||
"12DB": es8311_mic_gain.ES8311_MIC_GAIN_12DB,
|
||||
@@ -31,7 +30,6 @@ ES8311_MIC_GAIN_ENUM = {
|
||||
"30DB": es8311_mic_gain.ES8311_MIC_GAIN_30DB,
|
||||
"36DB": es8311_mic_gain.ES8311_MIC_GAIN_36DB,
|
||||
"42DB": es8311_mic_gain.ES8311_MIC_GAIN_42DB,
|
||||
"MAX": es8311_mic_gain.ES8311_MIC_GAIN_MAX,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -854,8 +854,13 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
|
||||
async def to_code(config):
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
|
||||
cg.add_platformio_option(
|
||||
"board_upload.maximum_size",
|
||||
int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024,
|
||||
)
|
||||
cg.set_cpp_standard("gnu++20")
|
||||
cg.add_build_flag("-DUSE_ESP32")
|
||||
cg.add_build_flag("-Wl,-z,noexecstack")
|
||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
||||
variant = config[CONF_VARIANT]
|
||||
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
|
||||
@@ -883,6 +888,12 @@ async def to_code(config):
|
||||
CORE.relative_internal_path(".espressif")
|
||||
)
|
||||
|
||||
add_extra_script(
|
||||
"pre",
|
||||
"pre_build.py",
|
||||
Path(__file__).parent / "pre_build.py.script",
|
||||
)
|
||||
|
||||
add_extra_script(
|
||||
"post",
|
||||
"post_build.py",
|
||||
@@ -931,6 +942,12 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True)
|
||||
|
||||
# ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency
|
||||
if get_esp32_variant() == VARIANT_ESP32S2:
|
||||
cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=1")
|
||||
cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=0")
|
||||
cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=0")
|
||||
|
||||
cg.add_build_flag("-Wno-nonnull-compare")
|
||||
|
||||
add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True)
|
||||
|
||||
9
esphome/components/esp32/pre_build.py.script
Normal file
9
esphome/components/esp32/pre_build.py.script
Normal file
@@ -0,0 +1,9 @@
|
||||
Import("env") # noqa: F821
|
||||
|
||||
# Remove custom_sdkconfig from the board config as it causes
|
||||
# pioarduino to enable some strange hybrid build mode that breaks IDF
|
||||
board = env.BoardConfig()
|
||||
if "espidf.custom_sdkconfig" in board:
|
||||
del board._manifest["espidf"]["custom_sdkconfig"]
|
||||
if not board._manifest["espidf"]:
|
||||
del board._manifest["espidf"]
|
||||
@@ -93,9 +93,9 @@ async def to_code(config):
|
||||
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||
os.environ["ESP_IDF_VERSION"] = f"{framework_ver.major}.{framework_ver.minor}"
|
||||
if framework_ver >= cv.Version(5, 5, 0):
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.2.2")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.3")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.6.1")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.7.0")
|
||||
else:
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
|
||||
|
||||
@@ -22,6 +22,11 @@ constexpr size_t CHUNK_SIZE = 1500;
|
||||
void Esp32HostedUpdate::setup() {
|
||||
this->update_info_.title = "ESP32 Hosted Coprocessor";
|
||||
|
||||
// if wifi is not present, connect to the coprocessor
|
||||
#ifndef USE_WIFI
|
||||
esp_hosted_connect_to_slave(); // NOLINT
|
||||
#endif
|
||||
|
||||
// get coprocessor version
|
||||
esp_hosted_coprocessor_fwver_t ver_info;
|
||||
if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) {
|
||||
|
||||
@@ -20,6 +20,10 @@ CONF_ON_STOP = "on_stop"
|
||||
CONF_STATUS_INDICATOR = "status_indicator"
|
||||
CONF_WIFI_TIMEOUT = "wifi_timeout"
|
||||
|
||||
# Default WiFi timeout - aligned with WiFi component ap_timeout
|
||||
# Allows sufficient time to try all BSSIDs before starting provisioning mode
|
||||
DEFAULT_WIFI_TIMEOUT = "90s"
|
||||
|
||||
|
||||
improv_ns = cg.esphome_ns.namespace("improv")
|
||||
Error = improv_ns.enum("Error")
|
||||
@@ -59,7 +63,7 @@ CONFIG_SCHEMA = (
|
||||
CONF_AUTHORIZED_DURATION, default="1min"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(
|
||||
CONF_WIFI_TIMEOUT, default="1min"
|
||||
CONF_WIFI_TIMEOUT, default=DEFAULT_WIFI_TIMEOUT
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation(
|
||||
{
|
||||
|
||||
@@ -127,6 +127,7 @@ void ESP32ImprovComponent::loop() {
|
||||
// Set initial state based on whether we have an authorizer
|
||||
this->set_state_(this->get_initial_state_(), false);
|
||||
this->set_error_(improv::ERROR_NONE);
|
||||
this->should_start_ = false; // Clear flag after starting
|
||||
ESP_LOGD(TAG, "Service started!");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class ESP32ImprovComponent : public Component, public improv_base::ImprovBase {
|
||||
void start();
|
||||
void stop();
|
||||
bool is_active() const { return this->state_ != improv::STATE_STOPPED; }
|
||||
bool should_start() const { return this->should_start_; }
|
||||
|
||||
#ifdef USE_ESP32_IMPROV_STATE_CALLBACK
|
||||
void add_on_state_callback(std::function<void(improv::State, improv::Error)> &&callback) {
|
||||
|
||||
@@ -14,8 +14,8 @@ void EspLdo::setup() {
|
||||
config.flags.adjustable = this->adjustable_;
|
||||
auto err = esp_ldo_acquire_channel(&config, &this->handle_);
|
||||
if (err != ESP_OK) {
|
||||
auto msg = str_sprintf("Failed to acquire LDO channel %d with voltage %fV", this->channel_, this->voltage_);
|
||||
this->mark_failed(msg.c_str());
|
||||
ESP_LOGE(TAG, "Failed to acquire LDO channel %d with voltage %fV", this->channel_, this->voltage_);
|
||||
this->mark_failed("Failed to acquire LDO channel");
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Acquired LDO channel %d with voltage %fV", this->channel_, this->voltage_);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <esp_event.h>
|
||||
#include <esp_mac.h>
|
||||
#include <esp_netif.h>
|
||||
#include <esp_now.h>
|
||||
#include <esp_random.h>
|
||||
#include <esp_wifi.h>
|
||||
@@ -157,6 +158,12 @@ bool ESPNowComponent::is_wifi_enabled() {
|
||||
}
|
||||
|
||||
void ESPNowComponent::setup() {
|
||||
#ifndef USE_WIFI
|
||||
// Initialize LwIP stack for wake_loop_threadsafe() socket support
|
||||
// When WiFi component is present, it handles esp_netif_init()
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
#endif
|
||||
|
||||
if (this->enable_on_boot_) {
|
||||
this->enable_();
|
||||
} else {
|
||||
|
||||
@@ -337,7 +337,7 @@ void Graph::draw_legend(display::Display *buff, uint16_t x_offset, uint16_t y_of
|
||||
return;
|
||||
|
||||
/// Plot border
|
||||
if (this->border_) {
|
||||
if (legend_->border_) {
|
||||
int w = legend_->width_;
|
||||
int h = legend_->height_;
|
||||
buff->horizontal_line(x_offset, y_offset, w, color);
|
||||
|
||||
@@ -189,7 +189,7 @@ template<typename... Ts> class EnrollmentAction : public Action<Ts...>, public P
|
||||
TEMPLATABLE_VALUE(std::string, name)
|
||||
TEMPLATABLE_VALUE(uint8_t, direction)
|
||||
|
||||
void play(Ts... x) override {
|
||||
void play(const Ts &...x) override {
|
||||
auto name = this->name_.value(x...);
|
||||
auto direction = (HlkFm22xFaceDirection) this->direction_.value(x...);
|
||||
this->parent_->enroll_face(name, direction);
|
||||
@@ -200,7 +200,7 @@ template<typename... Ts> class DeleteAction : public Action<Ts...>, public Paren
|
||||
public:
|
||||
TEMPLATABLE_VALUE(int16_t, face_id)
|
||||
|
||||
void play(Ts... x) override {
|
||||
void play(const Ts &...x) override {
|
||||
auto face_id = this->face_id_.value(x...);
|
||||
this->parent_->delete_face(face_id);
|
||||
}
|
||||
@@ -208,17 +208,17 @@ template<typename... Ts> class DeleteAction : public Action<Ts...>, public Paren
|
||||
|
||||
template<typename... Ts> class DeleteAllAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
|
||||
public:
|
||||
void play(Ts... x) override { this->parent_->delete_all_faces(); }
|
||||
void play(const Ts &...x) override { this->parent_->delete_all_faces(); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class ScanAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
|
||||
public:
|
||||
void play(Ts... x) override { this->parent_->scan_face(); }
|
||||
void play(const Ts &...x) override { this->parent_->scan_face(); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class ResetAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
|
||||
public:
|
||||
void play(Ts... x) override { this->parent_->reset(); }
|
||||
void play(const Ts &...x) override { this->parent_->reset(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::hlk_fm22x
|
||||
|
||||
@@ -49,18 +49,18 @@ void HttpRequestUpdate::update_task(void *params) {
|
||||
auto container = this_update->request_parent_->get(this_update->source_url_);
|
||||
|
||||
if (container == nullptr || container->status_code != HTTP_STATUS_OK) {
|
||||
std::string msg = str_sprintf("Failed to fetch manifest from %s", this_update->source_url_.c_str());
|
||||
ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str());
|
||||
// Defer to main loop to avoid race condition on component_state_ read-modify-write
|
||||
this_update->defer([this_update, msg]() { this_update->status_set_error(msg.c_str()); });
|
||||
this_update->defer([this_update]() { this_update->status_set_error("Failed to fetch manifest"); });
|
||||
UPDATE_RETURN;
|
||||
}
|
||||
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
uint8_t *data = allocator.allocate(container->content_length);
|
||||
if (data == nullptr) {
|
||||
std::string msg = str_sprintf("Failed to allocate %zu bytes for manifest", container->content_length);
|
||||
ESP_LOGE(TAG, "Failed to allocate %zu bytes for manifest", container->content_length);
|
||||
// Defer to main loop to avoid race condition on component_state_ read-modify-write
|
||||
this_update->defer([this_update, msg]() { this_update->status_set_error(msg.c_str()); });
|
||||
this_update->defer([this_update]() { this_update->status_set_error("Failed to allocate memory for manifest"); });
|
||||
container->end();
|
||||
UPDATE_RETURN;
|
||||
}
|
||||
@@ -121,9 +121,9 @@ void HttpRequestUpdate::update_task(void *params) {
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
std::string msg = str_sprintf("Failed to parse JSON from %s", this_update->source_url_.c_str());
|
||||
ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str());
|
||||
// Defer to main loop to avoid race condition on component_state_ read-modify-write
|
||||
this_update->defer([this_update, msg]() { this_update->status_set_error(msg.c_str()); });
|
||||
this_update->defer([this_update]() { this_update->status_set_error("Failed to parse manifest JSON"); });
|
||||
UPDATE_RETURN;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace jsn_sr04t {
|
||||
static const char *const TAG = "jsn_sr04t.sensor";
|
||||
|
||||
void Jsnsr04tComponent::update() {
|
||||
this->write_byte(0x55);
|
||||
this->write_byte((this->model_ == AJ_SR04M) ? 0x01 : 0x55);
|
||||
ESP_LOGV(TAG, "Request read out from sensor");
|
||||
}
|
||||
|
||||
@@ -31,19 +31,10 @@ void Jsnsr04tComponent::loop() {
|
||||
}
|
||||
|
||||
void Jsnsr04tComponent::check_buffer_() {
|
||||
uint8_t checksum = 0;
|
||||
switch (this->model_) {
|
||||
case JSN_SR04T:
|
||||
checksum = this->buffer_[0] + this->buffer_[1] + this->buffer_[2];
|
||||
break;
|
||||
case AJ_SR04M:
|
||||
checksum = this->buffer_[1] + this->buffer_[2];
|
||||
break;
|
||||
}
|
||||
|
||||
uint8_t checksum = this->buffer_[0] + this->buffer_[1] + this->buffer_[2];
|
||||
if (this->buffer_[3] == checksum) {
|
||||
uint16_t distance = encode_uint16(this->buffer_[1], this->buffer_[2]);
|
||||
if (distance > 250) {
|
||||
if (distance > ((this->model_ == AJ_SR04M) ? 200 : 250)) {
|
||||
float meters = distance / 1000.0f;
|
||||
ESP_LOGV(TAG, "Distance from sensor: %umm, %.3fm", distance, meters);
|
||||
this->publish_state(meters);
|
||||
|
||||
@@ -205,8 +205,10 @@ void LD2420Component::dump_config() {
|
||||
LOG_BUTTON(" ", "Factory Reset:", this->factory_reset_button_);
|
||||
LOG_BUTTON(" ", "Restart Module:", this->restart_module_button_);
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
ESP_LOGCONFIG(TAG, "Select:");
|
||||
LOG_SELECT(" ", "Operating Mode", this->operating_selector_);
|
||||
#endif
|
||||
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
|
||||
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
|
||||
}
|
||||
@@ -238,12 +240,20 @@ void LD2420Component::setup() {
|
||||
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
|
||||
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
|
||||
this->set_operating_mode(OP_SIMPLE_MODE_STRING);
|
||||
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
|
||||
#ifdef USE_SELECT
|
||||
if (this->operating_selector_ != nullptr) {
|
||||
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
|
||||
}
|
||||
#endif
|
||||
this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
|
||||
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
|
||||
} else {
|
||||
this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
|
||||
this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING);
|
||||
#ifdef USE_SELECT
|
||||
if (this->operating_selector_ != nullptr) {
|
||||
this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#ifdef USE_NUMBER
|
||||
this->init_gate_config_numbers();
|
||||
@@ -383,8 +393,12 @@ void LD2420Component::set_operating_mode(const char *state) {
|
||||
// If unsupported firmware ignore mode select
|
||||
if (ld2420::get_firmware_int(firmware_ver_) >= CALIBRATE_VERSION_MIN) {
|
||||
this->current_operating_mode = find_uint8(OP_MODE_BY_STR, state);
|
||||
// Entering Auto Calibrate we need to clear the privoiuos data collection
|
||||
this->operating_selector_->publish_state(state);
|
||||
// Entering Auto Calibrate we need to clear the previous data collection
|
||||
#ifdef USE_SELECT
|
||||
if (this->operating_selector_ != nullptr) {
|
||||
this->operating_selector_->publish_state(state);
|
||||
}
|
||||
#endif
|
||||
if (current_operating_mode == OP_CALIBRATE_MODE) {
|
||||
this->set_calibration_(true);
|
||||
for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) {
|
||||
@@ -404,7 +418,11 @@ void LD2420Component::set_operating_mode(const char *state) {
|
||||
}
|
||||
} else {
|
||||
this->current_operating_mode = OP_SIMPLE_MODE;
|
||||
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
|
||||
#ifdef USE_SELECT
|
||||
if (this->operating_selector_ != nullptr) {
|
||||
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ void LTRAlsPs501Component::loop() {
|
||||
break;
|
||||
|
||||
case State::WAITING_FOR_DATA:
|
||||
if (this->is_als_data_ready_(this->als_readings_) == DataAvail::DATA_OK) {
|
||||
if (this->is_als_data_ready_(this->als_readings_) == LtrDataAvail::LTR_DATA_OK) {
|
||||
tries = 0;
|
||||
ESP_LOGV(TAG, "Reading sensor data assuming gain = %.0fx, time = %d ms",
|
||||
get_gain_coeff(this->als_readings_.gain), get_itime_ms(this->als_readings_.integration_time));
|
||||
@@ -379,18 +379,18 @@ void LTRAlsPs501Component::configure_integration_time_(IntegrationTime501 time)
|
||||
}
|
||||
}
|
||||
|
||||
DataAvail LTRAlsPs501Component::is_als_data_ready_(AlsReadings &data) {
|
||||
LtrDataAvail LTRAlsPs501Component::is_als_data_ready_(AlsReadings &data) {
|
||||
AlsPsStatusRegister als_status{0};
|
||||
als_status.raw = this->reg((uint8_t) CommandRegisters::ALS_PS_STATUS).get();
|
||||
if (!als_status.als_new_data)
|
||||
return DataAvail::NO_DATA;
|
||||
return LtrDataAvail::LTR_NO_DATA;
|
||||
ESP_LOGV(TAG, "Data ready, reported gain is %.0fx", get_gain_coeff(als_status.gain));
|
||||
if (data.gain != als_status.gain) {
|
||||
ESP_LOGW(TAG, "Actual gain differs from requested (%.0f)", get_gain_coeff(data.gain));
|
||||
return DataAvail::BAD_DATA;
|
||||
return LtrDataAvail::LTR_BAD_DATA;
|
||||
}
|
||||
data.gain = als_status.gain;
|
||||
return DataAvail::DATA_OK;
|
||||
return LtrDataAvail::LTR_DATA_OK;
|
||||
}
|
||||
|
||||
void LTRAlsPs501Component::read_sensor_data_(AlsReadings &data) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
namespace esphome {
|
||||
namespace ltr501 {
|
||||
|
||||
enum DataAvail : uint8_t { NO_DATA, BAD_DATA, DATA_OK };
|
||||
enum LtrDataAvail : uint8_t { LTR_NO_DATA, LTR_BAD_DATA, LTR_DATA_OK };
|
||||
|
||||
enum LtrType : uint8_t {
|
||||
LTR_TYPE_UNKNOWN = 0,
|
||||
@@ -106,7 +106,7 @@ class LTRAlsPs501Component : public PollingComponent, public i2c::I2CDevice {
|
||||
void configure_als_();
|
||||
void configure_integration_time_(IntegrationTime501 time);
|
||||
void configure_gain_(AlsGain501 gain);
|
||||
DataAvail is_als_data_ready_(AlsReadings &data);
|
||||
LtrDataAvail is_als_data_ready_(AlsReadings &data);
|
||||
void read_sensor_data_(AlsReadings &data);
|
||||
bool are_adjustments_required_(AlsReadings &data);
|
||||
void apply_lux_calculation_(AlsReadings &data);
|
||||
|
||||
@@ -165,7 +165,7 @@ void LTRAlsPsComponent::loop() {
|
||||
break;
|
||||
|
||||
case State::WAITING_FOR_DATA:
|
||||
if (this->is_als_data_ready_(this->als_readings_) == DataAvail::DATA_OK) {
|
||||
if (this->is_als_data_ready_(this->als_readings_) == LtrDataAvail::LTR_DATA_OK) {
|
||||
tries = 0;
|
||||
ESP_LOGV(TAG, "Reading sensor data having gain = %.0fx, time = %d ms", get_gain_coeff(this->als_readings_.gain),
|
||||
get_itime_ms(this->als_readings_.integration_time));
|
||||
@@ -376,23 +376,23 @@ void LTRAlsPsComponent::configure_integration_time_(IntegrationTime time) {
|
||||
}
|
||||
}
|
||||
|
||||
DataAvail LTRAlsPsComponent::is_als_data_ready_(AlsReadings &data) {
|
||||
LtrDataAvail LTRAlsPsComponent::is_als_data_ready_(AlsReadings &data) {
|
||||
AlsPsStatusRegister als_status{0};
|
||||
|
||||
als_status.raw = this->reg((uint8_t) CommandRegisters::ALS_PS_STATUS).get();
|
||||
if (!als_status.als_new_data)
|
||||
return DataAvail::NO_DATA;
|
||||
return LtrDataAvail::LTR_NO_DATA;
|
||||
|
||||
if (als_status.data_invalid) {
|
||||
ESP_LOGW(TAG, "Data available but not valid");
|
||||
return DataAvail::BAD_DATA;
|
||||
return LtrDataAvail::LTR_BAD_DATA;
|
||||
}
|
||||
ESP_LOGV(TAG, "Data ready, reported gain is %.0f", get_gain_coeff(als_status.gain));
|
||||
if (data.gain != als_status.gain) {
|
||||
ESP_LOGW(TAG, "Actual gain differs from requested (%.0f)", get_gain_coeff(data.gain));
|
||||
return DataAvail::BAD_DATA;
|
||||
return LtrDataAvail::LTR_BAD_DATA;
|
||||
}
|
||||
return DataAvail::DATA_OK;
|
||||
return LtrDataAvail::LTR_DATA_OK;
|
||||
}
|
||||
|
||||
void LTRAlsPsComponent::read_sensor_data_(AlsReadings &data) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
namespace esphome {
|
||||
namespace ltr_als_ps {
|
||||
|
||||
enum DataAvail : uint8_t { NO_DATA, BAD_DATA, DATA_OK };
|
||||
enum LtrDataAvail : uint8_t { LTR_NO_DATA, LTR_BAD_DATA, LTR_DATA_OK };
|
||||
|
||||
enum LtrType : uint8_t {
|
||||
LTR_TYPE_UNKNOWN = 0,
|
||||
@@ -106,7 +106,7 @@ class LTRAlsPsComponent : public PollingComponent, public i2c::I2CDevice {
|
||||
void configure_als_();
|
||||
void configure_integration_time_(IntegrationTime time);
|
||||
void configure_gain_(AlsGain gain);
|
||||
DataAvail is_als_data_ready_(AlsReadings &data);
|
||||
LtrDataAvail is_als_data_ready_(AlsReadings &data);
|
||||
void read_sensor_data_(AlsReadings &data);
|
||||
bool are_adjustments_required_(AlsReadings &data);
|
||||
void apply_lux_calculation_(AlsReadings &data);
|
||||
|
||||
@@ -36,6 +36,8 @@ from .defines import (
|
||||
)
|
||||
from .lv_validation import padding, size
|
||||
|
||||
CONF_MULTIPLE_WIDGETS_PER_CELL = "multiple_widgets_per_cell"
|
||||
|
||||
cell_alignments = LV_CELL_ALIGNMENTS.one_of
|
||||
grid_alignments = LV_GRID_ALIGNMENTS.one_of
|
||||
flex_alignments = LV_FLEX_ALIGNMENTS.one_of
|
||||
@@ -220,6 +222,7 @@ class GridLayout(Layout):
|
||||
cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments,
|
||||
cv.Optional(CONF_PAD_ROW): padding,
|
||||
cv.Optional(CONF_PAD_COLUMN): padding,
|
||||
cv.Optional(CONF_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean,
|
||||
},
|
||||
{
|
||||
cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int,
|
||||
@@ -263,6 +266,7 @@ class GridLayout(Layout):
|
||||
# should be guaranteed to be a dict at this point
|
||||
assert isinstance(layout, dict)
|
||||
assert layout.get(CONF_TYPE).lower() == TYPE_GRID
|
||||
allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False)
|
||||
rows = len(layout[CONF_GRID_ROWS])
|
||||
columns = len(layout[CONF_GRID_COLUMNS])
|
||||
used_cells = [[None] * columns for _ in range(rows)]
|
||||
@@ -299,7 +303,10 @@ class GridLayout(Layout):
|
||||
f"exceeds grid size {rows}x{columns}",
|
||||
[CONF_WIDGETS, index],
|
||||
)
|
||||
if used_cells[row + i][column + j] is not None:
|
||||
if (
|
||||
not allow_multiple
|
||||
and used_cells[row + i][column + j] is not None
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}",
|
||||
[CONF_WIDGETS, index],
|
||||
|
||||
@@ -29,15 +29,18 @@ class LVGLNumber : public number::Number, public Component {
|
||||
this->publish_state(value);
|
||||
}
|
||||
|
||||
void on_value() { this->publish_state(this->value_lambda_()); }
|
||||
void on_value() { this->publish_(this->value_lambda_()); }
|
||||
|
||||
protected:
|
||||
void control(float value) override {
|
||||
this->control_lambda_(value);
|
||||
void publish_(float value) {
|
||||
this->publish_state(value);
|
||||
if (this->restore_)
|
||||
this->pref_.save(&value);
|
||||
}
|
||||
void control(float value) override {
|
||||
this->control_lambda_(value);
|
||||
this->publish_(value);
|
||||
}
|
||||
std::function<void(float)> control_lambda_;
|
||||
std::function<float()> value_lambda_;
|
||||
lv_event_code_t event_;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import config_validation as cv
|
||||
from esphome.automation import Trigger, validate_automation
|
||||
from esphome.components.time import RealTimeClock
|
||||
from esphome.config_validation import prepend_path
|
||||
from esphome.const import (
|
||||
CONF_ARGS,
|
||||
CONF_FORMAT,
|
||||
@@ -422,7 +423,10 @@ def any_widget_schema(extras=None):
|
||||
def validator(value):
|
||||
if isinstance(value, dict):
|
||||
# Convert to list
|
||||
is_dict = True
|
||||
value = [{k: v} for k, v in value.items()]
|
||||
else:
|
||||
is_dict = False
|
||||
if not isinstance(value, list):
|
||||
raise cv.Invalid("Expected a list of widgets")
|
||||
result = []
|
||||
@@ -443,7 +447,9 @@ def any_widget_schema(extras=None):
|
||||
)
|
||||
# Apply custom validation
|
||||
value = widget_type.validate(value or {})
|
||||
result.append({key: container_validator(value)})
|
||||
path = [key] if is_dict else [index, key]
|
||||
with prepend_path(path):
|
||||
result.append({key: container_validator(value)})
|
||||
return result
|
||||
|
||||
return validator
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import automation
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_RANGE_FROM, CONF_RANGE_TO, CONF_STEP, CONF_VALUE
|
||||
from esphome.cpp_generator import MockObj
|
||||
|
||||
from ..automation import action_to_code
|
||||
from ..defines import (
|
||||
@@ -114,7 +115,9 @@ class SpinboxType(WidgetType):
|
||||
w.obj, digits, digits - config[CONF_DECIMAL_PLACES]
|
||||
)
|
||||
if (value := config.get(CONF_VALUE)) is not None:
|
||||
lv.spinbox_set_value(w.obj, await lv_float.process(value))
|
||||
lv.spinbox_set_value(
|
||||
w.obj, MockObj(await lv_float.process(value)) * w.get_scale()
|
||||
)
|
||||
|
||||
def get_scale(self, config):
|
||||
return 10 ** config[CONF_DECIMAL_PLACES]
|
||||
|
||||
@@ -11,6 +11,12 @@ static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel
|
||||
xSemaphoreGiveFromISR(sem, &need_yield);
|
||||
return (need_yield == pdTRUE);
|
||||
}
|
||||
|
||||
void MIPI_DSI::smark_failed(const char *message, esp_err_t err) {
|
||||
ESP_LOGE(TAG, "%s: %s", message, esp_err_to_name(err));
|
||||
this->mark_failed(message);
|
||||
}
|
||||
|
||||
void MIPI_DSI::setup() {
|
||||
ESP_LOGCONFIG(TAG, "Running Setup");
|
||||
|
||||
|
||||
@@ -62,10 +62,7 @@ class MIPI_DSI : public display::Display {
|
||||
void set_lanes(uint8_t lanes) { this->lanes_ = lanes; }
|
||||
void set_madctl(uint8_t madctl) { this->madctl_ = madctl; }
|
||||
|
||||
void smark_failed(const char *message, esp_err_t err) {
|
||||
auto str = str_sprintf("Setup failed: %s: %s", message, esp_err_to_name(err));
|
||||
this->mark_failed(str.c_str());
|
||||
}
|
||||
void smark_failed(const char *message, esp_err_t err);
|
||||
|
||||
void update() override;
|
||||
|
||||
|
||||
@@ -164,8 +164,8 @@ void MipiRgb::common_setup_() {
|
||||
if (err == ESP_OK)
|
||||
err = esp_lcd_panel_init(this->handle_);
|
||||
if (err != ESP_OK) {
|
||||
auto msg = str_sprintf("lcd setup failed: %s", esp_err_to_name(err));
|
||||
this->mark_failed(msg.c_str());
|
||||
ESP_LOGE(TAG, "lcd setup failed: %s", esp_err_to_name(err));
|
||||
this->mark_failed("lcd setup failed");
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, "MipiRgb setup complete");
|
||||
}
|
||||
@@ -350,6 +350,7 @@ void MipiRgb::dump_config() {
|
||||
"\n Width: %u"
|
||||
"\n Height: %u"
|
||||
"\n Rotation: %d degrees"
|
||||
"\n PCLK Inverted: %s"
|
||||
"\n HSync Pulse Width: %u"
|
||||
"\n HSync Back Porch: %u"
|
||||
"\n HSync Front Porch: %u"
|
||||
@@ -357,18 +358,18 @@ void MipiRgb::dump_config() {
|
||||
"\n VSync Back Porch: %u"
|
||||
"\n VSync Front Porch: %u"
|
||||
"\n Invert Colors: %s"
|
||||
"\n Pixel Clock: %dMHz"
|
||||
"\n Pixel Clock: %uMHz"
|
||||
"\n Reset Pin: %s"
|
||||
"\n DE Pin: %s"
|
||||
"\n PCLK Pin: %s"
|
||||
"\n HSYNC Pin: %s"
|
||||
"\n VSYNC Pin: %s",
|
||||
this->model_, this->width_, this->height_, this->rotation_, this->hsync_pulse_width_,
|
||||
this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, this->vsync_back_porch_,
|
||||
this->vsync_front_porch_, YESNO(this->invert_colors_), this->pclk_frequency_ / 1000000,
|
||||
get_pin_name(this->reset_pin_).c_str(), get_pin_name(this->de_pin_).c_str(),
|
||||
get_pin_name(this->pclk_pin_).c_str(), get_pin_name(this->hsync_pin_).c_str(),
|
||||
get_pin_name(this->vsync_pin_).c_str());
|
||||
this->model_, this->width_, this->height_, this->rotation_, YESNO(this->pclk_inverted_),
|
||||
this->hsync_pulse_width_, this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_,
|
||||
this->vsync_back_porch_, this->vsync_front_porch_, YESNO(this->invert_colors_),
|
||||
(unsigned) (this->pclk_frequency_ / 1000000), get_pin_name(this->reset_pin_).c_str(),
|
||||
get_pin_name(this->de_pin_).c_str(), get_pin_name(this->pclk_pin_).c_str(),
|
||||
get_pin_name(this->hsync_pin_).c_str(), get_pin_name(this->vsync_pin_).c_str());
|
||||
|
||||
if (this->madctl_ & MADCTL_BGR) {
|
||||
this->dump_pins_(8, 13, "Blue", 0);
|
||||
|
||||
@@ -11,6 +11,7 @@ st7701s.extend(
|
||||
vsync_pin=17,
|
||||
pclk_pin=21,
|
||||
pclk_frequency="12MHz",
|
||||
pclk_inverted=False,
|
||||
pixel_mode="18bit",
|
||||
mirror_x=True,
|
||||
mirror_y=True,
|
||||
|
||||
@@ -116,7 +116,7 @@ bool MopekaProCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device)
|
||||
|
||||
// Get temperature of sensor
|
||||
if (this->temperature_ != nullptr) {
|
||||
uint8_t temp_in_c = this->parse_temperature_(manu_data.data);
|
||||
int8_t temp_in_c = this->parse_temperature_(manu_data.data);
|
||||
this->temperature_->publish_state(temp_in_c);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ uint32_t MopekaProCheck::parse_distance_(const std::vector<uint8_t> &message) {
|
||||
(MOPEKA_LPG_COEF[0] + MOPEKA_LPG_COEF[1] * raw_t + MOPEKA_LPG_COEF[2] * raw_t * raw_t));
|
||||
}
|
||||
|
||||
uint8_t MopekaProCheck::parse_temperature_(const std::vector<uint8_t> &message) { return (message[2] & 0x7F) - 40; }
|
||||
int8_t MopekaProCheck::parse_temperature_(const std::vector<uint8_t> &message) { return (message[2] & 0x7F) - 40; }
|
||||
|
||||
SensorReadQuality MopekaProCheck::parse_read_quality_(const std::vector<uint8_t> &message) {
|
||||
// Since a 8 bit value is being shifted and truncated to 2 bits all possible values are defined as enumeration
|
||||
|
||||
@@ -61,7 +61,7 @@ class MopekaProCheck : public Component, public esp32_ble_tracker::ESPBTDeviceLi
|
||||
|
||||
uint8_t parse_battery_level_(const std::vector<uint8_t> &message);
|
||||
uint32_t parse_distance_(const std::vector<uint8_t> &message);
|
||||
uint8_t parse_temperature_(const std::vector<uint8_t> &message);
|
||||
int8_t parse_temperature_(const std::vector<uint8_t> &message);
|
||||
SensorReadQuality parse_read_quality_(const std::vector<uint8_t> &message);
|
||||
};
|
||||
|
||||
|
||||
@@ -81,7 +81,12 @@ struct IPAddress {
|
||||
ip_addr_.type = IPADDR_TYPE_V6;
|
||||
}
|
||||
#endif /* LWIP_IPV6 */
|
||||
IPAddress(esp_ip4_addr_t *other_ip) { memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(esp_ip4_addr_t)); }
|
||||
IPAddress(esp_ip4_addr_t *other_ip) {
|
||||
memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(esp_ip4_addr_t));
|
||||
#if LWIP_IPV6
|
||||
ip_addr_.type = IPADDR_TYPE_V4;
|
||||
#endif
|
||||
}
|
||||
IPAddress(esp_ip_addr_t *other_ip) {
|
||||
#if LWIP_IPV6
|
||||
memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip_addr_));
|
||||
|
||||
@@ -174,6 +174,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) {
|
||||
|
||||
// Check if baud rate is supported
|
||||
this->original_baud_rate_ = this->parent_->get_baud_rate();
|
||||
if (baud_rate <= 0) {
|
||||
baud_rate = this->original_baud_rate_;
|
||||
}
|
||||
ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate);
|
||||
|
||||
// Define the configuration for the HTTP client
|
||||
|
||||
@@ -177,6 +177,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) {
|
||||
|
||||
// Check if baud rate is supported
|
||||
this->original_baud_rate_ = this->parent_->get_baud_rate();
|
||||
if (baud_rate <= 0) {
|
||||
baud_rate = this->original_baud_rate_;
|
||||
}
|
||||
ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate);
|
||||
|
||||
// Define the configuration for the HTTP client
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
|
||||
|
||||
#include "esphome/components/display/display_buffer.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
@@ -38,6 +39,14 @@ static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, ui
|
||||
PngDecoder *decoder = (PngDecoder *) pngle_get_user_data(pngle);
|
||||
Color color(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||
decoder->draw(x, y, w, h, color);
|
||||
|
||||
// Feed watchdog periodically to avoid triggering during long decode operations.
|
||||
// Feed every 1024 pixels to balance efficiency and responsiveness.
|
||||
uint32_t pixels = w * h;
|
||||
decoder->increment_pixels_decoded(pixels);
|
||||
if ((decoder->get_pixels_decoded() % 1024) < pixels) {
|
||||
App.feed_wdt();
|
||||
}
|
||||
}
|
||||
|
||||
PngDecoder::PngDecoder(OnlineImage *image) : ImageDecoder(image) {
|
||||
|
||||
@@ -25,9 +25,13 @@ class PngDecoder : public ImageDecoder {
|
||||
int prepare(size_t download_size) override;
|
||||
int HOT decode(uint8_t *buffer, size_t size) override;
|
||||
|
||||
void increment_pixels_decoded(uint32_t count) { this->pixels_decoded_ += count; }
|
||||
uint32_t get_pixels_decoded() const { return this->pixels_decoded_; }
|
||||
|
||||
protected:
|
||||
RAMAllocator<pngle_t> allocator_;
|
||||
pngle_t *pngle_;
|
||||
uint32_t pixels_decoded_{0};
|
||||
};
|
||||
|
||||
} // namespace online_image
|
||||
|
||||
@@ -195,8 +195,8 @@ static void add(std::vector<uint8_t> &vec, const char *str) {
|
||||
void PacketTransport::setup() {
|
||||
this->name_ = App.get_name().c_str();
|
||||
if (strlen(this->name_) > 255) {
|
||||
this->mark_failed();
|
||||
this->status_set_error("Device name exceeds 255 chars");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
this->resend_ping_key_ = this->ping_pong_enable_;
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
# in schema.py file in this directory.
|
||||
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import libretiny
|
||||
from esphome.components.libretiny.const import (
|
||||
COMPONENT_RTL87XX,
|
||||
FAMILY_RTL8710B,
|
||||
KEY_COMPONENT_DATA,
|
||||
KEY_FAMILY,
|
||||
KEY_LIBRETINY,
|
||||
LibreTinyComponent,
|
||||
)
|
||||
@@ -45,6 +48,11 @@ CONFIG_SCHEMA.prepend_extra(_set_core_data)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
# Use FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake required by AsyncTCP 3.4.3+
|
||||
# https://github.com/esphome/esphome/issues/10220
|
||||
# Only for RTL8710B (ambz) - RTL8720C (ambz2) requires FreeRTOS 10.x
|
||||
if CORE.data[KEY_LIBRETINY][KEY_FAMILY] == FAMILY_RTL8710B:
|
||||
cg.add_platformio_option("custom_versions.freertos", "8.2.3")
|
||||
return await libretiny.component_to_code(config)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <tuple>
|
||||
#include <forward_list>
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -278,7 +278,12 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
|
||||
|
||||
void setup() override {
|
||||
// Start with loop disabled - only enable when there's work to do
|
||||
this->disable_loop();
|
||||
// IMPORTANT: Only disable if num_running_ is 0, otherwise play_complex() was already
|
||||
// called before our setup() (e.g., from on_boot trigger at same priority level)
|
||||
// and we must not undo its enable_loop() call
|
||||
if (this->num_running_ == 0) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
@@ -290,10 +295,10 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
|
||||
}
|
||||
|
||||
// Store parameters for later execution
|
||||
this->param_queue_.emplace_front(x...);
|
||||
// Enable loop now that we have work to do
|
||||
this->param_queue_.emplace_back(x...);
|
||||
// Enable loop now that we have work to do - don't call loop() synchronously!
|
||||
// Let the event loop call it to avoid reentrancy issues
|
||||
this->enable_loop();
|
||||
this->loop();
|
||||
}
|
||||
|
||||
void loop() override {
|
||||
@@ -303,13 +308,17 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
|
||||
if (this->script_->is_running())
|
||||
return;
|
||||
|
||||
while (!this->param_queue_.empty()) {
|
||||
// Only process ONE queued item per loop iteration
|
||||
// Processing all items in a while loop causes infinite loops because
|
||||
// play_next_() can trigger more items to be queued
|
||||
if (!this->param_queue_.empty()) {
|
||||
auto ¶ms = this->param_queue_.front();
|
||||
this->play_next_tuple_(params, typename gens<sizeof...(Ts)>::type());
|
||||
this->param_queue_.pop_front();
|
||||
} else {
|
||||
// Queue is now empty - disable loop until next play_complex
|
||||
this->disable_loop();
|
||||
}
|
||||
// Queue is now empty - disable loop until next play_complex
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override { /* ignore - see play_complex */
|
||||
@@ -326,7 +335,7 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
|
||||
}
|
||||
|
||||
C *script_;
|
||||
std::forward_list<std::tuple<Ts...>> param_queue_;
|
||||
std::list<std::tuple<Ts...>> param_queue_;
|
||||
};
|
||||
|
||||
} // namespace script
|
||||
|
||||
@@ -73,17 +73,17 @@ void SFA30Component::update() {
|
||||
}
|
||||
|
||||
if (this->formaldehyde_sensor_ != nullptr) {
|
||||
const float formaldehyde = raw_data[0] / 5.0f;
|
||||
const float formaldehyde = static_cast<int16_t>(raw_data[0]) / 5.0f;
|
||||
this->formaldehyde_sensor_->publish_state(formaldehyde);
|
||||
}
|
||||
|
||||
if (this->humidity_sensor_ != nullptr) {
|
||||
const float humidity = raw_data[1] / 100.0f;
|
||||
const float humidity = static_cast<int16_t>(raw_data[1]) / 100.0f;
|
||||
this->humidity_sensor_->publish_state(humidity);
|
||||
}
|
||||
|
||||
if (this->temperature_sensor_ != nullptr) {
|
||||
const float temperature = raw_data[2] / 200.0f;
|
||||
const float temperature = static_cast<int16_t>(raw_data[2]) / 200.0f;
|
||||
this->temperature_sensor_->publish_state(temperature);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,10 +66,14 @@ SubstituteFilter::SubstituteFilter(const std::initializer_list<Substitution> &su
|
||||
: substitutions_(substitutions) {}
|
||||
|
||||
optional<std::string> SubstituteFilter::new_value(std::string value) {
|
||||
std::size_t pos;
|
||||
for (const auto &sub : this->substitutions_) {
|
||||
while ((pos = value.find(sub.from)) != std::string::npos)
|
||||
std::size_t pos = 0;
|
||||
while ((pos = value.find(sub.from, pos)) != std::string::npos) {
|
||||
value.replace(pos, sub.from.size(), sub.to);
|
||||
// Advance past the replacement to avoid infinite loop when
|
||||
// the replacement contains the search pattern (e.g., f -> foo)
|
||||
pos += sub.to.size();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ void UDPComponent::setup() {
|
||||
if (this->should_broadcast_) {
|
||||
this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
||||
if (this->broadcast_socket_ == nullptr) {
|
||||
this->mark_failed();
|
||||
this->status_set_error("Could not create socket");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
int enable = 1;
|
||||
@@ -41,15 +41,15 @@ void UDPComponent::setup() {
|
||||
if (this->should_listen_) {
|
||||
this->listen_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
||||
if (this->listen_socket_ == nullptr) {
|
||||
this->mark_failed();
|
||||
this->status_set_error("Could not create socket");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
auto err = this->listen_socket_->setblocking(false);
|
||||
if (err < 0) {
|
||||
ESP_LOGE(TAG, "Unable to set nonblocking: errno %d", errno);
|
||||
this->mark_failed();
|
||||
this->status_set_error("Unable to set nonblocking");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
int enable = 1;
|
||||
@@ -73,8 +73,8 @@ void UDPComponent::setup() {
|
||||
err = this->listen_socket_->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq));
|
||||
if (err < 0) {
|
||||
ESP_LOGE(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno);
|
||||
this->mark_failed();
|
||||
this->status_set_error("Failed to set IP_ADD_MEMBERSHIP");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,8 @@ void UDPComponent::setup() {
|
||||
err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server));
|
||||
if (err != 0) {
|
||||
ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno);
|
||||
this->mark_failed();
|
||||
this->status_set_error("Unable to bind socket");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import socket
|
||||
from esphome.components.uart import (
|
||||
CONF_DATA_BITS,
|
||||
CONF_PARITY,
|
||||
@@ -17,7 +18,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.cpp_types import Component
|
||||
|
||||
AUTO_LOAD = ["uart", "usb_host", "bytebuffer"]
|
||||
AUTO_LOAD = ["uart", "usb_host", "bytebuffer", "socket"]
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
|
||||
usb_uart_ns = cg.esphome_ns.namespace("usb_uart")
|
||||
@@ -116,6 +117,10 @@ CONFIG_SCHEMA = cv.ensure_list(
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
# Enable wake_loop_threadsafe for low-latency USB data processing
|
||||
# The USB task queues data events that need immediate processing
|
||||
socket.require_wake_loop_threadsafe()
|
||||
|
||||
for device in config:
|
||||
var = await register_usb_client(device)
|
||||
for index, channel in enumerate(device[CONF_CHANNELS]):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4)
|
||||
#include "usb_uart.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/components/uart/uart_debugger.h"
|
||||
|
||||
#include <cinttypes>
|
||||
@@ -262,6 +263,11 @@ void USBUartComponent::start_input(USBUartChannel *channel) {
|
||||
// Push to lock-free queue for main loop processing
|
||||
// Push always succeeds because pool size == queue size
|
||||
this->usb_data_queue_.push(chunk);
|
||||
|
||||
// Wake main loop immediately to process USB data instead of waiting for select() timeout
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
|
||||
App.wake_loop_threadsafe();
|
||||
#endif
|
||||
}
|
||||
|
||||
// On success, restart input immediately from USB task for performance
|
||||
|
||||
@@ -67,8 +67,8 @@ void WakeOnLanButton::setup() {
|
||||
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
|
||||
this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
||||
if (this->broadcast_socket_ == nullptr) {
|
||||
this->mark_failed();
|
||||
this->status_set_error("Could not create socket");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
int enable = 1;
|
||||
|
||||
@@ -87,6 +87,29 @@ int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void AsyncWebServer::safe_close_with_shutdown(httpd_handle_t hd, int sockfd) {
|
||||
// CRITICAL: Shut down receive BEFORE closing to prevent lwIP race conditions
|
||||
//
|
||||
// The race condition occurs because close() initiates lwIP teardown while
|
||||
// the TCP/IP thread can still receive packets, causing assertions when
|
||||
// recv_tcp() sees partially-torn-down state.
|
||||
//
|
||||
// By shutting down receive first, we tell lwIP to stop accepting new data BEFORE
|
||||
// the teardown begins, eliminating the race window. We only shutdown RD (not RDWR)
|
||||
// to allow the FIN packet to be sent cleanly during close().
|
||||
//
|
||||
// Note: This function may be called with an already-closed socket if the network
|
||||
// stack closed it. In that case, shutdown() will fail but close() is safe to call.
|
||||
//
|
||||
// See: https://github.com/esphome/esphome-webserver/issues/163
|
||||
|
||||
// Attempt shutdown - ignore errors as socket may already be closed
|
||||
shutdown(sockfd, SHUT_RD);
|
||||
|
||||
// Always close - safe even if socket is already closed by network stack
|
||||
close(sockfd);
|
||||
}
|
||||
|
||||
void AsyncWebServer::end() {
|
||||
if (this->server_) {
|
||||
httpd_stop(this->server_);
|
||||
@@ -94,6 +117,18 @@ 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();
|
||||
@@ -101,6 +136,10 @@ 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_;
|
||||
// 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) {
|
||||
const httpd_uri_t handler_get = {
|
||||
.uri = "",
|
||||
@@ -242,6 +281,7 @@ void AsyncWebServerRequest::send(int code, const char *content_type, const char
|
||||
void AsyncWebServerRequest::redirect(const std::string &url) {
|
||||
httpd_resp_set_status(*this, "302 Found");
|
||||
httpd_resp_set_hdr(*this, "Location", url.c_str());
|
||||
httpd_resp_set_hdr(*this, "Connection", "close");
|
||||
httpd_resp_send(*this, nullptr, 0);
|
||||
}
|
||||
|
||||
@@ -490,17 +530,11 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
|
||||
void AsyncEventSourceResponse::destroy(void *ptr) {
|
||||
auto *rsp = static_cast<AsyncEventSourceResponse *>(ptr);
|
||||
int fd = rsp->fd_.exchange(0); // Atomically get and clear fd
|
||||
|
||||
if (fd > 0) {
|
||||
ESP_LOGD(TAG, "Event source connection closed (fd: %d)", fd);
|
||||
// Immediately shut down the socket to prevent lwIP from delivering more data
|
||||
// This prevents "recv_tcp: recv for wrong pcb!" assertions when the TCP stack
|
||||
// tries to deliver queued data after the session is marked as dead
|
||||
// See: https://github.com/esphome/esphome/issues/11936
|
||||
shutdown(fd, SHUT_RDWR);
|
||||
// Note: We don't close() the socket - httpd owns it and will close it
|
||||
}
|
||||
// Session will be cleaned up in the main loop to avoid race conditions
|
||||
ESP_LOGD(TAG, "Event source connection closed (fd: %d)", fd);
|
||||
// Mark as dead - will be cleaned up in the main loop
|
||||
// Note: We don't delete or remove from set here to avoid race conditions
|
||||
// httpd will call our custom close_fn (safe_close_with_shutdown) which handles
|
||||
// shutdown() before close() to prevent lwIP race conditions
|
||||
}
|
||||
|
||||
// helper for allowing only unique entries in the queue
|
||||
|
||||
@@ -199,12 +199,17 @@ 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;
|
||||
static void safe_close_with_shutdown(httpd_handle_t hd, int sockfd);
|
||||
#ifdef USE_WEBSERVER_OTA
|
||||
esp_err_t handle_multipart_upload_(httpd_req_t *r, const char *content_type);
|
||||
#endif
|
||||
|
||||
@@ -69,6 +69,12 @@ CONF_MIN_AUTH_MODE = "min_auth_mode"
|
||||
# Limited to 127 because selected_sta_index_ is int8_t in C++
|
||||
MAX_WIFI_NETWORKS = 127
|
||||
|
||||
# Default AP timeout - allows sufficient time to try all BSSIDs during initial connection
|
||||
# After AP starts, WiFi scanning is skipped to avoid disrupting the AP, so we only
|
||||
# get best-effort connection attempts. Longer timeout ensures we exhaust all options
|
||||
# before falling back to AP mode. Aligned with improv wifi_timeout default.
|
||||
DEFAULT_AP_TIMEOUT = "90s"
|
||||
|
||||
wifi_ns = cg.esphome_ns.namespace("wifi")
|
||||
EAPAuth = wifi_ns.struct("EAPAuth")
|
||||
ManualIP = wifi_ns.struct("ManualIP")
|
||||
@@ -177,7 +183,7 @@ CONF_AP_TIMEOUT = "ap_timeout"
|
||||
WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_AP_TIMEOUT, default="1min"
|
||||
CONF_AP_TIMEOUT, default=DEFAULT_AP_TIMEOUT
|
||||
): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -199,7 +199,12 @@ static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1;
|
||||
|
||||
/// Cooldown duration in milliseconds after adapter restart or repeated failures
|
||||
/// Allows WiFi hardware to stabilize before next connection attempt
|
||||
static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 1000;
|
||||
static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500;
|
||||
|
||||
/// Cooldown duration when fallback AP is active and captive portal may be running
|
||||
/// Longer interval gives users time to configure WiFi without constant connection attempts
|
||||
/// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown
|
||||
static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000;
|
||||
|
||||
static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) {
|
||||
switch (phase) {
|
||||
@@ -275,7 +280,9 @@ int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this->ssid_was_seen_in_scan_(sta.get_ssid())) {
|
||||
// If we didn't scan this cycle, treat all networks as potentially hidden
|
||||
// Otherwise, only retry networks that weren't seen in the scan
|
||||
if (!this->did_scan_this_cycle_ || !this->ssid_was_seen_in_scan_(sta.get_ssid())) {
|
||||
ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.get_ssid().c_str(), static_cast<int>(i));
|
||||
return static_cast<int8_t>(i);
|
||||
}
|
||||
@@ -417,10 +424,6 @@ void WiFiComponent::start() {
|
||||
void WiFiComponent::restart_adapter() {
|
||||
ESP_LOGW(TAG, "Restarting adapter");
|
||||
this->wifi_mode_(false, {});
|
||||
// Enter cooldown state to allow WiFi hardware to stabilize after restart
|
||||
// Don't set retry_phase_ or num_retried_ here - state machine handles transitions
|
||||
this->state_ = WIFI_COMPONENT_STATE_COOLDOWN;
|
||||
this->action_started_ = millis();
|
||||
this->error_from_callback_ = false;
|
||||
}
|
||||
|
||||
@@ -441,7 +444,16 @@ void WiFiComponent::loop() {
|
||||
switch (this->state_) {
|
||||
case WIFI_COMPONENT_STATE_COOLDOWN: {
|
||||
this->status_set_warning(LOG_STR("waiting to reconnect"));
|
||||
if (now - this->action_started_ > WIFI_COOLDOWN_DURATION_MS) {
|
||||
// Skip cooldown if new credentials were provided while connecting
|
||||
if (this->skip_cooldown_next_cycle_) {
|
||||
this->skip_cooldown_next_cycle_ = false;
|
||||
this->check_connecting_finished();
|
||||
break;
|
||||
}
|
||||
// Use longer cooldown when captive portal/improv is active to avoid disrupting user config
|
||||
bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_();
|
||||
uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS;
|
||||
if (now - this->action_started_ > cooldown_duration) {
|
||||
// After cooldown we either restarted the adapter because of
|
||||
// a failure, or something tried to connect over and over
|
||||
// so we entered cooldown. In both cases we call
|
||||
@@ -495,7 +507,8 @@ void WiFiComponent::loop() {
|
||||
#endif // USE_WIFI_AP
|
||||
|
||||
#ifdef USE_IMPROV
|
||||
if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active()) {
|
||||
if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() &&
|
||||
!esp32_improv::global_improv_component->should_start()) {
|
||||
if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) {
|
||||
if (this->wifi_mode_(true, {}))
|
||||
esp32_improv::global_improv_component->start();
|
||||
@@ -605,6 +618,8 @@ void WiFiComponent::set_sta(const WiFiAP &ap) {
|
||||
this->init_sta(1);
|
||||
this->add_sta(ap);
|
||||
this->selected_sta_index_ = 0;
|
||||
// When new credentials are set (e.g., from improv), skip cooldown to retry immediately
|
||||
this->skip_cooldown_next_cycle_ = true;
|
||||
}
|
||||
|
||||
WiFiAP WiFiComponent::build_params_for_current_phase_() {
|
||||
@@ -666,6 +681,17 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa
|
||||
sta.set_ssid(ssid);
|
||||
sta.set_password(password);
|
||||
this->set_sta(sta);
|
||||
|
||||
// Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected)
|
||||
this->connect_soon_();
|
||||
}
|
||||
|
||||
void WiFiComponent::connect_soon_() {
|
||||
// Only trigger retry if we're in cooldown - if already connecting/connected, do nothing
|
||||
if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) {
|
||||
ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials");
|
||||
this->retry_connect();
|
||||
}
|
||||
}
|
||||
|
||||
void WiFiComponent::start_connecting(const WiFiAP &ap) {
|
||||
@@ -961,6 +987,7 @@ void WiFiComponent::check_scanning_finished() {
|
||||
return;
|
||||
}
|
||||
this->scan_done_ = false;
|
||||
this->did_scan_this_cycle_ = true;
|
||||
|
||||
if (this->scan_result_.empty()) {
|
||||
ESP_LOGW(TAG, "No networks found");
|
||||
@@ -1182,8 +1209,8 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() {
|
||||
}
|
||||
|
||||
case WiFiRetryPhase::SCAN_CONNECTING:
|
||||
// If scan found no matching networks, skip to hidden network mode
|
||||
if (!this->scan_result_.empty() && !this->scan_result_[0].get_matches()) {
|
||||
// If scan found no networks or no matching networks, skip to hidden network mode
|
||||
if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) {
|
||||
return WiFiRetryPhase::RETRY_HIDDEN;
|
||||
}
|
||||
|
||||
@@ -1227,9 +1254,16 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() {
|
||||
return WiFiRetryPhase::RESTARTING_ADAPTER;
|
||||
|
||||
case WiFiRetryPhase::RESTARTING_ADAPTER:
|
||||
// After restart, go back to explicit hidden if we went through it initially, otherwise scan
|
||||
return this->went_through_explicit_hidden_phase_() ? WiFiRetryPhase::EXPLICIT_HIDDEN
|
||||
: WiFiRetryPhase::SCAN_CONNECTING;
|
||||
// After restart, go back to explicit hidden if we went through it initially
|
||||
if (this->went_through_explicit_hidden_phase_()) {
|
||||
return WiFiRetryPhase::EXPLICIT_HIDDEN;
|
||||
}
|
||||
// Skip scanning when captive portal/improv is active to avoid disrupting AP
|
||||
// Even passive scans can cause brief AP disconnections on ESP32
|
||||
if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) {
|
||||
return WiFiRetryPhase::RETRY_HIDDEN;
|
||||
}
|
||||
return WiFiRetryPhase::SCAN_CONNECTING;
|
||||
}
|
||||
|
||||
// Should never reach here
|
||||
@@ -1317,6 +1351,12 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
|
||||
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
|
||||
this->restart_adapter();
|
||||
}
|
||||
// Clear scan flag - we're starting a new retry cycle
|
||||
this->did_scan_this_cycle_ = false;
|
||||
// Always enter cooldown after restart (or skip-restart) to allow stabilization
|
||||
// Use extended cooldown when AP is active to avoid constant scanning that blocks DNS
|
||||
this->state_ = WIFI_COMPONENT_STATE_COOLDOWN;
|
||||
this->action_started_ = millis();
|
||||
// Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting
|
||||
return true;
|
||||
|
||||
@@ -1515,6 +1555,20 @@ void WiFiComponent::retry_connect() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_RP2040
|
||||
// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart
|
||||
// mDNS when the network interface reconnects. However, this callback is disabled
|
||||
// in the arduino-pico framework. As a workaround, we block component setup until
|
||||
// WiFi is connected, ensuring mDNS.begin() is called with an active connection.
|
||||
|
||||
bool WiFiComponent::can_proceed() {
|
||||
if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) {
|
||||
return true;
|
||||
}
|
||||
return this->is_connected();
|
||||
}
|
||||
#endif
|
||||
|
||||
void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
|
||||
bool WiFiComponent::is_connected() {
|
||||
return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED &&
|
||||
|
||||
@@ -280,6 +280,10 @@ class WiFiComponent : public Component {
|
||||
|
||||
void retry_connect();
|
||||
|
||||
#ifdef USE_RP2040
|
||||
bool can_proceed() override;
|
||||
#endif
|
||||
|
||||
void set_reboot_timeout(uint32_t reboot_timeout);
|
||||
|
||||
bool is_connected();
|
||||
@@ -291,6 +295,7 @@ class WiFiComponent : public Component {
|
||||
void set_passive_scan(bool passive);
|
||||
|
||||
void save_wifi_sta(const std::string &ssid, const std::string &password);
|
||||
|
||||
// ========== INTERNAL METHODS ==========
|
||||
// (In most use cases you won't need these)
|
||||
/// Setup WiFi interface.
|
||||
@@ -424,6 +429,8 @@ class WiFiComponent : public Component {
|
||||
return true;
|
||||
}
|
||||
|
||||
void connect_soon_();
|
||||
|
||||
void wifi_loop_();
|
||||
bool wifi_mode_(optional<bool> sta, optional<bool> ap);
|
||||
bool wifi_sta_pre_setup_();
|
||||
@@ -529,6 +536,8 @@ class WiFiComponent : public Component {
|
||||
bool enable_on_boot_;
|
||||
bool got_ipv4_address_{false};
|
||||
bool keep_scan_results_{false};
|
||||
bool did_scan_this_cycle_{false};
|
||||
bool skip_cooldown_next_cycle_{false};
|
||||
|
||||
// Pointers at the end (naturally aligned)
|
||||
Trigger<> *connect_trigger_{new Trigger<>()};
|
||||
|
||||
@@ -870,7 +870,13 @@ bssid_t WiFiComponent::wifi_bssid() {
|
||||
return bssid;
|
||||
}
|
||||
std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); }
|
||||
int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; }
|
||||
int8_t WiFiComponent::wifi_rssi() {
|
||||
if (WiFi.status() != WL_CONNECTED)
|
||||
return WIFI_RSSI_DISCONNECTED;
|
||||
int8_t rssi = WiFi.RSSI();
|
||||
// Values >= 31 are error codes per NONOS SDK API, not valid RSSI readings
|
||||
return rssi >= 31 ? WIFI_RSSI_DISCONNECTED : rssi;
|
||||
}
|
||||
int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); }
|
||||
network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {(const ip_addr_t *) WiFi.subnetMask()}; }
|
||||
network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {(const ip_addr_t *) WiFi.gatewayIP()}; }
|
||||
|
||||
@@ -412,6 +412,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
}
|
||||
void WiFiComponent::wifi_scan_done_callback_() {
|
||||
this->scan_result_.clear();
|
||||
this->scan_done_ = true;
|
||||
|
||||
int16_t num = WiFi.scanComplete();
|
||||
if (num < 0)
|
||||
@@ -430,7 +431,6 @@ void WiFiComponent::wifi_scan_done_callback_() {
|
||||
ssid.length() == 0);
|
||||
}
|
||||
WiFi.scanDelete();
|
||||
this->scan_done_ = true;
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP
|
||||
|
||||
@@ -740,9 +740,10 @@ def has_at_most_one_key(*keys):
|
||||
if not isinstance(obj, dict):
|
||||
raise Invalid("expected dictionary")
|
||||
|
||||
number = sum(k in keys for k in obj)
|
||||
if number > 1:
|
||||
raise Invalid(f"Cannot specify more than one of {', '.join(keys)}.")
|
||||
used = set(obj) & set(keys)
|
||||
if len(used) > 1:
|
||||
msg = "Cannot specify more than one of '" + "', '".join(used) + "'."
|
||||
raise MultipleInvalid([Invalid(msg, path=[k]) for k in used])
|
||||
return obj
|
||||
|
||||
return validate
|
||||
|
||||
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2025.11.0b3"
|
||||
__version__ = "2025.11.5"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
@@ -336,6 +336,7 @@ CONF_ENERGY = "energy"
|
||||
CONF_ENTITY_CATEGORY = "entity_category"
|
||||
CONF_ENTITY_ID = "entity_id"
|
||||
CONF_ENUM_DATAPOINT = "enum_datapoint"
|
||||
CONF_ENVIRONMENT_VARIABLES = "environment_variables"
|
||||
CONF_EQUATION = "equation"
|
||||
CONF_ESP8266_DISABLE_SSL_SUPPORT = "esp8266_disable_ssl_support"
|
||||
CONF_ESPHOME = "esphome"
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <list>
|
||||
#include <vector>
|
||||
#include <forward_list>
|
||||
|
||||
namespace esphome {
|
||||
|
||||
@@ -433,9 +433,10 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
|
||||
// Store for later processing
|
||||
auto now = millis();
|
||||
auto timeout = this->timeout_value_.optional_value(x...);
|
||||
this->var_queue_.emplace_front(now, timeout, std::make_tuple(x...));
|
||||
this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...));
|
||||
|
||||
// Do immediate check with fresh timestamp
|
||||
// Do immediate check with fresh timestamp - don't call loop() synchronously!
|
||||
// Let the event loop call it to avoid reentrancy issues
|
||||
if (this->process_queue_(now)) {
|
||||
// Only enable loop if we still have pending items
|
||||
this->enable_loop();
|
||||
@@ -487,7 +488,7 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
|
||||
}
|
||||
|
||||
Condition<Ts...> *condition_;
|
||||
std::forward_list<std::tuple<uint32_t, optional<uint32_t>, std::tuple<Ts...>>> var_queue_{};
|
||||
std::list<std::tuple<uint32_t, optional<uint32_t>, std::tuple<Ts...>>> var_queue_{};
|
||||
};
|
||||
|
||||
template<typename... Ts> class UpdateComponentAction : public Action<Ts...> {
|
||||
|
||||
@@ -17,6 +17,7 @@ from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_DEBUG_SCHEDULER,
|
||||
CONF_DEVICES,
|
||||
CONF_ENVIRONMENT_VARIABLES,
|
||||
CONF_ESPHOME,
|
||||
CONF_FRIENDLY_NAME,
|
||||
CONF_ID,
|
||||
@@ -215,6 +216,11 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.string_strict: cv.Any([cv.string], cv.string),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema(
|
||||
{
|
||||
cv.string_strict: cv.string,
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_BOOT): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger),
|
||||
@@ -426,6 +432,12 @@ async def _add_platformio_options(pio_options):
|
||||
cg.add_platformio_option(key, val)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _add_environment_variables(env_vars: dict[str, str]) -> None:
|
||||
# Set environment variables for the build process
|
||||
os.environ.update(env_vars)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.AUTOMATION)
|
||||
async def _add_automations(config):
|
||||
for conf in config.get(CONF_ON_BOOT, []):
|
||||
@@ -563,6 +575,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config[CONF_PLATFORMIO_OPTIONS]:
|
||||
CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS])
|
||||
|
||||
if config[CONF_ENVIRONMENT_VARIABLES]:
|
||||
CORE.add_job(_add_environment_variables, config[CONF_ENVIRONMENT_VARIABLES])
|
||||
|
||||
# Process areas
|
||||
all_areas: list[dict[str, str | core.ID]] = []
|
||||
if CONF_AREA in config:
|
||||
|
||||
@@ -202,7 +202,7 @@ template<typename T> class StatefulEntityBase : public EntityBase {
|
||||
virtual bool has_state() const { return this->state_.has_value(); }
|
||||
virtual const T &get_state() const { return this->state_.value(); }
|
||||
virtual T get_state_default(T default_value) const { return this->state_.value_or(default_value); }
|
||||
void invalidate_state() { this->set_state_({}); }
|
||||
void invalidate_state() { this->set_new_state({}); }
|
||||
|
||||
void add_full_state_callback(std::function<void(optional<T> previous, optional<T> current)> &&callback) {
|
||||
if (this->full_state_callbacks_ == nullptr)
|
||||
@@ -224,20 +224,20 @@ template<typename T> class StatefulEntityBase : public EntityBase {
|
||||
/**
|
||||
* Set a new state for this entity. This will trigger callbacks only if the new state is different from the previous.
|
||||
*
|
||||
* @param state The new state.
|
||||
* @param new_state The new state.
|
||||
* @return True if the state was changed, false if it was the same as before.
|
||||
*/
|
||||
bool set_state_(const optional<T> &state) {
|
||||
if (this->state_ != state) {
|
||||
virtual bool set_new_state(const optional<T> &new_state) {
|
||||
if (this->state_ != new_state) {
|
||||
// call the full state callbacks with the previous and new state
|
||||
if (this->full_state_callbacks_ != nullptr)
|
||||
this->full_state_callbacks_->call(this->state_, state);
|
||||
this->full_state_callbacks_->call(this->state_, new_state);
|
||||
// trigger legacy callbacks only if the new state is valid and either the trigger on initial state is enabled or
|
||||
// the previous state was valid
|
||||
auto had_state = this->has_state();
|
||||
this->state_ = state;
|
||||
if (this->state_callbacks_ != nullptr && state.has_value() && (this->trigger_on_initial_state_ || had_state))
|
||||
this->state_callbacks_->call(state.value());
|
||||
this->state_ = new_state;
|
||||
if (this->state_callbacks_ != nullptr && new_state.has_value() && (this->trigger_on_initial_state_ || had_state))
|
||||
this->state_callbacks_->call(new_state.value());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -225,6 +225,9 @@ template<typename T> class FixedVector {
|
||||
other.reset_();
|
||||
}
|
||||
|
||||
// Allow conversion to std::vector
|
||||
operator std::vector<T>() const { return {data_, data_ + size_}; }
|
||||
|
||||
FixedVector &operator=(FixedVector &&other) noexcept {
|
||||
if (this != &other) {
|
||||
// Delete our current data
|
||||
|
||||
@@ -154,8 +154,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
|
||||
|
||||
// For retries, check if there's a cancelled timeout first
|
||||
if (is_retry && name_cstr != nullptr && type == SchedulerItem::TIMEOUT &&
|
||||
(has_cancelled_timeout_in_container_(this->items_, component, name_cstr, /* match_retry= */ true) ||
|
||||
has_cancelled_timeout_in_container_(this->to_add_, component, name_cstr, /* match_retry= */ true))) {
|
||||
(has_cancelled_timeout_in_container_locked_(this->items_, component, name_cstr, /* match_retry= */ true) ||
|
||||
has_cancelled_timeout_in_container_locked_(this->to_add_, component, name_cstr, /* match_retry= */ true))) {
|
||||
// Skip scheduling - the retry was cancelled
|
||||
#ifdef ESPHOME_DEBUG_SCHEDULER
|
||||
ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", name_cstr);
|
||||
@@ -315,7 +315,7 @@ void Scheduler::full_cleanup_removed_items_() {
|
||||
valid_items.push_back(std::move(item));
|
||||
} else {
|
||||
// Recycle removed items
|
||||
this->recycle_item_(std::move(item));
|
||||
this->recycle_item_main_loop_(std::move(item));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,8 +359,7 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
std::unique_ptr<SchedulerItem> item;
|
||||
{
|
||||
LockGuard guard{this->lock_};
|
||||
item = std::move(this->items_[0]);
|
||||
this->pop_raw_();
|
||||
item = this->pop_raw_locked_();
|
||||
}
|
||||
|
||||
const char *name = item->get_name();
|
||||
@@ -401,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
// Don't run on failed components
|
||||
if (item->component != nullptr && item->component->is_failed()) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->pop_raw_();
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -414,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
{
|
||||
LockGuard guard{this->lock_};
|
||||
if (is_item_removed_(item.get())) {
|
||||
this->pop_raw_();
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
this->to_remove_--;
|
||||
continue;
|
||||
}
|
||||
@@ -423,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
// Single-threaded or multi-threaded with atomics: can check without lock
|
||||
if (is_item_removed_(item.get())) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->pop_raw_();
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
this->to_remove_--;
|
||||
continue;
|
||||
}
|
||||
@@ -443,14 +442,14 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
|
||||
LockGuard guard{this->lock_};
|
||||
|
||||
auto executed_item = std::move(this->items_[0]);
|
||||
// Only pop after function call, this ensures we were reachable
|
||||
// during the function call and know if we were cancelled.
|
||||
this->pop_raw_();
|
||||
auto executed_item = this->pop_raw_locked_();
|
||||
|
||||
if (executed_item->remove) {
|
||||
// We were removed/cancelled in the function call, stop
|
||||
// We were removed/cancelled in the function call, recycle and continue
|
||||
this->to_remove_--;
|
||||
this->recycle_item_main_loop_(std::move(executed_item));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -461,7 +460,7 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
this->to_add_.push_back(std::move(executed_item));
|
||||
} else {
|
||||
// Timeout completed - recycle it
|
||||
this->recycle_item_(std::move(executed_item));
|
||||
this->recycle_item_main_loop_(std::move(executed_item));
|
||||
}
|
||||
|
||||
has_added_items |= !this->to_add_.empty();
|
||||
@@ -476,7 +475,7 @@ void HOT Scheduler::process_to_add() {
|
||||
for (auto &it : this->to_add_) {
|
||||
if (is_item_removed_(it.get())) {
|
||||
// Recycle cancelled items
|
||||
this->recycle_item_(std::move(it));
|
||||
this->recycle_item_main_loop_(std::move(it));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -497,7 +496,7 @@ size_t HOT Scheduler::cleanup_() {
|
||||
return this->items_.size();
|
||||
|
||||
// We must hold the lock for the entire cleanup operation because:
|
||||
// 1. We're modifying items_ (via pop_raw_) which requires exclusive access
|
||||
// 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access
|
||||
// 2. We're decrementing to_remove_ which is also modified by other threads
|
||||
// (though all modifications are already under lock)
|
||||
// 3. Other threads read items_ when searching for items to cancel in cancel_item_locked_()
|
||||
@@ -510,17 +509,18 @@ size_t HOT Scheduler::cleanup_() {
|
||||
if (!item->remove)
|
||||
break;
|
||||
this->to_remove_--;
|
||||
this->pop_raw_();
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
}
|
||||
return this->items_.size();
|
||||
}
|
||||
void HOT Scheduler::pop_raw_() {
|
||||
std::unique_ptr<Scheduler::SchedulerItem> HOT Scheduler::pop_raw_locked_() {
|
||||
std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
|
||||
|
||||
// Instead of destroying, recycle the item
|
||||
this->recycle_item_(std::move(this->items_.back()));
|
||||
// Move the item out before popping - this is the item that was at the front of the heap
|
||||
auto item = std::move(this->items_.back());
|
||||
|
||||
this->items_.pop_back();
|
||||
return item;
|
||||
}
|
||||
|
||||
// Helper to execute a scheduler item
|
||||
@@ -556,28 +556,25 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c
|
||||
#ifndef ESPHOME_THREAD_SINGLE
|
||||
// Mark items in defer queue as cancelled (they'll be skipped when processed)
|
||||
if (type == SchedulerItem::TIMEOUT) {
|
||||
total_cancelled += this->mark_matching_items_removed_(this->defer_queue_, component, name_cstr, type, match_retry);
|
||||
total_cancelled +=
|
||||
this->mark_matching_items_removed_locked_(this->defer_queue_, component, name_cstr, type, match_retry);
|
||||
}
|
||||
#endif /* not ESPHOME_THREAD_SINGLE */
|
||||
|
||||
// Cancel items in the main heap
|
||||
// Special case: if the last item in the heap matches, we can remove it immediately
|
||||
// (removing the last element doesn't break heap structure)
|
||||
// We only mark items for removal here - never recycle directly.
|
||||
// The main loop may be executing an item's callback right now, and recycling
|
||||
// would destroy the callback while it's running (use-after-free).
|
||||
// Only the main loop in call() should recycle items after execution completes.
|
||||
if (!this->items_.empty()) {
|
||||
auto &last_item = this->items_.back();
|
||||
if (this->matches_item_(last_item, component, name_cstr, type, match_retry)) {
|
||||
this->recycle_item_(std::move(this->items_.back()));
|
||||
this->items_.pop_back();
|
||||
total_cancelled++;
|
||||
}
|
||||
// For other items in heap, we can only mark for removal (can't remove from middle of heap)
|
||||
size_t heap_cancelled = this->mark_matching_items_removed_(this->items_, component, name_cstr, type, match_retry);
|
||||
size_t heap_cancelled =
|
||||
this->mark_matching_items_removed_locked_(this->items_, component, name_cstr, type, match_retry);
|
||||
total_cancelled += heap_cancelled;
|
||||
this->to_remove_ += heap_cancelled; // Track removals for heap items
|
||||
this->to_remove_ += heap_cancelled;
|
||||
}
|
||||
|
||||
// Cancel items in to_add_
|
||||
total_cancelled += this->mark_matching_items_removed_(this->to_add_, component, name_cstr, type, match_retry);
|
||||
total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_cstr, type, match_retry);
|
||||
|
||||
return total_cancelled > 0;
|
||||
}
|
||||
@@ -747,7 +744,11 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr<SchedulerItem> &a,
|
||||
: (a->next_execution_high_ > b->next_execution_high_);
|
||||
}
|
||||
|
||||
void Scheduler::recycle_item_(std::unique_ptr<SchedulerItem> item) {
|
||||
// Recycle a SchedulerItem back to the pool for reuse.
|
||||
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
|
||||
// This protects scheduler_item_pool_ from concurrent access by other threads
|
||||
// that may be acquiring items from the pool in set_timer_common_().
|
||||
void Scheduler::recycle_item_main_loop_(std::unique_ptr<SchedulerItem> item) {
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
|
||||
@@ -219,7 +219,9 @@ class Scheduler {
|
||||
// Returns the number of items remaining after cleanup
|
||||
// IMPORTANT: This method should only be called from the main thread (loop task).
|
||||
size_t cleanup_();
|
||||
void pop_raw_();
|
||||
// Remove and return the front item from the heap
|
||||
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
|
||||
std::unique_ptr<SchedulerItem> pop_raw_locked_();
|
||||
|
||||
private:
|
||||
// Helper to cancel items by name - must be called with lock held
|
||||
@@ -243,8 +245,18 @@ class Scheduler {
|
||||
}
|
||||
|
||||
// Helper function to check if item matches criteria for cancellation
|
||||
inline bool HOT matches_item_(const std::unique_ptr<SchedulerItem> &item, Component *component, const char *name_cstr,
|
||||
SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const {
|
||||
// IMPORTANT: Must be called with scheduler lock held
|
||||
inline bool HOT matches_item_locked_(const std::unique_ptr<SchedulerItem> &item, Component *component,
|
||||
const char *name_cstr, SchedulerItem::Type type, bool match_retry,
|
||||
bool skip_removed = true) const {
|
||||
// THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded
|
||||
// platforms, items can be moved out of defer_queue_ during processing, leaving nullptr entries.
|
||||
// PR #11305 added nullptr checks in callers (mark_matching_items_removed_locked_() and
|
||||
// has_cancelled_timeout_in_container_locked_()), but this check provides defense-in-depth: helper
|
||||
// functions should be safe regardless of caller behavior.
|
||||
// Fixes: https://github.com/esphome/esphome/issues/11940
|
||||
if (!item)
|
||||
return false;
|
||||
if (item->component != component || item->type != type || (skip_removed && item->remove) ||
|
||||
(match_retry && !item->is_retry)) {
|
||||
return false;
|
||||
@@ -260,8 +272,11 @@ class Scheduler {
|
||||
return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed());
|
||||
}
|
||||
|
||||
// Helper to recycle a SchedulerItem
|
||||
void recycle_item_(std::unique_ptr<SchedulerItem> item);
|
||||
// Helper to recycle a SchedulerItem back to the pool.
|
||||
// IMPORTANT: Only call from main loop context! Recycling clears the callback,
|
||||
// so calling from another thread while the callback is executing causes use-after-free.
|
||||
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
|
||||
void recycle_item_main_loop_(std::unique_ptr<SchedulerItem> item);
|
||||
|
||||
// Helper to perform full cleanup when too many items are cancelled
|
||||
void full_cleanup_removed_items_();
|
||||
@@ -304,8 +319,8 @@ class Scheduler {
|
||||
// SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_.
|
||||
// This is intentional and safe because:
|
||||
// 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function
|
||||
// 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_
|
||||
// and has_cancelled_timeout_in_container_ in scheduler.h)
|
||||
// 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_
|
||||
// and has_cancelled_timeout_in_container_locked_ in scheduler.h)
|
||||
// 3. The lock protects concurrent access, but the nullptr remains until cleanup
|
||||
item = std::move(this->defer_queue_[this->defer_queue_front_]);
|
||||
this->defer_queue_front_++;
|
||||
@@ -317,7 +332,10 @@ class Scheduler {
|
||||
now = this->execute_item_(item.get(), now);
|
||||
}
|
||||
// Recycle the defer item after execution
|
||||
this->recycle_item_(std::move(item));
|
||||
{
|
||||
LockGuard lock(this->lock_);
|
||||
this->recycle_item_main_loop_(std::move(item));
|
||||
}
|
||||
}
|
||||
|
||||
// If we've consumed all items up to the snapshot point, clean up the dead space
|
||||
@@ -393,10 +411,10 @@ class Scheduler {
|
||||
|
||||
// Helper to mark matching items in a container as removed
|
||||
// Returns the number of items marked for removal
|
||||
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
|
||||
// IMPORTANT: Must be called with scheduler lock held
|
||||
template<typename Container>
|
||||
size_t mark_matching_items_removed_(Container &container, Component *component, const char *name_cstr,
|
||||
SchedulerItem::Type type, bool match_retry) {
|
||||
size_t mark_matching_items_removed_locked_(Container &container, Component *component, const char *name_cstr,
|
||||
SchedulerItem::Type type, bool match_retry) {
|
||||
size_t count = 0;
|
||||
for (auto &item : container) {
|
||||
// Skip nullptr items (can happen in defer_queue_ when items are being processed)
|
||||
@@ -405,7 +423,7 @@ class Scheduler {
|
||||
// the vector can still contain nullptr items from the processing loop. This check prevents crashes.
|
||||
if (!item)
|
||||
continue;
|
||||
if (this->matches_item_(item, component, name_cstr, type, match_retry)) {
|
||||
if (this->matches_item_locked_(item, component, name_cstr, type, match_retry)) {
|
||||
// Mark item for removal (platform-specific)
|
||||
this->set_item_removed_(item.get(), true);
|
||||
count++;
|
||||
@@ -415,9 +433,10 @@ class Scheduler {
|
||||
}
|
||||
|
||||
// Template helper to check if any item in a container matches our criteria
|
||||
// IMPORTANT: Must be called with scheduler lock held
|
||||
template<typename Container>
|
||||
bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr,
|
||||
bool match_retry) const {
|
||||
bool has_cancelled_timeout_in_container_locked_(const Container &container, Component *component,
|
||||
const char *name_cstr, bool match_retry) const {
|
||||
for (const auto &item : container) {
|
||||
// Skip nullptr items (can happen in defer_queue_ when items are being processed)
|
||||
// The defer_queue_ uses index-based processing: items are std::moved out but left in the
|
||||
@@ -426,8 +445,8 @@ class Scheduler {
|
||||
if (!item)
|
||||
continue;
|
||||
if (is_item_removed_(item.get()) &&
|
||||
this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry,
|
||||
/* skip_removed= */ false)) {
|
||||
this->matches_item_locked_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry,
|
||||
/* skip_removed= */ false)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ def get_esphome_device_ip(
|
||||
|
||||
data = json.loads(payload)
|
||||
if "name" not in data or data["name"] != dev_name:
|
||||
_LOGGER.Warn("Wrong device answer")
|
||||
_LOGGER.warning("Wrong device answer")
|
||||
return
|
||||
|
||||
dev_ip = []
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from collections.abc import Callable
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
from types import TracebackType
|
||||
|
||||
from esphome import loader
|
||||
from esphome.config import iter_component_configs, iter_components
|
||||
@@ -121,7 +125,7 @@ def update_storage_json() -> None:
|
||||
)
|
||||
else:
|
||||
_LOGGER.info("Core config or version changed, cleaning build files...")
|
||||
clean_build()
|
||||
clean_build(clear_pio_cache=False)
|
||||
elif storage_should_update_cmake_cache(old, new):
|
||||
_LOGGER.info("Integrations changed, cleaning cmake cache...")
|
||||
clean_cmake_cache()
|
||||
@@ -301,9 +305,24 @@ def clean_cmake_cache():
|
||||
pioenvs_cmake_path.unlink()
|
||||
|
||||
|
||||
def clean_build():
|
||||
import shutil
|
||||
def _rmtree_error_handler(
|
||||
func: Callable[[str], object],
|
||||
path: str,
|
||||
exc_info: tuple[type[BaseException], BaseException, TracebackType | None],
|
||||
) -> None:
|
||||
"""Error handler for shutil.rmtree to handle read-only files on Windows.
|
||||
|
||||
On Windows, git pack files and other files may be marked read-only,
|
||||
causing shutil.rmtree to fail with "Access is denied". This handler
|
||||
removes the read-only flag and retries the deletion.
|
||||
"""
|
||||
if os.access(path, os.W_OK):
|
||||
raise exc_info[1].with_traceback(exc_info[2])
|
||||
os.chmod(path, stat.S_IWUSR | stat.S_IRUSR)
|
||||
func(path)
|
||||
|
||||
|
||||
def clean_build(clear_pio_cache: bool = True):
|
||||
# Allow skipping cache cleaning for integration tests
|
||||
if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"):
|
||||
_LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)")
|
||||
@@ -312,16 +331,19 @@ def clean_build():
|
||||
pioenvs = CORE.relative_pioenvs_path()
|
||||
if pioenvs.is_dir():
|
||||
_LOGGER.info("Deleting %s", pioenvs)
|
||||
shutil.rmtree(pioenvs)
|
||||
shutil.rmtree(pioenvs, onerror=_rmtree_error_handler)
|
||||
piolibdeps = CORE.relative_piolibdeps_path()
|
||||
if piolibdeps.is_dir():
|
||||
_LOGGER.info("Deleting %s", piolibdeps)
|
||||
shutil.rmtree(piolibdeps)
|
||||
shutil.rmtree(piolibdeps, onerror=_rmtree_error_handler)
|
||||
dependencies_lock = CORE.relative_build_path("dependencies.lock")
|
||||
if dependencies_lock.is_file():
|
||||
_LOGGER.info("Deleting %s", dependencies_lock)
|
||||
dependencies_lock.unlink()
|
||||
|
||||
if not clear_pio_cache:
|
||||
return
|
||||
|
||||
# Clean PlatformIO cache to resolve CMake compiler detection issues
|
||||
# This helps when toolchain paths change or get corrupted
|
||||
try:
|
||||
@@ -334,13 +356,17 @@ def clean_build():
|
||||
cache_dir = Path(config.get("platformio", "cache_dir"))
|
||||
if cache_dir.is_dir():
|
||||
_LOGGER.info("Deleting PlatformIO cache %s", cache_dir)
|
||||
shutil.rmtree(cache_dir)
|
||||
shutil.rmtree(cache_dir, onerror=_rmtree_error_handler)
|
||||
|
||||
|
||||
def clean_all(configuration: list[str]):
|
||||
import shutil
|
||||
|
||||
data_dirs = [Path(dir) / ".esphome" for dir in configuration]
|
||||
data_dirs = []
|
||||
for config in configuration:
|
||||
item = Path(config)
|
||||
if item.is_file() and item.suffix in (".yaml", ".yml"):
|
||||
data_dirs.append(item.parent / ".esphome")
|
||||
else:
|
||||
data_dirs.append(item / ".esphome")
|
||||
if is_ha_addon():
|
||||
data_dirs.append(Path("/data"))
|
||||
if "ESPHOME_DATA_DIR" in os.environ:
|
||||
@@ -355,7 +381,7 @@ def clean_all(configuration: list[str]):
|
||||
if item.is_file() and not item.name.endswith(".json"):
|
||||
item.unlink()
|
||||
elif item.is_dir() and item.name != "storage":
|
||||
shutil.rmtree(item)
|
||||
shutil.rmtree(item, onerror=_rmtree_error_handler)
|
||||
|
||||
# Clean PlatformIO project files
|
||||
try:
|
||||
@@ -369,7 +395,7 @@ def clean_all(configuration: list[str]):
|
||||
path = Path(config.get("platformio", pio_dir))
|
||||
if path.is_dir():
|
||||
_LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path)
|
||||
shutil.rmtree(path)
|
||||
shutil.rmtree(path, onerror=_rmtree_error_handler)
|
||||
|
||||
|
||||
GITIGNORE_CONTENT = """# Gitignore settings for ESPHome
|
||||
|
||||
@@ -2,6 +2,9 @@ esphome:
|
||||
debug_scheduler: true
|
||||
platformio_options:
|
||||
board_build.flash_mode: dio
|
||||
environment_variables:
|
||||
TEST_ENV_VAR: "test_value"
|
||||
BUILD_NUMBER: "12345"
|
||||
area:
|
||||
id: testing_area
|
||||
name: Testing Area
|
||||
|
||||
@@ -115,8 +115,8 @@ wifi:
|
||||
password: PASSWORD123
|
||||
|
||||
time:
|
||||
platform: sntp
|
||||
id: time_id
|
||||
- platform: sntp
|
||||
id: sntp_time
|
||||
|
||||
text:
|
||||
- id: lvgl_text
|
||||
|
||||
@@ -478,19 +478,19 @@ lvgl:
|
||||
id: hello_label
|
||||
text:
|
||||
time_format: "%c"
|
||||
time: time_id
|
||||
time: sntp_time
|
||||
- lvgl.label.update:
|
||||
id: hello_label
|
||||
text:
|
||||
time_format: "%c"
|
||||
time: !lambda return id(time_id).now();
|
||||
time: !lambda return id(sntp_time).now();
|
||||
- lvgl.label.update:
|
||||
id: hello_label
|
||||
text:
|
||||
time_format: "%c"
|
||||
time: !lambda |-
|
||||
ESP_LOGD("label", "multi-line lambda");
|
||||
return id(time_id).now();
|
||||
return id(sntp_time).now();
|
||||
on_value:
|
||||
logger.log:
|
||||
format: "state now %d"
|
||||
@@ -703,7 +703,9 @@ lvgl:
|
||||
on_value:
|
||||
- lvgl.spinbox.update:
|
||||
id: spinbox_id
|
||||
value: !lambda return x;
|
||||
value: !lambda |-
|
||||
static float yyy = 83.0;
|
||||
return yyy + .8;
|
||||
- button:
|
||||
styles: spin_button
|
||||
id: spin_up
|
||||
@@ -879,6 +881,7 @@ lvgl:
|
||||
grid_columns: [40, fr(1), fr(1)]
|
||||
pad_row: 6px
|
||||
pad_column: 0
|
||||
multiple_widgets_per_cell: true
|
||||
widgets:
|
||||
- image:
|
||||
grid_cell_row_pos: 0
|
||||
@@ -903,6 +906,10 @@ lvgl:
|
||||
grid_cell_row_pos: 1
|
||||
grid_cell_column_pos: 0
|
||||
text: "Grid cell 1/0"
|
||||
- label:
|
||||
grid_cell_row_pos: 1
|
||||
grid_cell_column_pos: 0
|
||||
text: "Duplicate for 1/0"
|
||||
- label:
|
||||
styles: bdr_style
|
||||
grid_cell_row_pos: 1
|
||||
|
||||
@@ -4,6 +4,7 @@ wifi:
|
||||
|
||||
time:
|
||||
- platform: sntp
|
||||
id: sntp_time
|
||||
|
||||
mqtt:
|
||||
broker: "192.168.178.84"
|
||||
|
||||
@@ -3,6 +3,7 @@ wifi:
|
||||
|
||||
time:
|
||||
- platform: sntp
|
||||
id: sntp_time
|
||||
|
||||
sensor:
|
||||
- platform: uptime
|
||||
|
||||
@@ -4,8 +4,10 @@ wifi:
|
||||
|
||||
time:
|
||||
- platform: sntp
|
||||
id: sntp_time
|
||||
|
||||
wireguard:
|
||||
time_id: sntp_time
|
||||
address: 172.16.34.100
|
||||
netmask: 255.255.255.0
|
||||
# NEVER use the following keys for your VPN -- they are now public!
|
||||
|
||||
@@ -7,7 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te
|
||||
- `conftest.py` - Common fixtures and utilities
|
||||
- `const.py` - Constants used throughout the integration tests
|
||||
- `types.py` - Type definitions for fixtures and functions
|
||||
- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `build_key_to_entity_mapping`)
|
||||
- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `find_entity`, `require_entity`)
|
||||
- `fixtures/` - YAML configuration files for tests
|
||||
- `test_*.py` - Individual test files
|
||||
|
||||
@@ -53,6 +53,28 @@ The `InitialStateHelper` class solves a common problem in integration tests: whe
|
||||
**Future work:**
|
||||
Consider converting existing integration tests to use `InitialStateHelper` for more reliable state tracking and to eliminate race conditions related to initial state broadcasts.
|
||||
|
||||
#### Entity Lookup Helpers (`state_utils.py`)
|
||||
|
||||
Two helper functions simplify finding entities in test code:
|
||||
|
||||
**`find_entity(entities, object_id_substring, entity_type=None)`**
|
||||
- Finds an entity by searching for a substring in its `object_id` (case-insensitive)
|
||||
- Optionally filters by entity type (e.g., `BinarySensorInfo`)
|
||||
- Returns `None` if not found
|
||||
|
||||
**`require_entity(entities, object_id_substring, entity_type=None, description=None)`**
|
||||
- Same as `find_entity` but raises `AssertionError` if not found
|
||||
- Use `description` parameter for clearer error messages
|
||||
|
||||
```python
|
||||
from aioesphomeapi import BinarySensorInfo
|
||||
from .state_utils import require_entity
|
||||
|
||||
# Find entities with clear error messages
|
||||
binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo)
|
||||
button = require_entity(entities, "set_true", description="Set True button")
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
The simplest way to write a test is to use the `run_compiled` and `api_client_connected` fixtures:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
esphome:
|
||||
name: test-binary-sensor-invalidate
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
# Template binary sensor that we can control
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: "Test Binary Sensor"
|
||||
id: test_binary_sensor
|
||||
|
||||
# Buttons to control the binary sensor state
|
||||
button:
|
||||
- platform: template
|
||||
name: "Set True"
|
||||
id: set_true_button
|
||||
on_press:
|
||||
- binary_sensor.template.publish:
|
||||
id: test_binary_sensor
|
||||
state: true
|
||||
|
||||
- platform: template
|
||||
name: "Set False"
|
||||
id: set_false_button
|
||||
on_press:
|
||||
- binary_sensor.template.publish:
|
||||
id: test_binary_sensor
|
||||
state: false
|
||||
|
||||
- platform: template
|
||||
name: "Invalidate State"
|
||||
id: invalidate_button
|
||||
on_press:
|
||||
- binary_sensor.invalidate_state:
|
||||
id: test_binary_sensor
|
||||
131
tests/integration/fixtures/script_delay_with_params.yaml
Normal file
131
tests/integration/fixtures/script_delay_with_params.yaml
Normal file
@@ -0,0 +1,131 @@
|
||||
esphome:
|
||||
name: test-script-delay-params
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
# Test case from issue #12044: parent script with repeat calling child with delay
|
||||
- action: test_repeat_with_delay
|
||||
then:
|
||||
- logger.log: "=== TEST: Repeat loop calling script with delay and parameters ==="
|
||||
- script.execute: father_script
|
||||
|
||||
# Test case from issue #12043: script.wait with delayed child script
|
||||
- action: test_script_wait
|
||||
then:
|
||||
- logger.log: "=== TEST: script.wait with delayed child script ==="
|
||||
- script.execute: show_start_page
|
||||
- script.wait: show_start_page
|
||||
- logger.log: "After wait: script completed successfully"
|
||||
|
||||
# Test: Delay with different parameter types
|
||||
- action: test_delay_param_types
|
||||
then:
|
||||
- logger.log: "=== TEST: Delay with various parameter types ==="
|
||||
- script.execute:
|
||||
id: delay_with_int
|
||||
val: 42
|
||||
- delay: 50ms
|
||||
- script.execute:
|
||||
id: delay_with_string
|
||||
msg: "test message"
|
||||
- delay: 50ms
|
||||
- script.execute:
|
||||
id: delay_with_float
|
||||
num: 3.14
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
script:
|
||||
# Reproduces issue #12044: child script with conditional delay
|
||||
- id: son_script
|
||||
mode: single
|
||||
parameters:
|
||||
iteration: int
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Son script started with iteration %d"
|
||||
args: ['iteration']
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return iteration >= 5;'
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Son script delaying for iteration %d"
|
||||
args: ['iteration']
|
||||
- delay: 100ms
|
||||
- logger.log:
|
||||
format: "Son script finished with iteration %d"
|
||||
args: ['iteration']
|
||||
|
||||
# Reproduces issue #12044: parent script with repeat loop
|
||||
- id: father_script
|
||||
mode: single
|
||||
then:
|
||||
- repeat:
|
||||
count: 10
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Father iteration %d: calling son"
|
||||
args: ['iteration']
|
||||
- script.execute:
|
||||
id: son_script
|
||||
iteration: !lambda 'return iteration;'
|
||||
- script.wait: son_script
|
||||
- logger.log:
|
||||
format: "Father iteration %d: son finished, wait returned"
|
||||
args: ['iteration']
|
||||
|
||||
# Reproduces issue #12043: script.wait hangs
|
||||
- id: show_start_page
|
||||
mode: single
|
||||
then:
|
||||
- logger.log: "Start page: beginning"
|
||||
- delay: 100ms
|
||||
- logger.log: "Start page: after delay"
|
||||
- delay: 100ms
|
||||
- logger.log: "Start page: completed"
|
||||
|
||||
# Test delay with int parameter
|
||||
- id: delay_with_int
|
||||
mode: single
|
||||
parameters:
|
||||
val: int
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Int test: before delay, val=%d"
|
||||
args: ['val']
|
||||
- delay: 50ms
|
||||
- logger.log:
|
||||
format: "Int test: after delay, val=%d"
|
||||
args: ['val']
|
||||
|
||||
# Test delay with string parameter
|
||||
- id: delay_with_string
|
||||
mode: single
|
||||
parameters:
|
||||
msg: string
|
||||
then:
|
||||
- logger.log:
|
||||
format: "String test: before delay, msg=%s"
|
||||
args: ['msg.c_str()']
|
||||
- delay: 50ms
|
||||
- logger.log:
|
||||
format: "String test: after delay, msg=%s"
|
||||
args: ['msg.c_str()']
|
||||
|
||||
# Test delay with float parameter
|
||||
- id: delay_with_float
|
||||
mode: single
|
||||
parameters:
|
||||
num: float
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Float test: before delay, num=%.2f"
|
||||
args: ['num']
|
||||
- delay: 50ms
|
||||
- logger.log:
|
||||
format: "Float test: after delay, num=%.2f"
|
||||
args: ['num']
|
||||
54
tests/integration/fixtures/script_wait_on_boot.yaml
Normal file
54
tests/integration/fixtures/script_wait_on_boot.yaml
Normal file
@@ -0,0 +1,54 @@
|
||||
esphome:
|
||||
name: test-script-wait-on-boot
|
||||
on_boot:
|
||||
# Use default priority (600.0) which is same as ScriptWaitAction's setup priority
|
||||
# This tests the race condition where on_boot runs before ScriptWaitAction::setup()
|
||||
then:
|
||||
- logger.log: "=== on_boot: Starting boot sequence ==="
|
||||
- script.execute: show_start_page
|
||||
- script.wait: show_start_page
|
||||
- logger.log: "=== on_boot: First script completed, starting second ==="
|
||||
- script.execute: flip_thru_pages
|
||||
- script.wait: flip_thru_pages
|
||||
- logger.log: "=== on_boot: All boot scripts completed successfully ==="
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
# Manual trigger for additional testing
|
||||
- action: test_script_wait
|
||||
then:
|
||||
- logger.log: "=== Manual test: Starting ==="
|
||||
- script.execute: show_start_page
|
||||
- script.wait: show_start_page
|
||||
- logger.log: "=== Manual test: First script completed ==="
|
||||
- script.execute: flip_thru_pages
|
||||
- script.wait: flip_thru_pages
|
||||
- logger.log: "=== Manual test: All completed ==="
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
script:
|
||||
# First script - simulates display initialization
|
||||
- id: show_start_page
|
||||
mode: single
|
||||
then:
|
||||
- logger.log: "show_start_page: Starting"
|
||||
- delay: 100ms
|
||||
- logger.log: "show_start_page: After delay 1"
|
||||
- delay: 100ms
|
||||
- logger.log: "show_start_page: Completed"
|
||||
|
||||
# Second script - simulates page flip sequence
|
||||
- id: flip_thru_pages
|
||||
mode: single
|
||||
then:
|
||||
- logger.log: "flip_thru_pages: Starting"
|
||||
- delay: 50ms
|
||||
- logger.log: "flip_thru_pages: Page 1"
|
||||
- delay: 50ms
|
||||
- logger.log: "flip_thru_pages: Page 2"
|
||||
- delay: 50ms
|
||||
- logger.log: "flip_thru_pages: Completed"
|
||||
82
tests/integration/fixtures/wait_until_fifo_ordering.yaml
Normal file
82
tests/integration/fixtures/wait_until_fifo_ordering.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
esphome:
|
||||
name: test-wait-until-ordering
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: test_wait_until_fifo
|
||||
then:
|
||||
- logger.log: "=== TEST: wait_until should execute in FIFO order ==="
|
||||
- globals.set:
|
||||
id: gate_open
|
||||
value: 'false'
|
||||
- delay: 100ms
|
||||
# Start multiple parallel executions of coordinator script
|
||||
# Each will call the shared waiter script, queueing in same wait_until
|
||||
- script.execute: coordinator_0
|
||||
- script.execute: coordinator_1
|
||||
- script.execute: coordinator_2
|
||||
- script.execute: coordinator_3
|
||||
- script.execute: coordinator_4
|
||||
# Give scripts time to reach wait_until and queue
|
||||
- delay: 200ms
|
||||
- logger.log: "Opening gate - all wait_until should complete now"
|
||||
- globals.set:
|
||||
id: gate_open
|
||||
value: 'true'
|
||||
- delay: 500ms
|
||||
- logger.log: "Test complete"
|
||||
|
||||
globals:
|
||||
- id: gate_open
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
|
||||
script:
|
||||
# Shared waiter with single wait_until action (all coordinators call this)
|
||||
- id: waiter
|
||||
mode: parallel
|
||||
parameters:
|
||||
iter: int
|
||||
then:
|
||||
- lambda: 'ESP_LOGD("main", "Queueing iteration %d", iter);'
|
||||
- wait_until:
|
||||
condition:
|
||||
lambda: 'return id(gate_open);'
|
||||
timeout: 5s
|
||||
- lambda: 'ESP_LOGD("main", "Completed iteration %d", iter);'
|
||||
|
||||
# Coordinator scripts - each calls shared waiter with different iteration number
|
||||
- id: coordinator_0
|
||||
then:
|
||||
- script.execute:
|
||||
id: waiter
|
||||
iter: 0
|
||||
|
||||
- id: coordinator_1
|
||||
then:
|
||||
- script.execute:
|
||||
id: waiter
|
||||
iter: 1
|
||||
|
||||
- id: coordinator_2
|
||||
then:
|
||||
- script.execute:
|
||||
id: waiter
|
||||
iter: 2
|
||||
|
||||
- id: coordinator_3
|
||||
then:
|
||||
- script.execute:
|
||||
id: waiter
|
||||
iter: 3
|
||||
|
||||
- id: coordinator_4
|
||||
then:
|
||||
- script.execute:
|
||||
id: waiter
|
||||
iter: 4
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -4,11 +4,74 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TypeVar
|
||||
|
||||
from aioesphomeapi import ButtonInfo, EntityInfo, EntityState
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T", bound=EntityInfo)
|
||||
|
||||
|
||||
def find_entity(
|
||||
entities: list[EntityInfo],
|
||||
object_id_substring: str,
|
||||
entity_type: type[T] | None = None,
|
||||
) -> T | EntityInfo | None:
|
||||
"""Find an entity by object_id substring and optionally by type.
|
||||
|
||||
Args:
|
||||
entities: List of entity info objects from the API
|
||||
object_id_substring: Substring to search for in object_id (case-insensitive)
|
||||
entity_type: Optional entity type to filter by (e.g., BinarySensorInfo)
|
||||
|
||||
Returns:
|
||||
The first matching entity, or None if not found
|
||||
|
||||
Example:
|
||||
binary_sensor = find_entity(entities, "test_binary_sensor", BinarySensorInfo)
|
||||
button = find_entity(entities, "set_true") # Any entity type
|
||||
"""
|
||||
substring_lower = object_id_substring.lower()
|
||||
for entity in entities:
|
||||
if substring_lower in entity.object_id.lower() and (
|
||||
entity_type is None or isinstance(entity, entity_type)
|
||||
):
|
||||
return entity
|
||||
return None
|
||||
|
||||
|
||||
def require_entity(
|
||||
entities: list[EntityInfo],
|
||||
object_id_substring: str,
|
||||
entity_type: type[T] | None = None,
|
||||
description: str | None = None,
|
||||
) -> T | EntityInfo:
|
||||
"""Find an entity or raise AssertionError if not found.
|
||||
|
||||
Args:
|
||||
entities: List of entity info objects from the API
|
||||
object_id_substring: Substring to search for in object_id (case-insensitive)
|
||||
entity_type: Optional entity type to filter by (e.g., BinarySensorInfo)
|
||||
description: Human-readable description for error message
|
||||
|
||||
Returns:
|
||||
The first matching entity
|
||||
|
||||
Raises:
|
||||
AssertionError: If no matching entity is found
|
||||
|
||||
Example:
|
||||
binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo)
|
||||
button = require_entity(entities, "set_true", description="Set True button")
|
||||
"""
|
||||
entity = find_entity(entities, object_id_substring, entity_type)
|
||||
if entity is None:
|
||||
desc = description or f"entity with '{object_id_substring}' in object_id"
|
||||
type_info = f" of type {entity_type.__name__}" if entity_type else ""
|
||||
raise AssertionError(f"{desc}{type_info} not found in entities")
|
||||
return entity
|
||||
|
||||
|
||||
def build_key_to_entity_mapping(
|
||||
entities: list[EntityInfo], entity_names: list[str]
|
||||
|
||||
138
tests/integration/test_binary_sensor_invalidate_state.py
Normal file
138
tests/integration/test_binary_sensor_invalidate_state.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Integration test for binary_sensor.invalidate_state() functionality.
|
||||
|
||||
This tests the fix in PR #12296 where invalidate_state() was not properly
|
||||
reporting the 'unknown' state to the API. The binary sensor should report
|
||||
missing_state=True when invalidated.
|
||||
|
||||
Regression test for: https://github.com/esphome/esphome/issues/12252
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binary_sensor_invalidate_state(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that binary_sensor.invalidate_state() reports unknown to the API.
|
||||
|
||||
This verifies that:
|
||||
1. Binary sensor starts with missing_state=True (no initial state)
|
||||
2. Publishing true sets missing_state=False and state=True
|
||||
3. Publishing false sets missing_state=False and state=False
|
||||
4. Invalidating state sets missing_state=True (unknown state)
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Track state changes
|
||||
states_received: list[BinarySensorState] = []
|
||||
state_future: asyncio.Future[BinarySensorState] = loop.create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
"""Track binary sensor state changes."""
|
||||
if isinstance(state, BinarySensorState):
|
||||
states_received.append(state)
|
||||
if not state_future.done():
|
||||
state_future.set_result(state)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "test-binary-sensor-invalidate"
|
||||
|
||||
# Get entities
|
||||
entities, _ = await client.list_entities_services()
|
||||
|
||||
# Find our binary sensor and buttons using helper
|
||||
binary_sensor = require_entity(entities, "test_binary_sensor", BinarySensorInfo)
|
||||
set_true_button = require_entity(
|
||||
entities, "set_true", description="Set True button"
|
||||
)
|
||||
set_false_button = require_entity(
|
||||
entities, "set_false", description="Set False button"
|
||||
)
|
||||
invalidate_button = require_entity(
|
||||
entities, "invalidate", description="Invalidate button"
|
||||
)
|
||||
|
||||
# Set up initial state helper to handle the initial state broadcast
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for initial states
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
# Check initial state - should be missing (unknown)
|
||||
initial_state = initial_state_helper.initial_states.get(binary_sensor.key)
|
||||
assert initial_state is not None, "No initial state received for binary sensor"
|
||||
assert isinstance(initial_state, BinarySensorState)
|
||||
assert initial_state.missing_state is True, (
|
||||
f"Initial state should have missing_state=True, got {initial_state}"
|
||||
)
|
||||
|
||||
# Test 1: Set state to true
|
||||
states_received.clear()
|
||||
state_future = loop.create_future()
|
||||
client.button_command(set_true_button.key)
|
||||
|
||||
try:
|
||||
state = await asyncio.wait_for(state_future, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for state=true")
|
||||
|
||||
assert state.missing_state is False, (
|
||||
f"After setting true, missing_state should be False, got {state}"
|
||||
)
|
||||
assert state.state is True, f"Expected state=True, got {state}"
|
||||
|
||||
# Test 2: Set state to false
|
||||
states_received.clear()
|
||||
state_future = loop.create_future()
|
||||
client.button_command(set_false_button.key)
|
||||
|
||||
try:
|
||||
state = await asyncio.wait_for(state_future, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for state=false")
|
||||
|
||||
assert state.missing_state is False, (
|
||||
f"After setting false, missing_state should be False, got {state}"
|
||||
)
|
||||
assert state.state is False, f"Expected state=False, got {state}"
|
||||
|
||||
# Test 3: Invalidate state (set to unknown)
|
||||
# This is the critical test for the bug fix
|
||||
states_received.clear()
|
||||
state_future = loop.create_future()
|
||||
client.button_command(invalidate_button.key)
|
||||
|
||||
try:
|
||||
state = await asyncio.wait_for(state_future, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
"Timeout waiting for invalidated state - "
|
||||
"binary_sensor.invalidate_state() may not be reporting to the API. "
|
||||
"See issue #12252."
|
||||
)
|
||||
|
||||
assert state.missing_state is True, (
|
||||
f"After invalidate_state(), missing_state should be True (unknown), "
|
||||
f"got {state}. This is the regression from issue #12252."
|
||||
)
|
||||
121
tests/integration/test_script_delay_params.py
Normal file
121
tests/integration/test_script_delay_params.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Integration test for script.wait FIFO ordering (issues #12043, #12044).
|
||||
|
||||
This test verifies that ScriptWaitAction processes queued items in FIFO order.
|
||||
|
||||
PR #7972 introduced bugs in ScriptWaitAction:
|
||||
- Used emplace_front() causing LIFO ordering instead of FIFO
|
||||
- Called loop() synchronously causing reentrancy issues
|
||||
- Used while loop processing entire queue causing infinite loops
|
||||
|
||||
These bugs manifested as:
|
||||
- Scripts becoming "zombies" (stuck in running state)
|
||||
- script.wait hanging forever
|
||||
- Incorrect execution order
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_script_delay_with_params(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that script.wait processes queued items in FIFO order.
|
||||
|
||||
This reproduces issues #12043 and #12044 where scripts would hang or become
|
||||
zombies due to LIFO ordering bugs in ScriptWaitAction from PR #7972.
|
||||
"""
|
||||
test_complete = asyncio.Event()
|
||||
|
||||
# Patterns to match in logs
|
||||
father_calling_pattern = re.compile(r"Father iteration (\d+): calling son")
|
||||
son_started_pattern = re.compile(r"Son script started with iteration (\d+)")
|
||||
son_delaying_pattern = re.compile(r"Son script delaying for iteration (\d+)")
|
||||
son_finished_pattern = re.compile(r"Son script finished with iteration (\d+)")
|
||||
father_wait_returned_pattern = re.compile(
|
||||
r"Father iteration (\d+): son finished, wait returned"
|
||||
)
|
||||
|
||||
# Track which iterations completed
|
||||
father_calling = set()
|
||||
son_started = set()
|
||||
son_delaying = set()
|
||||
son_finished = set()
|
||||
wait_returned = set()
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
"""Check log output for expected messages."""
|
||||
if test_complete.is_set():
|
||||
return
|
||||
|
||||
if mo := father_calling_pattern.search(line):
|
||||
father_calling.add(int(mo.group(1)))
|
||||
elif mo := son_started_pattern.search(line):
|
||||
son_started.add(int(mo.group(1)))
|
||||
elif mo := son_delaying_pattern.search(line):
|
||||
son_delaying.add(int(mo.group(1)))
|
||||
elif mo := son_finished_pattern.search(line):
|
||||
son_finished.add(int(mo.group(1)))
|
||||
elif mo := father_wait_returned_pattern.search(line):
|
||||
iteration = int(mo.group(1))
|
||||
wait_returned.add(iteration)
|
||||
# Test completes when iteration 9 finishes
|
||||
if iteration == 9:
|
||||
test_complete.set()
|
||||
|
||||
# Run with log monitoring
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "test-script-delay-params"
|
||||
|
||||
# Get services
|
||||
_, services = await client.list_entities_services()
|
||||
test_service = next(
|
||||
(s for s in services if s.name == "test_repeat_with_delay"), None
|
||||
)
|
||||
assert test_service is not None, "test_repeat_with_delay service not found"
|
||||
|
||||
# Execute the test
|
||||
client.execute_service(test_service, {})
|
||||
|
||||
# Wait for test to complete (10 iterations * ~100ms each + margin)
|
||||
try:
|
||||
await asyncio.wait_for(test_complete.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Test timed out. Completed iterations: {sorted(wait_returned)}. "
|
||||
f"This likely indicates the script became a zombie (issue #12044)."
|
||||
)
|
||||
|
||||
# Verify all 10 iterations completed successfully
|
||||
expected_iterations = set(range(10))
|
||||
assert father_calling == expected_iterations, "Not all iterations started"
|
||||
assert son_started == expected_iterations, (
|
||||
"Son script not started for all iterations"
|
||||
)
|
||||
assert son_finished == expected_iterations, (
|
||||
"Son script not finished for all iterations"
|
||||
)
|
||||
assert wait_returned == expected_iterations, (
|
||||
"script.wait did not return for all iterations"
|
||||
)
|
||||
|
||||
# Verify delays were triggered for iterations >= 5
|
||||
expected_delays = set(range(5, 10))
|
||||
assert son_delaying == expected_delays, (
|
||||
"Delays not triggered for iterations >= 5"
|
||||
)
|
||||
130
tests/integration/test_script_wait_on_boot.py
Normal file
130
tests/integration/test_script_wait_on_boot.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Integration test for script.wait during on_boot (issue #12043).
|
||||
|
||||
This test verifies that script.wait works correctly when triggered from on_boot.
|
||||
The issue was that ScriptWaitAction::setup() unconditionally disabled the loop,
|
||||
even if play_complex() had already been called (from an on_boot trigger at the
|
||||
same priority level) and enabled it.
|
||||
|
||||
The race condition occurs because:
|
||||
1. on_boot's default priority is 600.0 (setup_priority::DATA)
|
||||
2. ScriptWaitAction's default setup priority is also DATA (600.0)
|
||||
3. When they have the same priority, if on_boot runs first and triggers a script,
|
||||
ScriptWaitAction::play_complex() enables the loop
|
||||
4. Then ScriptWaitAction::setup() runs and unconditionally disables the loop
|
||||
5. The wait never completes because the loop is disabled
|
||||
|
||||
The fix adds a conditional check (like WaitUntilAction has) to only disable the
|
||||
loop in setup() if num_running_ is 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_script_wait_on_boot(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that script.wait works correctly when triggered from on_boot.
|
||||
|
||||
This reproduces issue #12043 where script.wait would hang forever when
|
||||
triggered from on_boot due to a race condition in ScriptWaitAction::setup().
|
||||
"""
|
||||
test_complete = asyncio.Event()
|
||||
|
||||
# Track progress through the boot sequence
|
||||
boot_started = False
|
||||
first_script_started = False
|
||||
first_script_completed = False
|
||||
first_wait_returned = False
|
||||
second_script_started = False
|
||||
second_script_completed = False
|
||||
all_completed = False
|
||||
|
||||
# Patterns for boot sequence logs
|
||||
boot_start_pattern = re.compile(r"on_boot: Starting boot sequence")
|
||||
show_start_pattern = re.compile(r"show_start_page: Starting")
|
||||
show_complete_pattern = re.compile(r"show_start_page: Completed")
|
||||
first_wait_pattern = re.compile(r"on_boot: First script completed")
|
||||
flip_start_pattern = re.compile(r"flip_thru_pages: Starting")
|
||||
flip_complete_pattern = re.compile(r"flip_thru_pages: Completed")
|
||||
all_complete_pattern = re.compile(r"on_boot: All boot scripts completed")
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
"""Check log output for boot sequence progress."""
|
||||
nonlocal boot_started, first_script_started, first_script_completed
|
||||
nonlocal first_wait_returned, second_script_started, second_script_completed
|
||||
nonlocal all_completed
|
||||
|
||||
if boot_start_pattern.search(line):
|
||||
boot_started = True
|
||||
elif show_start_pattern.search(line):
|
||||
first_script_started = True
|
||||
elif show_complete_pattern.search(line):
|
||||
first_script_completed = True
|
||||
elif first_wait_pattern.search(line):
|
||||
first_wait_returned = True
|
||||
elif flip_start_pattern.search(line):
|
||||
second_script_started = True
|
||||
elif flip_complete_pattern.search(line):
|
||||
second_script_completed = True
|
||||
elif all_complete_pattern.search(line):
|
||||
all_completed = True
|
||||
test_complete.set()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "test-script-wait-on-boot"
|
||||
|
||||
# Wait for on_boot sequence to complete
|
||||
# The boot sequence should complete automatically
|
||||
# Timeout is generous to allow for delays in the scripts
|
||||
try:
|
||||
await asyncio.wait_for(test_complete.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
# Build a detailed error message showing where the boot sequence got stuck
|
||||
progress = []
|
||||
if boot_started:
|
||||
progress.append("boot started")
|
||||
if first_script_started:
|
||||
progress.append("show_start_page started")
|
||||
if first_script_completed:
|
||||
progress.append("show_start_page completed")
|
||||
if first_wait_returned:
|
||||
progress.append("first script.wait returned")
|
||||
if second_script_started:
|
||||
progress.append("flip_thru_pages started")
|
||||
if second_script_completed:
|
||||
progress.append("flip_thru_pages completed")
|
||||
|
||||
if not first_wait_returned and first_script_completed:
|
||||
pytest.fail(
|
||||
f"Test timed out - script.wait hung after show_start_page completed! "
|
||||
f"This is the issue #12043 bug. Progress: {', '.join(progress)}"
|
||||
)
|
||||
else:
|
||||
pytest.fail(
|
||||
f"Test timed out. Progress: {', '.join(progress) if progress else 'none'}"
|
||||
)
|
||||
|
||||
# Verify the complete boot sequence executed in order
|
||||
assert boot_started, "on_boot did not start"
|
||||
assert first_script_started, "show_start_page did not start"
|
||||
assert first_script_completed, "show_start_page did not complete"
|
||||
assert first_wait_returned, "First script.wait did not return"
|
||||
assert second_script_started, "flip_thru_pages did not start"
|
||||
assert second_script_completed, "flip_thru_pages did not complete"
|
||||
assert all_completed, "Boot sequence did not complete"
|
||||
90
tests/integration/test_wait_until_ordering.py
Normal file
90
tests/integration/test_wait_until_ordering.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Integration test for wait_until FIFO ordering.
|
||||
|
||||
This test verifies that when multiple wait_until actions are queued,
|
||||
they execute in FIFO (First In First Out) order, not LIFO.
|
||||
|
||||
PR #7972 introduced a bug where emplace_front() was used, causing
|
||||
LIFO ordering which is incorrect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_until_fifo_ordering(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that wait_until executes queued items in FIFO order.
|
||||
|
||||
With the bug (using emplace_front), the order would be 4,3,2,1,0 (LIFO).
|
||||
With the fix (using emplace_back), the order should be 0,1,2,3,4 (FIFO).
|
||||
"""
|
||||
test_complete = asyncio.Event()
|
||||
|
||||
# Track completion order
|
||||
completed_order = []
|
||||
|
||||
# Patterns to match
|
||||
queuing_pattern = re.compile(r"Queueing iteration (\d+)")
|
||||
completed_pattern = re.compile(r"Completed iteration (\d+)")
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
"""Check log output for completion order."""
|
||||
if test_complete.is_set():
|
||||
return
|
||||
|
||||
if mo := queuing_pattern.search(line):
|
||||
iteration = int(mo.group(1))
|
||||
|
||||
elif mo := completed_pattern.search(line):
|
||||
iteration = int(mo.group(1))
|
||||
completed_order.append(iteration)
|
||||
|
||||
# Test completes when all 5 have completed
|
||||
if len(completed_order) == 5:
|
||||
test_complete.set()
|
||||
|
||||
# Run with log monitoring
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "test-wait-until-ordering"
|
||||
|
||||
# Get services
|
||||
_, services = await client.list_entities_services()
|
||||
test_service = next(
|
||||
(s for s in services if s.name == "test_wait_until_fifo"), None
|
||||
)
|
||||
assert test_service is not None, "test_wait_until_fifo service not found"
|
||||
|
||||
# Execute the test
|
||||
client.execute_service(test_service, {})
|
||||
|
||||
# Wait for test to complete
|
||||
try:
|
||||
await asyncio.wait_for(test_complete.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Test timed out. Completed order: {completed_order}. "
|
||||
f"Expected 5 completions but got {len(completed_order)}."
|
||||
)
|
||||
|
||||
# Verify FIFO order
|
||||
expected_order = [0, 1, 2, 3, 4]
|
||||
assert completed_order == expected_order, (
|
||||
f"Unexpected order: {completed_order}. "
|
||||
f"Expected FIFO order: {expected_order}"
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Test writer module functionality."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -15,6 +17,7 @@ from esphome.writer import (
|
||||
CPP_INCLUDE_BEGIN,
|
||||
CPP_INCLUDE_END,
|
||||
GITIGNORE_CONTENT,
|
||||
clean_all,
|
||||
clean_build,
|
||||
clean_cmake_cache,
|
||||
storage_should_clean,
|
||||
@@ -737,6 +740,37 @@ def test_write_cpp_with_duplicate_markers(
|
||||
write_cpp("// New code")
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_all_with_yaml_file(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test clean_all with a .yaml file uses parent directory."""
|
||||
# Create config directory with yaml file
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
yaml_file = config_dir / "test.yaml"
|
||||
yaml_file.write_text("esphome:\n name: test\n")
|
||||
|
||||
build_dir = config_dir / ".esphome"
|
||||
build_dir.mkdir()
|
||||
(build_dir / "dummy.txt").write_text("x")
|
||||
|
||||
from esphome.writer import clean_all
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
clean_all([str(yaml_file)])
|
||||
|
||||
# Verify .esphome directory still exists but contents cleaned
|
||||
assert build_dir.exists()
|
||||
assert not (build_dir / "dummy.txt").exists()
|
||||
|
||||
# Verify logging mentions the build dir
|
||||
assert "Cleaning" in caplog.text
|
||||
assert str(build_dir) in caplog.text
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_all(
|
||||
mock_core: MagicMock,
|
||||
@@ -1031,3 +1065,103 @@ def test_clean_all_preserves_json_files(
|
||||
# Verify logging mentions cleaning
|
||||
assert "Cleaning" in caplog.text
|
||||
assert str(build_dir) in caplog.text
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_build_handles_readonly_files(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test clean_build handles read-only files (e.g., git pack files on Windows)."""
|
||||
# Create directory structure with read-only files
|
||||
pioenvs_dir = tmp_path / ".pioenvs"
|
||||
pioenvs_dir.mkdir()
|
||||
git_dir = pioenvs_dir / ".git" / "objects" / "pack"
|
||||
git_dir.mkdir(parents=True)
|
||||
|
||||
# Create a read-only file (simulating git pack files on Windows)
|
||||
readonly_file = git_dir / "pack-abc123.pack"
|
||||
readonly_file.write_text("pack data")
|
||||
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
|
||||
|
||||
# Verify file is read-only
|
||||
assert not os.access(readonly_file, os.W_OK)
|
||||
|
||||
# Call the function - should not crash
|
||||
clean_build()
|
||||
|
||||
# Verify directory was removed despite read-only files
|
||||
assert not pioenvs_dir.exists()
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_all_handles_readonly_files(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test clean_all handles read-only files."""
|
||||
# Create config directory
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
|
||||
build_dir = config_dir / ".esphome"
|
||||
build_dir.mkdir()
|
||||
|
||||
# Create a subdirectory with read-only files
|
||||
subdir = build_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
readonly_file = subdir / "readonly.txt"
|
||||
readonly_file.write_text("content")
|
||||
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
|
||||
|
||||
# Verify file is read-only
|
||||
assert not os.access(readonly_file, os.W_OK)
|
||||
|
||||
# Call the function - should not crash
|
||||
clean_all([str(config_dir)])
|
||||
|
||||
# Verify directory was removed despite read-only files
|
||||
assert not subdir.exists()
|
||||
assert build_dir.exists() # .esphome dir itself is preserved
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_build_reraises_for_other_errors(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test clean_build re-raises errors that are not read-only permission issues."""
|
||||
# Create directory structure with a read-only subdirectory
|
||||
# This prevents file deletion and triggers the error handler
|
||||
pioenvs_dir = tmp_path / ".pioenvs"
|
||||
pioenvs_dir.mkdir()
|
||||
subdir = pioenvs_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
test_file = subdir / "test.txt"
|
||||
test_file.write_text("content")
|
||||
|
||||
# Make subdir read-only so files inside can't be deleted
|
||||
os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR)
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
|
||||
|
||||
try:
|
||||
# Mock os.access in writer module to return True (writable)
|
||||
# This simulates a case where the error is NOT due to read-only permissions
|
||||
# so the error handler should re-raise instead of trying to fix permissions
|
||||
with (
|
||||
patch("esphome.writer.os.access", return_value=True),
|
||||
pytest.raises(PermissionError),
|
||||
):
|
||||
clean_build()
|
||||
finally:
|
||||
# Cleanup - restore write permission so tmp_path cleanup works
|
||||
os.chmod(subdir, stat.S_IRWXU)
|
||||
|
||||
Reference in New Issue
Block a user