mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 12:35:25 +00:00
Merge branch 'central-netif' into multi-interface-poc
# Conflicts: # esphome/components/wifi/wifi_component.cpp
This commit is contained in:
4
.github/actions/build-image/action.yaml
vendored
4
.github/actions/build-image/action.yaml
vendored
@@ -47,7 +47,7 @@ runs:
|
||||
|
||||
- name: Build and push to ghcr by digest
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
@@ -73,7 +73,7 @@ runs:
|
||||
|
||||
- name: Build and push to dockerhub by digest
|
||||
id: build-dockerhub
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
2
.github/workflows/ci-docker.yml
vendored
2
.github/workflows/ci-docker.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Set TAG
|
||||
run: |
|
||||
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -6,14 +6,6 @@ on:
|
||||
branches: [dev, beta, release]
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- "**"
|
||||
- "!.github/workflows/*.yml"
|
||||
- "!.github/actions/build-image/*"
|
||||
- ".github/workflows/ci.yml"
|
||||
- "!.yamllint"
|
||||
- "!.github/dependabot.yml"
|
||||
- "!docker/**"
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
@@ -101,6 +93,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -223,6 +217,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -261,6 +257,7 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
outputs:
|
||||
core-ci: ${{ steps.determine.outputs.core-ci }}
|
||||
integration-tests: ${{ steps.determine.outputs.integration-tests }}
|
||||
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
|
||||
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
|
||||
@@ -314,6 +311,7 @@ jobs:
|
||||
echo "$output" | jq
|
||||
|
||||
# Extract individual fields
|
||||
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
|
||||
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
|
||||
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
|
||||
@@ -969,7 +967,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -99,15 +99,15 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -178,17 +178,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
|
||||
remove-stale-when-updated: true
|
||||
|
||||
@@ -252,6 +252,22 @@ void RingBufferAudioSource::consume(size_t bytes) {
|
||||
}
|
||||
}
|
||||
|
||||
void RingBufferAudioSource::clear_buffered_data() {
|
||||
// Release the held item before reset() so the source no longer references memory the reset will reclaim.
|
||||
if (this->acquired_item_ != nullptr) {
|
||||
this->ring_buffer_->receive_release(this->acquired_item_);
|
||||
this->acquired_item_ = nullptr;
|
||||
}
|
||||
this->current_data_ = nullptr;
|
||||
this->current_available_ = 0;
|
||||
this->queued_data_ = nullptr;
|
||||
this->queued_length_ = 0;
|
||||
this->item_trailing_ptr_ = nullptr;
|
||||
this->item_trailing_length_ = 0;
|
||||
this->splice_length_ = 0;
|
||||
this->ring_buffer_->reset();
|
||||
}
|
||||
|
||||
bool RingBufferAudioSource::has_buffered_data() const {
|
||||
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
|
||||
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
|
||||
|
||||
@@ -250,6 +250,10 @@ class RingBufferAudioSource : public AudioReadableBuffer {
|
||||
/// exposure stays in place and fill() returns 0 until it is fully consumed.
|
||||
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
|
||||
|
||||
/// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
|
||||
/// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
|
||||
void clear_buffered_data();
|
||||
|
||||
/// @brief Returns a mutable pointer to the currently exposed audio data.
|
||||
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
|
||||
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
|
||||
|
||||
@@ -135,12 +135,26 @@ void BluetoothConnection::loop() {
|
||||
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
|
||||
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
|
||||
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
|
||||
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
|
||||
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
|
||||
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
|
||||
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
|
||||
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
|
||||
this->send_service_ == DONE_SENDING_SERVICES)) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
|
||||
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
|
||||
// base class. Free the proxy slot, notify the API client, and reset send_service_.
|
||||
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
|
||||
if (this->address_ == 0) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
|
||||
this->reset_connection_(reason);
|
||||
}
|
||||
|
||||
void BluetoothConnection::reset_connection_(esp_err_t reason) {
|
||||
// Send disconnection notification
|
||||
this->proxy_->send_device_connection(this->address_, false, 0, reason);
|
||||
@@ -372,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_CLOSE_EVT: {
|
||||
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
|
||||
param->close.reason);
|
||||
// Now the GATT connection is fully closed and controller resources are freed
|
||||
// Safe to mark the connection slot as available
|
||||
this->reset_connection_(param->close.reason);
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_OPEN_EVT: {
|
||||
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
|
||||
this->reset_connection_(param->open.status);
|
||||
|
||||
@@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
|
||||
protected:
|
||||
friend class BluetoothProxy;
|
||||
|
||||
void on_disconnect_complete(esp_err_t reason) override;
|
||||
|
||||
bool supports_efficient_uuids_() const;
|
||||
void send_service_for_discovery_();
|
||||
void reset_connection_(esp_err_t reason);
|
||||
|
||||
@@ -1816,11 +1816,9 @@ async def to_code(config):
|
||||
Path(__file__).parent / "iram_fix.py.script",
|
||||
)
|
||||
else:
|
||||
cg.add_build_flag("-Wno-error=format")
|
||||
cg.add_build_flag("-Wno-error=maybe-uninitialized")
|
||||
cg.add_build_flag("-Wno-error=overloaded-virtual")
|
||||
cg.add_build_flag("-Wno-error=reorder")
|
||||
cg.add_build_flag("-Wno-error=volatile")
|
||||
# Undo IDF's blanket -Werror so third-party libraries and user
|
||||
# lambdas don't need a -Wno-error=<class> entry per warning class.
|
||||
cg.add_build_flag("-Wno-error")
|
||||
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
|
||||
cg.add_build_flag("-Wno-missing-field-initializers")
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ void BLEClientBase::loop() {
|
||||
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
|
||||
this->release_services();
|
||||
this->set_idle_();
|
||||
this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
this->log_gattc_lifecycle_event_("CLOSE");
|
||||
this->release_services();
|
||||
this->set_idle_();
|
||||
this->on_disconnect_complete(param->close.reason);
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_SEARCH_RES_EVT: {
|
||||
|
||||
@@ -140,6 +140,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
|
||||
void log_gattc_warning_(const char *operation, esp_err_t err);
|
||||
void log_connection_params_(const char *param_type);
|
||||
void handle_connection_result_(esp_err_t ret);
|
||||
/// Hook called once a connection has been fully torn down (after release_services() and
|
||||
/// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout.
|
||||
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
|
||||
/// override this to release that state. `reason` is the controller reason code, or
|
||||
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
|
||||
virtual void on_disconnect_complete(esp_err_t reason) {}
|
||||
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
|
||||
void set_idle_() {
|
||||
this->set_state(espbt::ClientState::IDLE);
|
||||
@@ -149,6 +155,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
|
||||
void set_disconnecting_() {
|
||||
this->disconnecting_started_ = millis();
|
||||
this->set_state(espbt::ClientState::DISCONNECTING);
|
||||
// BluetoothConnection::loop() disables the component loop after service discovery
|
||||
// completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT
|
||||
// gets lost. Re-enable the loop so the 10s safety timeout can force IDLE.
|
||||
this->enable_loop();
|
||||
}
|
||||
// Compact error logging helpers to reduce flash usage
|
||||
void log_error_(const char *message);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <core_esp8266_features.h>
|
||||
#include <coredecls.h>
|
||||
|
||||
extern "C" {
|
||||
#include <user_interface.h>
|
||||
@@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
|
||||
// call to the original millis() that --wrap can't intercept, so calling ::delay()
|
||||
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
|
||||
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
|
||||
// WiFi run correctly. Theoretically less power-efficient than Arduino's
|
||||
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
|
||||
// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is
|
||||
// negligible.
|
||||
// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
|
||||
// suspend the cont task for `ms` milliseconds without polling millis(). This
|
||||
// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
|
||||
// and lets the SDK run freely while we wait, which timing-sensitive
|
||||
// interrupt-driven code (e.g. ESP8266 software-serial RX in components like
|
||||
// fingerprint_grow) depends on. The poll-based busy-wait that this replaced
|
||||
// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
|
||||
// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does
|
||||
// not call millis(), so the slow Arduino millis() body is not pulled into IRAM
|
||||
// by this path (the --wrap=millis goal of #15662 is preserved).
|
||||
void HOT delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
optimistic_yield(1000);
|
||||
return;
|
||||
}
|
||||
uint32_t start = millis();
|
||||
while (millis() - start < ms) {
|
||||
optimistic_yield(1000);
|
||||
}
|
||||
esp_delay(ms);
|
||||
}
|
||||
|
||||
void arch_restart() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "w5500_custom_spi.h"
|
||||
|
||||
#include <lwip/dns.h>
|
||||
#include <cinttypes>
|
||||
@@ -221,6 +222,10 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
|
||||
w5500_config.poll_period_ms = this->polling_interval_;
|
||||
#endif
|
||||
// Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait
|
||||
// path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which
|
||||
// runs the driver's init().
|
||||
install_w5500_async_spi(w5500_config);
|
||||
#elif defined(USE_ETHERNET_DM9051)
|
||||
dm9051_config.int_gpio_num = this->interrupt_pin_;
|
||||
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
|
||||
|
||||
118
esphome/components/ethernet/w5500_custom_spi.cpp
Normal file
118
esphome/components/ethernet/w5500_custom_spi.cpp
Normal file
@@ -0,0 +1,118 @@
|
||||
#include "w5500_custom_spi.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
|
||||
|
||||
#include <driver/spi_master.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
namespace esphome::ethernet {
|
||||
|
||||
namespace {
|
||||
|
||||
// Per-device context returned by init() and handed back to read/write/deinit.
|
||||
struct W5500CustomSpiContext {
|
||||
spi_device_handle_t handle;
|
||||
SemaphoreHandle_t lock;
|
||||
};
|
||||
|
||||
// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger
|
||||
// transfers (the frame payloads) use the blocking, DMA-backed transmit.
|
||||
constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64;
|
||||
constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50;
|
||||
|
||||
void *w5500_custom_spi_init(const void *spi_config) {
|
||||
const auto *config = static_cast<const eth_w5500_config_t *>(spi_config);
|
||||
auto *ctx = new (std::nothrow) W5500CustomSpiContext{};
|
||||
if (ctx == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
// The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control
|
||||
// byte in the address phase; mirror what the stock driver configures.
|
||||
spi_device_interface_config_t devcfg = *config->spi_devcfg;
|
||||
devcfg.command_bits = 16;
|
||||
devcfg.address_bits = 8;
|
||||
if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) {
|
||||
delete ctx;
|
||||
return nullptr;
|
||||
}
|
||||
ctx->lock = xSemaphoreCreateMutex();
|
||||
if (ctx->lock == nullptr) {
|
||||
spi_bus_remove_device(ctx->handle);
|
||||
delete ctx;
|
||||
return nullptr;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
esp_err_t w5500_custom_spi_deinit(void *spi_ctx) {
|
||||
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
|
||||
spi_bus_remove_device(ctx->handle);
|
||||
vSemaphoreDelete(ctx->lock);
|
||||
delete ctx;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size.
|
||||
// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register
|
||||
// accesses stay on the cheaper polling path. Used by both read and write.
|
||||
esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) {
|
||||
if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
esp_err_t ret;
|
||||
if (len > W5500_SPI_BULK_THRESHOLD) {
|
||||
ret = spi_device_transmit(ctx->handle, trans);
|
||||
} else {
|
||||
ret = spi_device_polling_transmit(ctx->handle, trans);
|
||||
}
|
||||
xSemaphoreGive(ctx->lock);
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) {
|
||||
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
|
||||
spi_transaction_t trans = {};
|
||||
trans.cmd = static_cast<uint16_t>(cmd);
|
||||
trans.addr = addr;
|
||||
trans.length = 8 * len;
|
||||
trans.tx_buffer = data;
|
||||
return w5500_custom_spi_transfer(ctx, &trans, len);
|
||||
}
|
||||
|
||||
esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) {
|
||||
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
|
||||
spi_transaction_t trans = {};
|
||||
// Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary
|
||||
// overwrites of adjacent registers (same guard the stock driver uses).
|
||||
const bool use_rxdata = len <= 4;
|
||||
trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0;
|
||||
trans.cmd = static_cast<uint16_t>(cmd);
|
||||
trans.addr = addr;
|
||||
trans.length = 8 * len;
|
||||
trans.rx_buffer = data;
|
||||
esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len);
|
||||
if (use_rxdata && (ret == ESP_OK)) {
|
||||
memcpy(data, trans.rx_data, len);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void install_w5500_async_spi(eth_w5500_config_t &config) {
|
||||
// Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and
|
||||
// spi_devcfg back out of it. The self-reference is valid because both the config and the
|
||||
// spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init().
|
||||
config.custom_spi_driver.config = &config;
|
||||
config.custom_spi_driver.init = w5500_custom_spi_init;
|
||||
config.custom_spi_driver.deinit = w5500_custom_spi_deinit;
|
||||
config.custom_spi_driver.read = w5500_custom_spi_read;
|
||||
config.custom_spi_driver.write = w5500_custom_spi_write;
|
||||
}
|
||||
|
||||
} // namespace esphome::ethernet
|
||||
|
||||
#endif // USE_ESP32 && USE_ETHERNET_W5500
|
||||
35
esphome/components/ethernet/w5500_custom_spi.h
Normal file
35
esphome/components/ethernet/w5500_custom_spi.h
Normal file
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
|
||||
|
||||
#include <esp_idf_version.h>
|
||||
// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t
|
||||
// is no longer reachable through esp_eth.h and needs the explicit header.
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
#include <esp_eth_mac_w5500.h>
|
||||
#else
|
||||
#include <esp_eth.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::ethernet {
|
||||
|
||||
// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path.
|
||||
//
|
||||
// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which
|
||||
// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame,
|
||||
// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX
|
||||
// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers
|
||||
// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps
|
||||
// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait
|
||||
// is cheaper than an interrupt round-trip.
|
||||
//
|
||||
// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back
|
||||
// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay
|
||||
// alive until esp_eth_mac_new_w5500() returns.
|
||||
void install_w5500_async_spi(eth_w5500_config_t &config);
|
||||
|
||||
} // namespace esphome::ethernet
|
||||
|
||||
#endif // USE_ESP32 && USE_ETHERNET_W5500
|
||||
@@ -17,9 +17,9 @@ void HomeassistantSensor::setup() {
|
||||
}
|
||||
|
||||
if (this->attribute_ != nullptr) {
|
||||
ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
|
||||
ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
|
||||
ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
|
||||
}
|
||||
this->publish_state(*val);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/i2s_std.h>
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
@@ -299,6 +300,15 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() {
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
|
||||
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
|
||||
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
|
||||
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
|
||||
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
|
||||
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
|
||||
gpio_reset_pin(this->dout_pin_);
|
||||
gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(this->dout_pin_, 0);
|
||||
}
|
||||
this->parent_->unlock();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import subprocess
|
||||
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
|
||||
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
|
||||
# - LN882H: stock linker has no glob for ".sram.text", so we inject
|
||||
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH).
|
||||
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
|
||||
# immediately after KEEP(*(.vectors)), so the vector table stays at
|
||||
# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment.
|
||||
#
|
||||
# All families also get a post-link summary showing where IRAM_ATTR landed.
|
||||
|
||||
@@ -27,7 +29,11 @@ _KEEP_LINE = (
|
||||
"__esphome_sram_text_end = .; "
|
||||
+ _MARKER + "\n"
|
||||
)
|
||||
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)")
|
||||
# Inject after KEEP(*(.vectors)) so the vector table stays at
|
||||
# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte-
|
||||
# aligned address; injecting before the vectors would push them to an
|
||||
# unaligned offset and mis-route every IRQ handler.
|
||||
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
|
||||
|
||||
|
||||
def _detect(env):
|
||||
@@ -56,7 +62,7 @@ KNOWN_VARIANTS = frozenset({
|
||||
|
||||
|
||||
def _inject_keep(host_section):
|
||||
"""Return a patcher that injects _KEEP_LINE at the top of `host_section`."""
|
||||
"""Return a patcher that injects _KEEP_LINE after `host_section` match."""
|
||||
def patch(content):
|
||||
if _MARKER in content:
|
||||
return content
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.automation import Trigger, validate_automation
|
||||
@@ -534,7 +535,16 @@ def strip_defaults(schema: cv.Schema):
|
||||
return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()})
|
||||
|
||||
|
||||
def container_schema(widget_type: WidgetType, extras=None):
|
||||
# Keyed by (id(widget_type), id(extras)); strong refs in the value keep both
|
||||
# alive so id() can't be recycled.
|
||||
_CONTAINER_SCHEMA_CACHE: dict[
|
||||
tuple[int, int], tuple[Any, Any, Callable[[Any], Any]]
|
||||
] = {}
|
||||
|
||||
|
||||
def container_schema(
|
||||
widget_type: WidgetType, extras: Any = None
|
||||
) -> Callable[[Any], Any]:
|
||||
"""
|
||||
Create a schema for a container widget of a given type. All obj properties are available, plus
|
||||
the extras passed in, plus any defined for the specific widget being specified.
|
||||
@@ -542,19 +552,31 @@ def container_schema(widget_type: WidgetType, extras=None):
|
||||
:param extras: Additional options to be made available, e.g. layout properties for children
|
||||
:return: The schema for this type of widget.
|
||||
"""
|
||||
cache_key = (id(widget_type), id(extras))
|
||||
cached = _CONTAINER_SCHEMA_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
cached_widget_type, cached_extras, cached_validator = cached
|
||||
if cached_widget_type is widget_type and cached_extras is extras:
|
||||
return cached_validator
|
||||
|
||||
cached_schema: cv.Schema | None = None
|
||||
|
||||
def get_schema() -> cv.Schema:
|
||||
nonlocal cached_schema
|
||||
if cached_schema is None:
|
||||
schema = obj_schema(widget_type).extend(
|
||||
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
|
||||
)
|
||||
if extras:
|
||||
schema = schema.extend(extras)
|
||||
# Delayed evaluation for recursion
|
||||
cached_schema = schema.extend(widget_type.schema)
|
||||
return cached_schema
|
||||
|
||||
schema = schema.extend(widget_type.schema)
|
||||
|
||||
def validator(value):
|
||||
def validator(value: Any) -> Any:
|
||||
value = value or {}
|
||||
return append_layout_schema(schema, value)(value)
|
||||
return append_layout_schema(get_schema(), value)(value)
|
||||
|
||||
_CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator)
|
||||
return validator
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from collections.abc import Callable
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from esphome import codegen as cg, config_validation as cv
|
||||
from esphome.automation import register_action
|
||||
@@ -15,6 +17,7 @@ from esphome.const import (
|
||||
from esphome.core import ID, EsphomeError, TimePeriod
|
||||
from esphome.coroutine import FakeAwaitable
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.schema_extractors import EnableSchemaExtraction
|
||||
from esphome.types import Expression
|
||||
|
||||
from ..defines import (
|
||||
@@ -73,6 +76,34 @@ from ..types import (
|
||||
EVENT_LAMB = "event_lamb__"
|
||||
|
||||
|
||||
def _build_update_schema(widget_type: "WidgetType") -> Schema:
|
||||
# Local import: ..schemas imports WidgetType from this module.
|
||||
from ..schemas import base_update_schema
|
||||
|
||||
return base_update_schema(widget_type, widget_type.parts).extend(
|
||||
widget_type.modify_schema
|
||||
)
|
||||
|
||||
|
||||
def _update_action_schema(
|
||||
widget_type: "WidgetType",
|
||||
) -> Schema | Callable[[Any], Any]:
|
||||
# Eager when extracting so build_language_schema.py sees the mapping;
|
||||
# lazy otherwise to skip ~200 ms of import-time voluptuous work.
|
||||
if EnableSchemaExtraction:
|
||||
return _build_update_schema(widget_type)
|
||||
|
||||
cached: Schema | None = None
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
nonlocal cached
|
||||
if cached is None:
|
||||
cached = _build_update_schema(widget_type)
|
||||
return cached(value)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
class WidgetType:
|
||||
"""
|
||||
Describes a type of Widget, e.g. "bar" or "line"
|
||||
@@ -113,18 +144,17 @@ class WidgetType:
|
||||
|
||||
# Local import to avoid circular import
|
||||
from ..automation import update_to_code
|
||||
from ..schemas import WIDGET_TYPES, base_update_schema
|
||||
from ..schemas import WIDGET_TYPES
|
||||
|
||||
if not is_mock:
|
||||
if self.name in WIDGET_TYPES:
|
||||
raise EsphomeError(f"Duplicate definition of widget type '{self.name}'")
|
||||
WIDGET_TYPES[self.name] = self
|
||||
|
||||
# Register the update action automatically, adding widget-specific properties
|
||||
register_action(
|
||||
f"lvgl.{self.name}.update",
|
||||
ObjUpdateAction,
|
||||
base_update_schema(self, self.parts).extend(self.modify_schema),
|
||||
_update_action_schema(self),
|
||||
synchronous=True,
|
||||
)(update_to_code)
|
||||
|
||||
|
||||
@@ -453,7 +453,7 @@ async def to_code(config):
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.NETWORK_SERVICES)
|
||||
async def network_component_to_code(config):
|
||||
async def network_component_to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
# sendspin-cpp library
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0")
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
|
||||
|
||||
cg.add_define("USE_SENDSPIN", True) # for MDNS
|
||||
|
||||
|
||||
@@ -513,10 +513,11 @@ async def uart_write_to_code(config, action_id, template_arg, args):
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def final_step():
|
||||
"""Final code generation step to configure optional UART features."""
|
||||
if CORE.is_esp32 and CORE.has_networking:
|
||||
# Wake-on-RX is essentially free on ESP32 (just an ISR function pointer
|
||||
# registration) — enable by default to reduce RX buffer overflow risk
|
||||
# by waking the main loop immediately when data arrives.
|
||||
if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking:
|
||||
# Wake-on-RX is essentially free (just an ISR function pointer
|
||||
# registration on ESP32, an inline flag set on ESP8266 software
|
||||
# serial) — enable by default to reduce RX buffer overflow risk by
|
||||
# waking the main loop immediately when data arrives.
|
||||
cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#ifdef USE_UART_WAKE_LOOP_ON_RX
|
||||
#include "esphome/core/wake.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_LOGGER
|
||||
#include "esphome/components/logger/logger.h"
|
||||
@@ -149,7 +152,11 @@ void ESP8266UartComponent::dump_config() {
|
||||
if (this->hw_serial_ != nullptr) {
|
||||
ESP_LOGCONFIG(TAG, " Using hardware serial interface.");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Using software serial");
|
||||
ESP_LOGCONFIG(TAG, " Using software serial"
|
||||
#ifdef USE_UART_WAKE_LOOP_ON_RX
|
||||
"\n Wake on data RX: ENABLED"
|
||||
#endif
|
||||
);
|
||||
}
|
||||
this->check_logger_conflict();
|
||||
}
|
||||
@@ -266,6 +273,12 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) {
|
||||
arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_;
|
||||
// Clear RX pin so that the interrupt doesn't re-trigger right away again.
|
||||
arg->rx_pin_.clear_interrupt();
|
||||
#ifdef USE_UART_WAKE_LOOP_ON_RX
|
||||
// Wake the main loop so the consuming component drains the byte promptly
|
||||
// instead of waiting for the next loop_interval_ tick. Important for timing
|
||||
// sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow).
|
||||
wake_loop_isrsafe();
|
||||
#endif
|
||||
}
|
||||
void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) {
|
||||
if (this->gpio_tx_pin_ == nullptr) {
|
||||
|
||||
@@ -637,9 +637,6 @@ void WiFiComponent::setup() {
|
||||
#endif
|
||||
this->start();
|
||||
} else {
|
||||
// Note: esp_netif_init() used to be called here as a fallback for non-IDF builds.
|
||||
// It is now centralized in NetworkComponent::setup(), which runs at AFTER_BLUETOOTH
|
||||
// (300) ahead of WiFi (250). No action needed.
|
||||
this->state_ = WIFI_COMPONENT_STATE_DISABLED;
|
||||
}
|
||||
}
|
||||
@@ -2201,7 +2198,15 @@ bool WiFiComponent::request_high_performance() {
|
||||
}
|
||||
|
||||
// Give the semaphore (non-blocking). This increments the count.
|
||||
return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
|
||||
bool success = xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
|
||||
|
||||
// Wake the main loop so the switch to high-performance mode is applied on the
|
||||
// next tick instead of waiting up to loop_interval.
|
||||
if (success) {
|
||||
App.wake_loop_threadsafe();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
bool WiFiComponent::release_high_performance() {
|
||||
|
||||
@@ -100,6 +100,6 @@ dependencies:
|
||||
esp32async/asynctcp:
|
||||
version: 3.4.91
|
||||
sendspin/sendspin-cpp:
|
||||
version: 0.6.0
|
||||
version: 0.6.1
|
||||
lvgl/lvgl:
|
||||
version: 9.5.0
|
||||
|
||||
@@ -16,7 +16,7 @@ hypothesis==6.92.1
|
||||
# CodSpeed benchmarks under tests/benchmarks/python/
|
||||
# (skipped via pytest.importorskip when missing -- only required for the
|
||||
# benchmarks job in .github/workflows/ci.yml)
|
||||
pytest-codspeed==5.0.2
|
||||
pytest-codspeed==5.0.3
|
||||
|
||||
# Used by the import-time regression check (.github/workflows/ci.yml → import-time job)
|
||||
importtime-waterfall==1.0.0
|
||||
|
||||
@@ -5,6 +5,7 @@ This script is a centralized way to determine which CI jobs need to run based on
|
||||
what files have changed. It outputs JSON with the following structure:
|
||||
|
||||
{
|
||||
"core_ci": true/false,
|
||||
"integration_tests": true/false,
|
||||
"integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...],
|
||||
"clang_tidy": true/false,
|
||||
@@ -22,6 +23,11 @@ what files have changed. It outputs JSON with the following structure:
|
||||
}
|
||||
|
||||
The CI workflow uses this information to:
|
||||
- Gate the unconditional jobs (ci-custom, pytest, pre-commit-ci-lite) via core_ci;
|
||||
false when a pull_request only touches CI-irrelevant meta paths (other workflow
|
||||
files, .github/actions/build-image/*, .yamllint, .github/dependabot.yml, docker/**)
|
||||
so workflow-only PRs satisfy the required CI Status check without running the
|
||||
unconditional jobs. Always true on non-pull_request events and under --force-all.
|
||||
- Skip or run integration tests
|
||||
- Skip or run clang-tidy (and whether to do a full scan)
|
||||
- Skip or run clang-format
|
||||
@@ -712,6 +718,69 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
|
||||
return any(get_component_from_path(f) in benchmarked_components for f in files)
|
||||
|
||||
|
||||
# Files / path patterns whose changes alone don't warrant running the
|
||||
# unconditional CI jobs (`ci-custom`, `pytest`, `pre-commit-ci-lite`).
|
||||
# Single source of truth for what we treat as "CI-irrelevant" on
|
||||
# pull_request events; ci.yml used to encode this in its own
|
||||
# `pull_request.paths` filter, but that hid the required `CI Status`
|
||||
# check on PRs that only touched these files (dependabot Action bumps,
|
||||
# dependabot.yml edits, docker/ changes, etc.) and forced admin
|
||||
# force-merges.
|
||||
#
|
||||
# ci.yml itself is deliberately *not* ignored — editing the CI workflow
|
||||
# must still run CI. Workflows that have their own dedicated triggers
|
||||
# (codeql.yml, ci-docker.yml, ...) are matched via the
|
||||
# `.github/workflows/*.yml` prefix below and exclude ci.yml explicitly.
|
||||
CI_IRRELEVANT_EXACT_FILES = frozenset(
|
||||
{
|
||||
".yamllint",
|
||||
".github/dependabot.yml",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_ci_irrelevant_path(path: str) -> bool:
|
||||
"""Whether a single changed path is irrelevant to the unconditional CI jobs."""
|
||||
if path in CI_IRRELEVANT_EXACT_FILES:
|
||||
return True
|
||||
# docker/** — all descendants
|
||||
if path.startswith("docker/"):
|
||||
return True
|
||||
# .github/workflows/*.yml — top-level workflow files other than ci.yml
|
||||
# (ci.yml itself must still trigger full CI when edited).
|
||||
if path.startswith(".github/workflows/") and path.endswith(".yml"):
|
||||
if path == ".github/workflows/ci.yml":
|
||||
return False
|
||||
if "/" not in path[len(".github/workflows/") :]:
|
||||
return True
|
||||
# .github/actions/build-image/* — direct children only, matches the
|
||||
# single-star glob the workflow used to encode.
|
||||
if path.startswith(".github/actions/build-image/"):
|
||||
rest = path[len(".github/actions/build-image/") :]
|
||||
if rest and "/" not in rest:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def should_run_core_ci(branch: str | None = None) -> bool:
|
||||
"""Determine if the unconditional CI jobs (ci-custom/pytest/pre-commit-ci-lite) should run.
|
||||
|
||||
Returns False only when every changed file is in the CI-irrelevant set
|
||||
above (see ``_is_ci_irrelevant_path``). Empty diffs return True so we
|
||||
never accidentally skip CI when the diff probe fails.
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against. If None, uses default.
|
||||
|
||||
Returns:
|
||||
True if the unconditional CI jobs should run, False otherwise.
|
||||
"""
|
||||
files = changed_files(branch)
|
||||
if not files:
|
||||
return True
|
||||
return any(not _is_ci_irrelevant_path(f) for f in files)
|
||||
|
||||
|
||||
def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool:
|
||||
"""Check if a changed file ends with any of the specified extensions."""
|
||||
return any(file.endswith(extensions) for file in changed_files(branch))
|
||||
@@ -1075,6 +1144,16 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine what should run
|
||||
# core_ci gates the unconditional jobs in ci.yml (ci-custom, pytest,
|
||||
# pre-commit-ci-lite). Non-pull_request events (push to dev/beta/release
|
||||
# and merge_group) always run them so behavior like venv-cache saves on
|
||||
# push to dev is preserved.
|
||||
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
|
||||
run_core_ci = (
|
||||
True
|
||||
if args.force_all or event_name != "pull_request"
|
||||
else should_run_core_ci(args.branch)
|
||||
)
|
||||
if args.force_all:
|
||||
integration_run_all, integration_test_files = True, []
|
||||
run_clang_tidy = True
|
||||
@@ -1255,6 +1334,7 @@ def main() -> None:
|
||||
component_test_batches = []
|
||||
|
||||
output: dict[str, Any] = {
|
||||
"core_ci": run_core_ci,
|
||||
"integration_tests": run_integration,
|
||||
"integration_test_buckets": integration_test_buckets,
|
||||
"clang_tidy": run_clang_tidy,
|
||||
|
||||
87
tests/component_tests/lvgl/test_container_schema_cache.py
Normal file
87
tests/component_tests/lvgl/test_container_schema_cache.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Tests for container_schema() memoization and lazy build."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
import esphome.components.lvgl # noqa: F401
|
||||
from esphome.components.lvgl import schemas as lvgl_schemas
|
||||
from esphome.components.lvgl.schemas import WIDGET_TYPES, container_schema
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_container_schema_cache() -> Generator[None]:
|
||||
cache = getattr(lvgl_schemas, "_CONTAINER_SCHEMA_CACHE", None)
|
||||
if cache is not None:
|
||||
cache.clear()
|
||||
yield
|
||||
if cache is not None:
|
||||
cache.clear()
|
||||
|
||||
|
||||
def _widget_type(name: str = "obj"):
|
||||
wt = WIDGET_TYPES.get(name)
|
||||
assert wt is not None, f"widget type {name!r} not registered"
|
||||
return wt
|
||||
|
||||
|
||||
def test_same_args_return_same_validator() -> None:
|
||||
wt = _widget_type("obj")
|
||||
assert container_schema(wt) is container_schema(wt)
|
||||
|
||||
|
||||
def test_extras_none_vs_truthy_get_different_validators() -> None:
|
||||
wt = _widget_type("obj")
|
||||
no_extras = container_schema(wt)
|
||||
extras = {cv.Optional("custom_extra"): cv.string}
|
||||
assert no_extras is not container_schema(wt, extras)
|
||||
|
||||
|
||||
def test_different_widget_types_get_different_validators() -> None:
|
||||
assert container_schema(_widget_type("obj")) is not container_schema(
|
||||
_widget_type("label")
|
||||
)
|
||||
|
||||
|
||||
def test_schema_build_is_deferred_until_first_validation() -> None:
|
||||
wt = _widget_type("obj")
|
||||
with patch.object(
|
||||
lvgl_schemas, "obj_schema", wraps=lvgl_schemas.obj_schema
|
||||
) as obj_schema_mock:
|
||||
validator = container_schema(wt)
|
||||
assert obj_schema_mock.call_count == 0
|
||||
validator({})
|
||||
assert obj_schema_mock.call_count == 1
|
||||
validator({})
|
||||
assert obj_schema_mock.call_count == 1
|
||||
|
||||
|
||||
def test_cached_validator_produces_equivalent_output() -> None:
|
||||
wt = _widget_type("obj")
|
||||
cached = container_schema(wt)
|
||||
cached_result = cached({})
|
||||
lvgl_schemas._CONTAINER_SCHEMA_CACHE.clear()
|
||||
reference = container_schema(wt)
|
||||
assert cached is not reference
|
||||
assert cached_result == reference({})
|
||||
|
||||
|
||||
def test_id_recycling_is_caught_by_identity_guard() -> None:
|
||||
wt = _widget_type("obj")
|
||||
real_extras = {cv.Optional("a"): cv.int_}
|
||||
validator_a = container_schema(wt, real_extras)
|
||||
|
||||
cache_key = (id(wt), id(real_extras))
|
||||
cached_entry = lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key]
|
||||
sentinel = {cv.Optional("a"): cv.int_}
|
||||
lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] = (
|
||||
cached_entry[0],
|
||||
sentinel,
|
||||
cached_entry[2],
|
||||
)
|
||||
|
||||
assert container_schema(wt, real_extras) is not validator_a
|
||||
53
tests/component_tests/lvgl/test_update_action_lazy.py
Normal file
53
tests/component_tests/lvgl/test_update_action_lazy.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests for lvgl.<widget>.update lazy schema build."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from esphome.automation import ACTION_REGISTRY
|
||||
import esphome.components.lvgl # noqa: F401
|
||||
from esphome.components.lvgl.schemas import WIDGET_TYPES
|
||||
from esphome.components.lvgl.widgets import _update_action_schema
|
||||
from esphome.config_validation import Schema
|
||||
|
||||
|
||||
def _widget_type(name: str = "obj"):
|
||||
wt = WIDGET_TYPES.get(name)
|
||||
assert wt is not None, f"widget type {name!r} not registered"
|
||||
return wt
|
||||
|
||||
|
||||
def test_registry_entry_uses_lazy_validator() -> None:
|
||||
entry = ACTION_REGISTRY["lvgl.label.update"]
|
||||
assert callable(entry.raw_schema)
|
||||
assert not isinstance(entry.raw_schema, Schema)
|
||||
|
||||
|
||||
def test_lazy_validator_defers_build_until_first_call() -> None:
|
||||
wt = _widget_type("label")
|
||||
with patch(
|
||||
"esphome.components.lvgl.widgets._build_update_schema",
|
||||
wraps=lambda w: Schema({}),
|
||||
) as build_mock:
|
||||
validator = _update_action_schema(wt)
|
||||
assert build_mock.call_count == 0
|
||||
validator({})
|
||||
assert build_mock.call_count == 1
|
||||
validator({})
|
||||
assert build_mock.call_count == 1
|
||||
|
||||
|
||||
def test_eager_build_when_schema_extraction_enabled() -> None:
|
||||
wt = _widget_type("label")
|
||||
with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True):
|
||||
result = _update_action_schema(wt)
|
||||
assert isinstance(result, Schema)
|
||||
|
||||
|
||||
def test_lazy_and_eager_produce_equivalent_validation() -> None:
|
||||
wt = _widget_type("label")
|
||||
with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True):
|
||||
eager = _update_action_schema(wt)
|
||||
lazy = _update_action_schema(wt)
|
||||
sample = {"id": "label_id"}
|
||||
assert lazy(sample) == eager(sample)
|
||||
@@ -1,6 +1,8 @@
|
||||
<<: !include common.yaml
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
esp32_ble:
|
||||
max_connections: 9
|
||||
|
||||
bluetooth_proxy:
|
||||
|
||||
@@ -1503,13 +1503,18 @@ async def test_websocket_refresh_command(
|
||||
) -> None:
|
||||
"""Test WebSocket refresh command triggers dashboard update."""
|
||||
with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber:
|
||||
mock_subscriber.request_refresh = Mock()
|
||||
# Signal an asyncio.Event when request_refresh is invoked so the
|
||||
# test can deterministically wait for the server-side handler to run
|
||||
# instead of relying on a fixed sleep (flaky on Windows CI under load).
|
||||
called = asyncio.Event()
|
||||
mock_subscriber.request_refresh = Mock(side_effect=called.set)
|
||||
|
||||
# Send refresh command
|
||||
await websocket_client.write_message(json.dumps({"event": "refresh"}))
|
||||
|
||||
# Give it a moment to process
|
||||
await asyncio.sleep(0.01)
|
||||
# Wait for the server to process the message and invoke request_refresh
|
||||
async with asyncio.timeout(5):
|
||||
await called.wait()
|
||||
|
||||
# Verify request_refresh was called
|
||||
mock_subscriber.request_refresh.assert_called_once()
|
||||
|
||||
@@ -775,6 +775,88 @@ def test_should_run_import_time_with_branch() -> None:
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "expected_result"),
|
||||
[
|
||||
# Exact-file matches in the CI-irrelevant set.
|
||||
(".yamllint", True),
|
||||
(".github/dependabot.yml", True),
|
||||
# Other top-level workflow files are irrelevant; ci.yml itself is not.
|
||||
(".github/workflows/codeql.yml", True),
|
||||
(".github/workflows/release.yml", True),
|
||||
(".github/workflows/ci.yml", False),
|
||||
# Nested files under workflows/ are not matched by the single-star glob.
|
||||
(".github/workflows/matchers/gcc.json", False),
|
||||
# build-image action: direct children only (single-star glob).
|
||||
(".github/actions/build-image/action.yml", True),
|
||||
(".github/actions/build-image/nested/file.yml", False),
|
||||
# Other actions are CI-relevant.
|
||||
(".github/actions/restore-python/action.yml", False),
|
||||
# docker/** covers everything under docker/.
|
||||
("docker/Dockerfile", True),
|
||||
("docker/scripts/run.sh", True),
|
||||
# Regular source files are CI-relevant.
|
||||
("esphome/__main__.py", False),
|
||||
("esphome/components/wifi/wifi_component.cpp", False),
|
||||
("README.md", False),
|
||||
("tests/script/test_determine_jobs.py", False),
|
||||
],
|
||||
)
|
||||
def test_is_ci_irrelevant_path(path: str, expected_result: bool) -> None:
|
||||
"""Test _is_ci_irrelevant_path mirrors the historic ci.yml path filter."""
|
||||
assert determine_jobs._is_ci_irrelevant_path(path) == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
# Empty diffs default to True — don't accidentally skip CI on a
|
||||
# broken probe.
|
||||
([], True),
|
||||
# Any CI-relevant file flips the result to True.
|
||||
(["esphome/__main__.py"], True),
|
||||
(["esphome/components/wifi/wifi_component.cpp"], True),
|
||||
(["README.md"], True),
|
||||
# All-irrelevant diffs return False.
|
||||
([".github/workflows/codeql.yml"], False),
|
||||
(
|
||||
[".github/workflows/codeql.yml", ".github/workflows/release.yml"],
|
||||
False,
|
||||
),
|
||||
([".yamllint"], False),
|
||||
([".github/dependabot.yml"], False),
|
||||
(["docker/Dockerfile"], False),
|
||||
(
|
||||
[
|
||||
".github/workflows/codeql.yml",
|
||||
".github/dependabot.yml",
|
||||
"docker/Dockerfile",
|
||||
],
|
||||
False,
|
||||
),
|
||||
# Mixed diffs always trigger CI.
|
||||
(
|
||||
[".github/workflows/codeql.yml", "esphome/__main__.py"],
|
||||
True,
|
||||
),
|
||||
# ci.yml itself is treated as CI-relevant.
|
||||
([".github/workflows/ci.yml"], True),
|
||||
],
|
||||
)
|
||||
def test_should_run_core_ci(changed_files: list[str], expected_result: bool) -> None:
|
||||
"""Test should_run_core_ci function."""
|
||||
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
|
||||
assert determine_jobs.should_run_core_ci() == expected_result
|
||||
|
||||
|
||||
def test_should_run_core_ci_with_branch() -> None:
|
||||
"""Test should_run_core_ci passes the branch through to changed_files."""
|
||||
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
||||
mock_changed.return_value = []
|
||||
determine_jobs.should_run_core_ci("release")
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
|
||||
@@ -4,6 +4,7 @@ esphome:
|
||||
|
||||
esp32:
|
||||
board: esp32-c6-devkitc-1
|
||||
flash_size: 8MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
|
||||
Reference in New Issue
Block a user