Merge branch 'central-netif' into multi-interface-poc

# Conflicts:
#	esphome/components/wifi/wifi_component.cpp
This commit is contained in:
kbx81
2026-05-23 20:19:32 -05:00
36 changed files with 674 additions and 82 deletions

View File

@@ -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

View File

@@ -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: |

View File

@@ -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

View File

@@ -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}}"

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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 ||
this->send_service_ == DONE_SENDING_SERVICES)) {
// 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);

View File

@@ -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);

View File

@@ -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")

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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 1100 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() {

View File

@@ -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

View 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

View 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

View File

@@ -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);
});

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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.
"""
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
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
schema = schema.extend(widget_type.schema)
cached_schema: cv.Schema | None = None
def validator(value):
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)
cached_schema = schema.extend(widget_type.schema)
return cached_schema
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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View 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

View 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)

View File

@@ -1,6 +1,8 @@
<<: !include common.yaml
esp32_ble_tracker:
esp32_ble:
max_connections: 9
bluetooth_proxy:

View File

@@ -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()

View File

@@ -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"),
[

View File

@@ -4,6 +4,7 @@ esphome:
esp32:
board: esp32-c6-devkitc-1
flash_size: 8MB
framework:
type: esp-idf