Compare commits

...
Author SHA1 Message Date
J. Nick Koston 06668c742d Merge remote-tracking branch 'origin/dev' into uart-idf-write-before-setup-guard
# Conflicts:
#	esphome/components/uart/uart_component_esp_idf.h
2026-09-14 10:52:21 -05:00
Kevin Ahrendt 54b8e2e6dc [audio] Update esp-audio-libs to 4.0.0 (#19300) 2026-09-14 13:56:14 +00:00
651863323b [issues] Add AI usage guidance to the bug report template (#19122)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-09-14 22:40:00 +10:00
esphome[bot] 1e627a31f1 [ci] Refresh integration test durations (#19275) 2026-09-14 07:36:30 -05:00
J. Nick Koston e5eb577b49 [esp8266_pwm] Skip the frequency setter when it matches the default (#19224) 2026-09-14 18:05:27 +12:00
J. Nick Koston c378ea1300 [logger] Skip the hardware UART setter when it matches the default (#19230) 2026-09-14 16:53:41 +12:00
J. Nick Koston cba4f5bc05 [wifi] Skip setters that pass the default priority, timeouts, power save and auth mode (#19229) 2026-09-14 16:52:57 +12:00
J. Nick Koston 8109aa96f6 [light] Skip the flash transition setter and the empty effect list (#19228) 2026-09-14 16:51:42 +12:00
J. Nick Koston 4067f572cc [output] Skip the power limit setters when they match the defaults (#19225) 2026-09-14 16:50:56 +12:00
J. Nick Koston 1e22861d11 [web_server] Skip setters that pass the default port, log and include internal values (#19226) 2026-09-14 16:50:00 +12:00
J. Nick Koston 282be54d1e [number] Fix the default mode check so mode auto is no longer emitted (#19231) 2026-09-14 16:44:36 +12:00
Jesse Hills abadfbfd20 [core] Mark filters, manual_ip and interlock as advanced (#19272) 2026-09-14 16:32:31 +12:00
Jesse Hills 93b3dc1876 Merge branch 'beta' into dev 2026-09-14 15:27:22 +12:00
Jesse Hills e7c9c5ad0b Merge pull request #19270 from esphome/bump-2026.9.0b4
2026.9.0b4
2026-09-14 15:27:03 +12:00
J. Nick Koston 82b608706c [bluetooth_connection] Keep USE_BLUETOOTH_PROXY out of the shared host test binary (#19271) 2026-09-14 14:18:38 +12:00
J. Nick Koston 02648d3547 [bluetooth_connection] Keep USE_BLUETOOTH_PROXY out of the shared host test binary (#19271) 2026-09-14 14:17:58 +12:00
tronikos 41e34c19eb [template] Stop water heater republishing when a temperature is unknown (#19013) 2026-09-14 13:36:39 +12:00
Jeff Brown a0821c225a [pmsa003i] Fix read from uninitialized stack memory (#19053) 2026-09-14 13:18:52 +12:00
Jesse Hills 7fe0689fb8 Bump version to 2026.9.0b4 2026-09-14 13:12:27 +12:00
J. Nick Koston 93fa95c833 [api] Reuse overflow buffer storage instead of allocating per stalled write (#19093) 2026-09-14 13:12:26 +12:00
Jesse Hills 501009073d [core] Clear loaded_platforms on CORE.reset() (#19268) 2026-09-14 13:12:26 +12:00
J. Nick Koston 81a54ea9db [ota] Allocate the signature block through RAMAllocator (#19251) 2026-09-14 13:12:26 +12:00
J. Nick Koston e28b4eb2a0 [ethernet] Keep the W5500 SPI context in a static instance instead of the heap (#19248) 2026-09-14 13:12:26 +12:00
J. Nick Koston ebe72c3cef [core] Resolve file paths against the YAML file that declares them (#19259) 2026-09-14 13:12:26 +12:00
J. Nick Koston 977542061d [esphome] Allocate the OTA noise session and auth buffer through RAMAllocator (#19249) 2026-09-14 13:12:26 +12:00
J. Nick Koston b0b75f705a [nextion] Allocate queue components through RAMAllocator and free entries the way they were allocated (#19246) 2026-09-14 13:12:25 +12:00
J. Nick Koston be2c3dda94 [esp32_ble_tracker] Revert coexistence preference to balanced when OTA starts (#19082) 2026-09-14 13:12:25 +12:00
Kevin Ahrendt 64fd87f436 [i2s_audio][router] Loop thread controls all state changes (#19089) 2026-09-14 13:12:25 +12:00
J. Nick Koston cd5d4ff254 [core] Add RAMAllocator::make_unique for objects whose allocation may fail (#19245) 2026-09-14 13:12:25 +12:00
J. Nick Koston 1a555d5848 [esp32_ble_tracker] Re-register GATT clients after ble.disable and ble.enable (#19068) 2026-09-14 13:12:25 +12:00
J. Nick Koston b17cd89469 [wifi] Drop a scan instead of aborting when its results cannot be allocated, filter ESP32 scans by SSID in the driver (#19254) 2026-09-14 13:12:25 +12:00
esphome[bot] 9064bfcc85 Bump bundled esphome-device-builder to 1.14.9 (#19263) 2026-09-14 13:12:25 +12:00
J. Nick Koston 0803d7b37c [core] Add FixedVector::try_init so callers can handle an exhausted heap (#19253) 2026-09-14 13:12:25 +12:00
J. Nick Koston 9814966fe7 [noise] Bump noise-c to 0.1.30 and libsodium to 1.10021.11 (#19062) 2026-09-14 13:12:25 +12:00
esphome[bot] 3eda3060b8 Bump bundled esphome-device-builder to 1.14.8 (#19250) 2026-09-14 13:12:25 +12:00
Clyde Stubbs 6cc2b9bf17 [lvgl] Fix crash when using lvgl.list.add (#19177) 2026-09-14 13:12:25 +12:00
esphome[bot] 1975b17eac Bump bundled esphome-device-builder to 1.14.7 (#19096) 2026-09-14 13:12:25 +12:00
J. Nick Koston 3b499ecb3e [core] Support set_internal() during setup, log error after setup (#19069) 2026-09-14 13:12:25 +12:00
esphome[bot] 6bd6603d52 Bump bundled esphome-device-builder to 1.14.6 (#19072) 2026-09-14 13:12:25 +12:00
Robin ThoniandJesse Hills ad4ee1d34e [network] Improve network::is_connected() to better handle multiple interfaces (#18999)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-14 13:12:25 +12:00
Remco van Essen ccec6e72bf [sendspin] Fix codec enum codegen when codecs is not set (#19055) 2026-09-14 13:12:25 +12:00
J. Nick Koston 91b1a82a66 [api] Reuse overflow buffer storage instead of allocating per stalled write (#19093) 2026-09-14 13:03:00 +12:00
Jesse Hills 7801cf4a8a [core] Clear loaded_platforms on CORE.reset() (#19268) 2026-09-14 00:55:13 +00:00
J. Nick Koston e431bfcb38 [spi] Send ESP8266 writes through transferBytes instead of a heap copy (#19265) 2026-09-13 19:28:24 -05:00
J. Nick Koston f5afd141de [ota] Allocate the signature block through RAMAllocator (#19251) 2026-09-14 10:37:04 +12:00
J. Nick Koston e38ee343b4 [ethernet] Keep the W5500 SPI context in a static instance instead of the heap (#19248) 2026-09-14 10:35:38 +12:00
J. Nick Koston cf398ea8b2 [core] Resolve file paths against the YAML file that declares them (#19259) 2026-09-13 17:27:03 -05:00
J. Nick Koston bc1841c1b5 [esphome] Allocate the OTA noise session and auth buffer through RAMAllocator (#19249) 2026-09-13 17:26:28 -05:00
J. Nick Koston ed5a570e17 [nextion] Allocate queue components through RAMAllocator and free entries the way they were allocated (#19246) 2026-09-13 17:26:14 -05:00
J. Nick Koston 16df92212e [esp32_ble_tracker] Revert coexistence preference to balanced when OTA starts (#19082) 2026-09-14 10:02:42 +12:00
Kevin Ahrendt 67871bcf55 [i2s_audio][router] Loop thread controls all state changes (#19089) 2026-09-14 10:01:58 +12:00
J. Nick Koston c51020dbaf [core] Add RAMAllocator::make_unique for objects whose allocation may fail (#19245) 2026-09-14 09:56:50 +12:00
J. Nick Koston eb1ea4aefe [esp32_ble_tracker] Re-register GATT clients after ble.disable and ble.enable (#19068) 2026-09-14 09:49:23 +12:00
J. Nick Koston b1bfc512ac [wifi] Drop a scan instead of aborting when its results cannot be allocated, filter ESP32 scans by SSID in the driver (#19254) 2026-09-14 09:49:13 +12:00
esphome[bot] ce8fad1435 Bump bundled esphome-device-builder to 1.14.9 (#19263) 2026-09-13 21:44:03 +00:00
J. Nick Koston 4f0faac148 [core] Add FixedVector::try_init so callers can handle an exhausted heap (#19253) 2026-09-14 09:16:37 +12:00
J. Nick Koston fc8611a212 [noise] Bump noise-c to 0.1.30 and libsodium to 1.10021.11 (#19062) 2026-09-14 09:14:26 +12:00
6b08aa60e6 [bluetooth_proxy] Add an advertisement filter hook (#19220)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-09-13 16:25:27 +00:00
esphome[bot] eea66fc32b Bump bundled esphome-device-builder to 1.14.8 (#19250) 2026-09-13 08:06:35 -05:00
Clyde Stubbs 5bbfe12e4f [core] Isolate contextvars per task in the coroutine runner (#19238) 2026-09-13 13:38:27 +10:00
Clyde Stubbs 533002c41e [lvgl] Fix crash when using lvgl.list.add (#19177) 2026-09-13 06:12:08 +10:00
ebb9037ea1 [bridge] New component and cdc_acm_uart platform (#11689)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-09-11 22:08:23 -05:00
J. Nick Koston 9a7ab80c43 [uart] Clear the driver installed flag only after a successful delete
A failed uart_driver_delete leaves the old driver installed and
working; clearing the flag beforehand would gate off a live driver.
2026-08-16 21:16:53 -05:00
J. Nick Koston 575311f0e8 [uart] Re-arm the dropped write warning and unify the readiness predicate
Reset the one-shot warning when the driver is reinstalled so a later
not-installed episode is loud again, and use the driver installed flag
in the rx threshold and timeout setters so the file carries a single
readiness predicate.
2026-08-16 20:27:20 -05:00
J. Nick Koston 11b37e1861 [uart] Order members largest to smallest to reduce padding 2026-08-16 19:30:59 -05:00
J. Nick Koston 752f36458b [uart] Gate I/O on driver installation instead of component state
Track driver installation with a dedicated flag. Component state was
the wrong predicate on both ends: before setup uart_num_ is not yet
assigned so uart_is_driver_installed() could alias another bus, and
after a runtime failure the installed driver kept working, so gating
on is_ready() turned mark_failed() into a permanent bus shutdown that
load_settings() could not revive. Also throttle the dropped write
warning to a single line since consumers writing from loop() can hit
it on every iteration of the setup phase wait loops.
2026-08-16 19:27:16 -05:00
J. Nick Koston d98484c283 [uart] Report no data available while the bus is not ready
A stale peeked byte was still counted by available() while the read
paths refused to deliver it, so a caller looping on available() would
spin forever.
2026-08-16 18:39:23 -05:00
J. Nick Koston 7f9636b7f2 [uart] Guard ESP-IDF UART operations before the driver is installed
A component at the same setup priority could write to the bus before
uart_driver_install() ran; the failed write marked the UART component
failed, its setup was then skipped, and the bus never came up. Guard
the driver calls on is_ready() so early use is dropped instead.
2026-08-16 17:32:55 -05:00
134 changed files with 3463 additions and 649 deletions
+23
View File
@@ -9,6 +9,29 @@ body:
If you have a feature request or enhancement, please [request them here instead][fr].
[fr]: https://github.com/orgs/esphome/discussions
- type: markdown
attributes:
value: |
## Use of AI in bug reports
AI tools are good at carrying out well-defined tasks, but they are not good at troubleshooting.
Please do NOT paste an AI-generated wall of text into the issue template - if the AI hasn't solved
your problem, its wild guesses are not likely to help.
Please DO include your own words and observations, compile/boot logs, and
especially a minimal reproducible example of your YAML configuration that demonstrates the problem.
It is however quite acceptable to use AI to translate your *own* report,
if you aren't a competent English speaker.
If you really think it will be useful to include an AI's analysis, preferably wrap it in a `<details>` block which will be collapsed by default.
If you are using AI to help solve a problem, rather than asking it to speculate about what the problem is,
it can be more useful to ask it to create a step-by-step troubleshooting procedure.
AI is also useful for generating boilerplate code, such as a minimal reproducible example of your YAML
configuration that demonstrates the problem.
Used properly, AI can be a useful tool to help you solve your problem, but don't let it get in the way.
- type: textarea
validations:
required: true
+3
View File
@@ -100,6 +100,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bridge/* @kbx81
esphome/components/bthome_mithermometer/* @nagyrobi
esphome/components/button/* @esphome/core
esphome/components/bytebuffer/* @clydebarrow
@@ -111,6 +112,8 @@ esphome/components/captive_portal/* @esphome/core
esphome/components/cc1101/* @gabest11 @lygris
esphome/components/ccs811/* @habbie
esphome/components/cd74hc4067/* @asoehlke
esphome/components/cdc_acm_uart/* @kbx81
esphome/components/cdc_acm_uart/bridge/* @kbx81
esphome/components/ch422g/* @clydebarrow @jesterret
esphome/components/ch423/* @dwmw2
esphome/components/chsc6x/* @kkosik20
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.7
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.9
RUN \
platformio settings set enable_telemetry No \
+2 -3
View File
@@ -350,10 +350,9 @@ CONFIG_SCHEMA = cv.All(
ln882x=5, # Moderate RAM
nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller)
): cv.int_range(min=1, max=20),
# Maximum queued send buffers per connection before dropping connection
# Each buffer uses ~8-12 bytes overhead plus actual message size
# Max queued messages per connection, and 2 KB of backlog per slot up
# to 64 KB (a lone message is exempt), before the connection is dropped
# Platform defaults based on available RAM and typical message rates:
# CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo
cv.SplitDefault(
CONF_MAX_SEND_QUEUE,
esp8266=4, # Limited RAM, need to fail fast
+26 -9
View File
@@ -1,20 +1,37 @@
#include "api_buffer.h"
#include <new>
#ifdef ESPHOME_DEBUG_API
#include "esphome/core/log.h"
#endif
namespace esphome::api {
#ifdef ESPHOME_DEBUG_API
void APIBuffer::debug_check_drop_(size_t drop) const {
if (drop > this->size_) {
ESP_LOGE("api.buffer", "drop_front: drop=%zu size=%u", drop, this->size_);
abort();
}
}
#endif
bool APIBuffer::grow_(size_t n) {
// nothrow (no zero-fill) so OOM is reportable; plain new aborts instead
// (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF).
// RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory.
std::unique_ptr<uint8_t[]> new_data(new (std::nothrow) uint8_t[n]);
if (new_data == nullptr)
if (n > MAX_SIZE)
return false;
if (this->size_)
std::memcpy(new_data.get(), this->data_.get(), this->size_);
this->data_ = std::move(new_data);
// realloc extends in place when it can, avoiding the copy
uint8_t *grown = RAMAllocator<uint8_t>().reallocate(this->data_.get(), n);
if (grown == nullptr)
return false;
(void) this->data_.release(); // realloc already freed or reused the old block
this->data_.reset(grown);
this->capacity_ = n;
return true;
}
uint8_t *APIBuffer::append(size_t n) {
const size_t old_size = this->size_;
if (!this->resize(old_size + n))
return nullptr;
return this->data_.get() + old_size;
}
} // namespace esphome::api
+20 -4
View File
@@ -25,6 +25,7 @@ namespace esphome::api {
/// writes in debug builds.
class APIBuffer {
public:
static constexpr size_t MAX_SIZE = UINT16_MAX; // API frames carry 16 bit lengths
void clear() { this->size_ = 0; }
/// Returns false if allocation fails; the buffer is left unchanged.
[[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); }
@@ -36,9 +37,19 @@ class APIBuffer {
[[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
if (!this->reserve(std::max(reserve_size, new_size)))
return false;
this->size_ = new_size;
this->size_ = static_cast<uint16_t>(new_size);
return true;
}
/// Grow by n bytes; returns the new bytes, or nullptr on allocation failure.
[[nodiscard]] uint8_t *append(size_t n);
/// Drop the first `drop` bytes, sliding the rest down. Precondition: drop <= size().
void drop_front(size_t drop) {
#ifdef ESPHOME_DEBUG_API
this->debug_check_drop_(drop);
#endif
this->size_ -= drop;
std::memmove(this->data_.get(), this->data_.get() + drop, this->size_);
}
uint8_t *data() { return this->data_.get(); }
const uint8_t *data() const { return this->data_.get(); }
size_t size() const { return this->size_; }
@@ -55,9 +66,14 @@ class APIBuffer {
protected:
bool grow_(size_t n);
std::unique_ptr<uint8_t[]> data_;
size_t size_{0};
size_t capacity_{0};
#ifdef ESPHOME_DEBUG_API
void debug_check_drop_(size_t drop) const;
#endif
// RAMAllocator: PSRAM when available, and it reports failure where
// new (std::nothrow) still aborts on ESP-IDF without exceptions
RAMUniquePtr<uint8_t[]> data_;
uint16_t size_{0};
uint16_t capacity_{0};
};
} // namespace esphome::api
+4 -1
View File
@@ -364,7 +364,10 @@ void APIConnection::check_keepalive_(uint32_t now) {
ESP_LOGVV(TAG, "Sending keepalive PING");
PingRequest req;
this->flags_.sent_ping = this->send_message(req);
if (!this->flags_.sent_ping) {
if (this->flags_.sent_ping) {
// Quiet for a keepalive period and the ping is on its way: a one-off stall's storage can go
this->helper_->release_overflow_buffer();
} else {
// If we can't send the ping request directly (tx_buffer full),
// schedule it at the front of the batch so it will be sent with priority
ESP_LOGW(TAG, "Buffer full, ping queued");
+1 -1
View File
@@ -171,7 +171,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin
return APIError::OK;
// Queue unsent data into overflow buffer
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent))) {
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, sent)) {
HELPER_LOG("Overflow buffer full or out of memory, dropping connection");
this->state_ = State::FAILED;
return APIError::SOCKET_WRITE_FAILED;
@@ -219,7 +219,10 @@ class APIFrameHelper {
if (this->rx_buf_len_ == 0) {
this->rx_buf_.release();
}
this->release_overflow_buffer();
}
// Free the send backlog storage once it has drained
void release_overflow_buffer() { this->overflow_buf_.release(); }
protected:
// Drain backlogged overflow data to the socket and handle errors.
@@ -67,15 +67,15 @@ APIError APINoiseFrameHelper::init() {
}
// init prologue
size_t old_size = prologue_.size();
if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] {
uint8_t *dst = prologue_.append(PROLOGUE_INIT_LEN);
if (dst == nullptr) [[unlikely]] {
state_ = State::FAILED;
return APIError::OUT_OF_MEMORY;
}
#ifdef USE_ESP8266
memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
#else
std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
#endif
state_ = State::CLIENT_HELLO;
@@ -272,17 +272,17 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
return handle_handshake_frame_error_(aerr);
}
// ignore contents, may be used in future for flags
// Resize for: existing prologue + 2 size bytes + frame data
size_t old_size = this->prologue_.size();
// Append 2 size bytes + frame data to the prologue
size_t rx_size = this->rx_buf_.size();
if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] {
uint8_t *dst = this->prologue_.append(2 + rx_size);
if (dst == nullptr) [[unlikely]] {
state_ = State::FAILED;
return APIError::OUT_OF_MEMORY;
}
this->prologue_[old_size] = (uint8_t) (rx_size >> 8);
this->prologue_[old_size + 1] = (uint8_t) rx_size;
dst[0] = (uint8_t) (rx_size >> 8);
dst[1] = (uint8_t) rx_size;
if (rx_size > 0) {
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size);
std::memcpy(dst + 2, this->rx_buf_.data(), rx_size);
}
state_ = State::SERVER_HELLO;
+57 -64
View File
@@ -1,98 +1,91 @@
#include "api_overflow_buffer.h"
#ifdef USE_API
#include <cstring>
#include <new>
namespace esphome::api {
APIOverflowBuffer::~APIOverflowBuffer() {
for (auto *entry : this->queue_) {
if (entry != nullptr)
Entry::destroy(entry);
}
}
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
// socket->write() can re-enter this function: a log message emitted from an
// lwip callback during the write goes out over the API and lands back in the
// frame helper's write/drain path. If a nested drain ran here it would send
// and free the entry the outer drain is still holding, causing a double free.
// Report "no progress" instead; the outer drain keeps draining, and the
// nested send is enqueued behind the existing backlog.
// Nested call from inside socket->write(); see draining_
if (this->draining_)
return 0;
// RAII so the flag is cleared on every return path
struct DrainGuard {
explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; }
~DrainGuard() { this->flag_ = false; }
bool &flag_;
} guard(this->draining_);
APIOverflowBuffer &owner;
~DrainGuard() { this->owner.draining_ = false; }
} guard{*this};
this->draining_ = true;
while (this->count_ > 0) {
Entry *front = this->queue_[this->head_];
uint8_t *msg = this->buf_.data() + this->head_;
size_t len = msg[0] | (msg[1] << 8);
ssize_t sent = socket->write(front->current_data(), front->remaining());
if (sent <= 0) {
// -1 = error (caller checks errno for EWOULDBLOCK vs hard error)
// 0 = nothing sent (treat as no progress)
ssize_t sent = socket->write(msg + LEN_PREFIX, len);
if (sent <= 0)
return sent;
if (static_cast<size_t>(sent) < len) {
// Step past the sent bytes and rewrite the prefix there; it lands on bytes already sent
this->head_ += sent;
len -= sent;
msg += sent;
msg[0] = len;
msg[1] = len >> 8;
return sent;
}
if (static_cast<uint16_t>(sent) < front->remaining()) {
// Partially sent, update offset and stop
front->offset += static_cast<uint16_t>(sent);
return sent;
}
// Entry fully sent — unlink it before freeing so a freed pointer is never
// reachable from the queue
this->queue_[this->head_] = nullptr;
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
this->head_ += LEN_PREFIX + len;
this->count_--;
Entry::destroy(front);
}
return 0; // All drained
this->head_ = 0;
if (this->release_when_drained_) {
this->release_when_drained_ = false;
this->buf_.release();
} else {
this->buf_.clear();
}
return 0;
}
bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) {
bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip) {
if (this->count_ >= API_MAX_SEND_QUEUE)
return false;
uint16_t buffer_size = total_len - skip;
// nothrow: a failed allocation returns nullptr so the connection is dropped
// cleanly instead of plain new's crash or abort on OOM
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
auto *data = new (std::nothrow) uint8_t[buffer_size];
if (data == nullptr)
return false;
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
auto *entry = new (std::nothrow) Entry{data, buffer_size, 0};
if (entry == nullptr) {
delete[] data;
const size_t new_len = total_len - skip;
const size_t new_bytes = LEN_PREFIX + new_len;
const size_t live = this->buf_.size() - this->head_;
// A lone message is only bound by the buffer; refusing it would just drop the connection
if (live + new_bytes > (this->count_ > 0 ? MAX_BYTES : MAX_LONE_BYTES))
return false;
if (this->buf_.size() + new_bytes > this->buf_.capacity()) {
// Storage would move under an outer drain's write()
if (this->draining_)
return false;
if (this->head_ > 0) {
// Reclaim the sent prefix before growing
this->buf_.drop_front(this->head_);
this->head_ = 0;
}
if (!this->buf_.reserve(reserve_for(live + new_bytes)))
return false;
}
uint16_t to_skip = skip;
uint16_t write_pos = 0;
for (int i = 0; i < iovcnt; i++) {
if (to_skip >= iov[i].iov_len) {
to_skip -= static_cast<uint16_t>(iov[i].iov_len);
uint8_t *dst = this->buf_.append(new_bytes);
if (dst == nullptr)
return false;
dst[0] = new_len;
dst[1] = new_len >> 8;
dst += LEN_PREFIX;
for (const struct iovec *end = iov + iovcnt; iov != end; iov++) {
if (skip >= iov->iov_len) {
skip -= iov->iov_len;
} else {
const uint8_t *src = reinterpret_cast<uint8_t *>(iov[i].iov_base) + to_skip;
uint16_t len = static_cast<uint16_t>(iov[i].iov_len) - to_skip;
std::memcpy(entry->data + write_pos, src, len);
write_pos += len;
to_skip = 0;
const size_t len = iov->iov_len - skip;
std::memcpy(dst, static_cast<const uint8_t *>(iov->iov_base) + skip, len);
dst += len;
skip = 0;
}
}
// Publish only after the copy completes so a half-built entry is never reachable
this->queue_[this->tail_] = entry;
this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE;
this->count_++;
return true;
}
+40 -53
View File
@@ -1,5 +1,6 @@
#pragma once
#include <array>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <sys/types.h>
@@ -8,71 +9,57 @@
#include "esphome/components/socket/headers.h"
#include "esphome/components/socket/socket.h"
#include "esphome/core/helpers.h"
#include "api_buffer.h"
namespace esphome::api {
/// Circular queue of heap-allocated byte buffers used as a TCP send backlog.
///
/// Under normal operation this buffer is **never used** — data goes straight
/// from the frame helper to the socket. It only fills when the LWIP TCP
/// send buffer is full (slow client, congested network, heavy logging).
/// The queue drains automatically on subsequent write/loop calls once the
/// socket becomes writable again.
///
/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python
/// config). If the queue fills completely the connection is marked failed.
/// TCP send backlog, only used when the socket send buffer is full.
/// One contiguous buffer per connection, allocated on the first stall and
/// kept at its high-water mark so a lossy link does not churn the heap.
/// Messages are stored as a 2 byte length prefix plus payload.
/// API_MAX_SEND_QUEUE bounds queued messages and, at 2 KB per slot, queued
/// bytes; exceeding either fails the connection.
class APIOverflowBuffer {
public:
/// A single heap-allocated send-backlog entry.
/// Lifetime is manually managed — see destroy().
struct Entry {
uint8_t *data;
uint16_t size; // Total size of the buffer
uint16_t offset; // Current send offset within the buffer
uint16_t remaining() const { return this->size - this->offset; }
const uint8_t *current_data() const { return this->data + this->offset; }
/// Free this entry and its data buffer.
static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) {
delete[] entry->data;
delete entry; // NOLINT(cppcoreguidelines-owning-memory)
}
};
~APIOverflowBuffer();
/// True when no backlogged data is waiting.
bool empty() const { return this->count_ == 0; }
/// True when the queue has no room for another entry.
bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; }
/// Number of entries currently queued.
uint8_t count() const { return this->count_; }
/// Try to drain queued data to the socket.
/// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress,
/// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors).
/// Callers only need to act on -1; 0 and positive values both mean "no error".
/// Frees entries as they are fully sent.
/// Drain queued messages to the socket.
/// Returns bytes written, 0 for a re-entrant call, -1 on error (check errno
/// for EWOULDBLOCK); callers only need to act on -1.
ssize_t try_drain(socket::Socket *socket);
/// Enqueue unsent IOV data into the backlog.
/// Copies iov data starting at byte offset `skip` into a new entry.
/// Returns false if the queue is full or allocation fails (caller should fail the connection).
bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip);
/// Queue iov data from byte offset `skip` as one message.
/// Returns false when a limit is hit, allocation fails, or storage would move
/// during a drain; the caller should fail the connection.
bool enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip);
/// Free the retained storage, now if empty, otherwise once it has drained.
void release() {
if (this->count_ == 0) {
this->buf_.release();
} else {
this->release_when_drained_ = true;
}
}
protected:
std::array<Entry *, API_MAX_SEND_QUEUE> queue_{};
uint8_t head_{0};
uint8_t tail_{0};
static constexpr size_t LEN_PREFIX = 2;
static constexpr size_t BYTES_PER_SLOT = 2048;
// Reserve in 256 byte steps so a creeping high-water mark settles quickly
static constexpr size_t GROW_QUANTUM = 256;
// Lone message ceiling, rounded down so reserve_for() never exceeds the buffer limit
static constexpr size_t MAX_LONE_BYTES = APIBuffer::MAX_SIZE & ~(GROW_QUANTUM - 1);
static constexpr size_t MAX_BYTES = std::min(API_MAX_SEND_QUEUE * BYTES_PER_SLOT, MAX_LONE_BYTES);
static constexpr size_t reserve_for(size_t want) { return (want + GROW_QUANTUM - 1) & ~(GROW_QUANTUM - 1); }
APIBuffer buf_;
uint16_t head_{0}; // offset of the front message's length prefix; bytes before it are sent
uint8_t count_{0};
// Guards against re-entrant drains: socket->write() can re-enter the API
// send path (e.g. a log message emitted from an lwip callback), and a nested
// drain would free the entry the outer drain is still holding.
bool draining_{false};
// socket->write() can re-enter the send path (log from an lwip callback):
// a nested drain makes no progress and a nested enqueue never moves storage
bool draining_ : 1 {false};
bool release_when_drained_ : 1 {false};
};
} // namespace esphome::api
+1 -4
View File
@@ -339,10 +339,7 @@ async def to_code(config: ConfigType) -> None:
# HTTPS streams verify the server against the root certificate bundle
require_certificate_bundle()
add_idf_component(
name="esphome/esp-audio-libs",
ref="3.2.1",
)
add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0")
data = _get_data()
+3 -1
View File
@@ -452,7 +452,9 @@ _BINARY_SENSOR_SCHEMA = (
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(
CONF_FILTERS, visibility=cv.Visibility.ADVANCED
): validate_filters,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
cv.Optional(CONF_ON_CLICK): cv.All(
@@ -45,15 +45,7 @@ void BluedroidGattClient::setup() {
void BluedroidGattClient::loop() {
if (!esp32_ble::global_ble->is_active()) {
// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer
// frees its slot, then re-register the app on the next enable.
auto down_st = this->state();
if (down_st != ClientState::IDLE && down_st != ClientState::INIT) {
this->release_services();
this->set_idle_();
this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED);
}
this->set_state(ClientState::INIT);
// ble_before_disabled_event_handler() settles the slot.
return;
}
auto st = this->state();
@@ -65,7 +57,7 @@ void BluedroidGattClient::loop() {
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret);
this->mark_failed();
}
// Do not wait for REG_EVT; a dropped event must not wedge the slot.
// Do not wait for REG_EVT; connect() rejects until it lands.
this->set_idle_();
} else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) {
// The one teardown safety net: a lost CLOSE_EVT, or a scheduled
@@ -78,8 +70,8 @@ void BluedroidGattClient::loop() {
this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT);
}
} else {
// The loop stays on while a link exists (stack-down watch, pre-started
// search flush); it settles only back at IDLE.
// The loop stays on while a link exists (pre-started search flush); it
// settles only back at IDLE.
this->deliver_pending_search_();
if (this->state() == ClientState::IDLE) {
this->disable_loop();
@@ -87,6 +79,22 @@ void BluedroidGattClient::loop() {
}
}
// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer
// frees its slot, then register the app again on the next enable.
void BluedroidGattClient::ble_before_disabled_event_handler() {
auto st = this->state();
if (st != ClientState::IDLE && st != ClientState::INIT) {
this->release_services();
this->set_idle_();
this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED);
}
// The interface belongs to the torn-down stack.
this->gattc_if_ = ESP_GATT_IF_NONE;
this->set_state(ClientState::INIT);
// An idle slot runs no loop; the INIT branch must run to register again.
this->enable_loop();
}
void BluedroidGattClient::dump_config() {
ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_);
if (this->is_failed()) {
@@ -97,6 +105,11 @@ void BluedroidGattClient::dump_config() {
// ---- contract ops ----
int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
if (this->gattc_if_ == ESP_GATT_IF_NONE) {
// Bluedroid drops an open on an unknown interface without any event.
ESP_LOGW(TAG, "[%d] Connect rejected, GATT app not registered", this->connection_index_);
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
// Only from idle: clobbering DISCONNECTING would open a new link the
// stale CLOSE_EVT then tears down.
if (this->state() != ClientState::IDLE) {
@@ -56,6 +56,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
void connect() override;
void disconnect() override;
void ble_before_disabled_event_handler() override;
bool wants_parsed_advertisements() override { return false; }
void on_scan_end() override {}
bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; }
@@ -395,6 +395,17 @@ async def _to_code_ble_hub(config: ConfigType) -> None:
await _connections_to_code(var, config)
def enable_advertisement_filter() -> None:
"""Compile the advertisement filter hook into bluetooth_proxy.
Called by external filtering components from to_code(). The define behind
this is an implementation detail; do not emit it directly.
Public API for external components. Do not remove.
"""
cg.add_define("USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER")
async def to_code(config: ConfigType) -> None:
if CORE.is_esp32:
await _to_code_esp32(config)
@@ -94,6 +94,15 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return;
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
// Ask the filter before the packet is queued, so a dropped advertisement never
// reaches the batch or the network.
if (this->advertisement_filter_.is_set() && !this->advertisement_filter_.should_forward(raw)) {
ESP_LOGVV(TAG, "Filtered packet from %012" PRIX64, raw.address);
return;
}
#endif
auto &adv = this->response_.advertisements[this->response_.advertisements_len];
adv.address = raw.address;
adv.rssi = raw.rssi;
@@ -184,6 +193,9 @@ void BluetoothProxy::dump_config() {
" Adapter MAC: %s",
scan_mode, mac_out);
#endif
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
ESP_LOGCONFIG(TAG, " Advertisement filter: %s", YESNO(this->advertisement_filter_.is_set()));
#endif
}
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
@@ -97,6 +97,29 @@ static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x000011223344556
static_assert(PendingReply{}.empty());
#endif
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
/// Predicate slot letting an external component drop advertisements before they
/// are queued for the API. Same shape as
/// ble_device_base::RawAdvertisementCallback. Runs on the advertisement hot
/// path, so it must be cheap and must not block.
///
/// Usage:
/// proxy->set_advertisement_filter({this, [](void *self, const ble_device_base::RawAdvertisement &adv) {
/// return static_cast<MyFilter *>(self)->should_forward(adv);
/// }});
///
/// Returning false drops the advertisement. Not called at all while the API is
/// disconnected, which matters to a stateful filter. Compiled in only when an
/// external component calls bluetooth_proxy.enable_advertisement_filter().
struct AdvertisementFilter {
void *instance{nullptr};
bool (*fn)(void *instance, const ble_device_base::RawAdvertisement &adv){nullptr};
/// A default-constructed slot is "no filter"; the proxy guards on this.
bool is_set() const { return this->fn != nullptr; }
bool should_forward(const ble_device_base::RawAdvertisement &adv) const { return this->fn(this->instance, adv); }
};
#endif // USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
class BluetoothProxy final : public Component {
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// Allow the connection to update connections_free_response_
@@ -162,6 +185,11 @@ class BluetoothProxy final : public Component {
void set_active(bool active) { this->active_ = active; }
bool has_active() { return this->active_; }
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
/// One subscriber; a later call replaces an earlier one.
void set_advertisement_filter(AdvertisementFilter filter) { this->advertisement_filter_ = filter; }
#endif
uint32_t get_legacy_version() const {
if (!this->active_) {
return LEGACY_PASSIVE_ONLY_VERSION;
@@ -330,6 +358,10 @@ class BluetoothProxy final : public Component {
// start on an even word, closing two alignment holes.
uint32_t last_advertisement_flush_time_{0};
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
AdvertisementFilter advertisement_filter_{};
#endif
// BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_;
+4
View File
@@ -0,0 +1,4 @@
CODEOWNERS = ["@kbx81"]
DOMAIN = "bridge"
IS_PLATFORM_COMPONENT = True
@@ -0,0 +1 @@
CODEOWNERS = ["@kbx81"]
@@ -0,0 +1,114 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import esp32, uart, usb_cdc_acm
from esphome.components.bridge import DOMAIN as BRIDGE_DOMAIN
from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3
import esphome.config_validation as cv
from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@kbx81"]
DEPENDENCIES = ["tinyusb", "uart", "usb_cdc_acm"]
CONF_DTR_PIN = "dtr_pin"
CONF_RTS_PIN = "rts_pin"
CONF_USB_CDC_ACM_ID = "usb_cdc_acm_id"
cdc_acm_uart_ns = cg.esphome_ns.namespace("cdc_acm_uart")
CDCACMUARTBridge = cdc_acm_uart_ns.class_("CDCACMUARTBridge", cg.Component)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CDCACMUARTBridge),
cv.Required(CONF_UART_ID): cv.use_id(uart.IDFUARTComponent),
cv.Required(CONF_USB_CDC_ACM_ID): cv.use_id(usb_cdc_acm.USBCDCACMInstance),
cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema,
}
).extend(cv.COMPONENT_SCHEMA),
# Narrower than usb_cdc_acm's variant list on purpose: S31/H4 untested on
# hardware; extend once verified.
esp32.only_on_variant(
supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3],
),
)
def _subtree_references_uart(node: object, uart_id: str) -> bool:
"""Return True if any dict in the subtree has a uart_id entry naming this bus."""
if isinstance(node, dict):
return any(
(key == CONF_UART_ID and str(value) == uart_id)
or _subtree_references_uart(value, uart_id)
for key, value in node.items()
)
if isinstance(node, list):
return any(_subtree_references_uart(item, uart_id) for item in node)
return False
def _reject_debug(uart_conf: ConfigType) -> ConfigType:
# The worker tasks use the IDF driver directly, so the uart debugger never sees
# bridge traffic and its dummy_receiver would drain RX bytes on the main loop.
if CONF_DEBUG in uart_conf:
raise cv.Invalid(
"A bridged UART cannot use 'debug'; the bridge bypasses the UART "
"component's read/write path.",
[CONF_DEBUG],
)
return uart_conf
def _final_validate(config: ConfigType) -> ConfigType:
full_config = fv.full_config.get()
# Bridges of any platform must own their interfaces exclusively; shared ring
# buffers and overwritten callbacks would corrupt both streams silently. The
# seen-set is keyed on the bridge domain so future platforms share it.
# Other components bind either interface through the same uart_id key (the CDC
# instance is itself a uart::UARTComponent) and would race the worker tasks.
# Bare `id:` references (a uart.write action) cannot be distinguished; not caught.
data = full_config.data.setdefault(BRIDGE_DOMAIN, {})
for conf_key, label in (
(CONF_UART_ID, "UART"),
(CONF_USB_CDC_ACM_ID, "USB CDC-ACM interface"),
):
owned_id = str(config[conf_key])
used = data.setdefault(conf_key, set())
if owned_id in used:
raise cv.Invalid(
f"The {label} '{owned_id}' is already bridged by another 'bridge' "
f"instance; each bridge requires its own {label}.",
[conf_key],
)
used.add(owned_id)
for domain, domain_conf in full_config.items():
if domain == BRIDGE_DOMAIN:
continue
if _subtree_references_uart(domain_conf, owned_id):
raise cv.Invalid(
f"The {label} '{owned_id}' is also used by '{domain}'; a bridge "
f"requires exclusive use of its {label}.",
[conf_key],
)
fv.id_declaration_match_schema(_reject_debug)(config[CONF_UART_ID])
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
uart_component = await cg.get_variable(config[CONF_UART_ID])
usb_cdc = await cg.get_variable(config[CONF_USB_CDC_ACM_ID])
var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc)
await cg.register_component(var, config)
if dtr_pin_config := config.get(CONF_DTR_PIN):
dtr_pin = await cg.gpio_pin_expression(dtr_pin_config)
cg.add(var.set_dtr_pin(dtr_pin))
if rts_pin_config := config.get(CONF_RTS_PIN):
rts_pin = await cg.gpio_pin_expression(rts_pin_config)
cg.add(var.set_rts_pin(rts_pin))
@@ -0,0 +1,468 @@
#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
#include "cdc_acm_uart_bridge.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/ringbuf.h"
#include "driver/uart.h"
#include "soc/soc_caps.h"
namespace esphome::cdc_acm_uart {
static const char *const TAG = "cdc_acm_uart";
static constexpr size_t UART_TASK_STACK_SIZE = 4096;
static constexpr size_t RINGBUF_RETRY_CHUNK_SIZE = 64;
static constexpr uint32_t LOG_THROTTLE_MS = 1000;
static constexpr uint32_t UART_RELOAD_SETTLE_MS = 20;
// Above the default priority but below the USB/Wi-Fi system tasks.
static constexpr UBaseType_t TASK_PRIORITY = 4;
static bool should_log_now(uint32_t *last_ms, uint32_t interval_ms) {
uint32_t now = millis();
if ((now - *last_ms) >= interval_ms) {
*last_ms = now;
return true;
}
return false;
}
static bool ringbuf_send_with_retry(RingbufHandle_t ringbuf, const uint8_t *data, size_t len, uint32_t *log_ms) {
if (len == 0) {
return true;
}
if (xRingbufferSend(ringbuf, data, len, pdMS_TO_TICKS(1)) == pdTRUE) {
return true;
}
size_t offset = 0;
while (offset < len) {
size_t chunk = std::min(RINGBUF_RETRY_CHUNK_SIZE, len - offset);
if (xRingbufferSend(ringbuf, data + offset, chunk, pdMS_TO_TICKS(1)) != pdTRUE) {
if (should_log_now(log_ms, LOG_THROTTLE_MS)) {
ESP_LOGW(TAG, "USB TX buffer full; some data is lost");
}
return false;
}
offset += chunk;
}
return true;
}
void CDCACMUARTBridge::setup() {
// Line state starts deasserted (no host yet); active-low DTR#/RTS# wiring is
// handled by configuring the pins inverted, so deasserted idles HIGH.
if (this->dtr_pin_ != nullptr) {
this->dtr_pin_->setup();
this->dtr_pin_->digital_write(false);
}
if (this->rts_pin_ != nullptr) {
this->rts_pin_->setup();
this->rts_pin_->digital_write(false);
}
// A failed UART never assigned its port number, so the worker tasks would run
// against an indeterminate port.
if (this->uart_parent_->is_failed()) {
ESP_LOGE(TAG, "UART parent failed; aborting");
this->mark_failed();
return;
}
this->configured_baud_rate_ = this->uart_parent_->get_baud_rate();
this->configured_parity_ = this->uart_parent_->get_parity();
this->configured_stop_bits_ = this->uart_parent_->get_stop_bits();
this->configured_data_bits_ = this->uart_parent_->get_data_bits();
// usb_cdc_acm sets up first (priority IO > HARDWARE). Any interface failing marks
// the hub failed, and a failed hub no longer runs loop(), so line coding and line
// state events would never reach this bridge even if its own interface is healthy.
if (this->usb_cdc_parent_->get_parent()->is_failed()) {
ESP_LOGE(TAG, "USB CDC ACM failed; aborting");
this->mark_failed();
return;
}
// Per-instance task names (keyed on the CDC interface number) keep task dumps
// unambiguous with multiple bridges.
char tx_task_name[] = "cdc_uart_tx_0";
char rx_task_name[] = "cdc_uart_rx_0";
const char itf_char = format_hex_char(this->usb_cdc_parent_->get_itf());
tx_task_name[sizeof(tx_task_name) - 2] = itf_char;
rx_task_name[sizeof(rx_task_name) - 2] = itf_char;
xTaskCreate(uart_tx_task_fn, tx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_tx_task_handle_);
if (this->uart_tx_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Failed to create UART TX task");
this->mark_failed();
return;
}
xTaskCreate(uart_rx_task_fn, rx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_rx_task_handle_);
if (this->uart_rx_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Failed to create UART RX task");
vTaskDelete(this->uart_tx_task_handle_);
this->uart_tx_task_handle_ = nullptr;
this->mark_failed();
return;
}
// Only register callbacks once both tasks exist, so a failed setup never drives
// DTR/RTS from a dead bridge.
this->usb_cdc_parent_->set_line_state_callback([this](bool dtr, bool rts) { this->set_line_state(dtr, rts); });
this->usb_cdc_parent_->set_line_coding_callback([this](uint32_t, uint8_t, uint8_t, uint8_t) {
this->host_coding_seen_ = true;
// Another component owns the UART's framing while paused; resume() re-syncs.
if (this->paused_ == 0) {
this->set_line_coding();
}
});
// Release the workers only now: until here a failed setup may still delete the TX
// task, which is safe only while it is parked and owns nothing in the driver.
xTaskNotifyGive(this->uart_tx_task_handle_);
xTaskNotifyGive(this->uart_rx_task_handle_);
// loop() only services line-coding reloads; stay off the main loop until one is
// scheduled.
this->disable_loop();
}
void CDCACMUARTBridge::dump_config() {
ESP_LOGCONFIG(TAG,
"CDC-ACM UART Bridge:\n"
" UART Bus: %u\n"
" USB CDC Interface: %u",
this->uart_parent_->get_hw_serial_number(), this->usb_cdc_parent_->get_itf());
LOG_PIN(" DTR Pin: ", this->dtr_pin_);
LOG_PIN(" RTS Pin: ", this->rts_pin_);
}
void CDCACMUARTBridge::on_shutdown() {
// The UART (BUS) shuts down after this component (HARDWARE) and deletes its driver,
// freeing the ring buffer and mutexes the worker tasks block on. Suspending the
// tasks unlinks them from those objects first.
if (this->uart_rx_task_handle_ != nullptr) {
vTaskSuspend(this->uart_rx_task_handle_);
}
if (this->uart_tx_task_handle_ != nullptr) {
vTaskSuspend(this->uart_tx_task_handle_);
}
}
void CDCACMUARTBridge::loop() {
switch (this->state_) {
case MainState::MAIN_STATE_RELOAD_PENDING:
if ((App.get_loop_component_start_time() - this->reload_requested_at_) < UART_RELOAD_SETTLE_MS) {
return;
}
// Deliberately not gated on tx_idle_(): a host that re-codes the line mid-stream
// wants the new framing now, and its own in-flight bytes are its concern.
// apply_settings_live() rewrites the framing registers without reinstalling the
// driver, so the worker tasks blocked inside it are undisturbed.
this->uart_parent_->apply_settings_live();
this->state_ = MainState::MAIN_STATE_RUNNING;
break;
case MainState::MAIN_STATE_PAUSING:
case MainState::MAIN_STATE_RESUMING:
// Let a host write that was in flight drain, FIFO included, before a reload
// flushes the FIFOs and truncates it.
if (!this->tx_idle_()) {
return;
}
if (this->state_ == MainState::MAIN_STATE_PAUSING) {
this->restore_configured_framing_();
this->state_ = MainState::MAIN_STATE_PAUSED;
} else {
this->finish_resume_();
}
break;
default:
break;
}
this->disable_loop();
}
void CDCACMUARTBridge::set_line_coding() {
if (!this->sync_host_framing_()) {
return;
}
// Coalesce rapid line-coding updates from the host.
this->reload_requested_at_ = App.get_loop_component_start_time();
this->state_ = MainState::MAIN_STATE_RELOAD_PENDING;
// Main-loop context (via USBCDCACMInstance::process_events_).
this->enable_loop();
}
bool CDCACMUARTBridge::sync_host_framing_() {
// usb_cdc_acm has already translated the wire coding onto the CDC instance (main
// loop); mirror it here so the framing translation has a single source of truth.
bool changed = false;
// Reject 0 (the CDC B0/hang-up encoding; older IDF revisions divide by the rate)
// and rates above the SoC ceiling. Anything in between is the driver's call,
// matching what a YAML-configured UART accepts.
const uint32_t baud = this->usb_cdc_parent_->get_baud_rate();
if (baud == 0 || baud > SOC_UART_BITRATE_MAX) {
ESP_LOGW(TAG, "Ignoring unsupported baud rate %" PRIu32 " from host; keeping %" PRIu32, baud,
this->uart_parent_->get_baud_rate());
} else if (this->uart_parent_->get_baud_rate() != baud) {
this->uart_parent_->set_baud_rate(baud);
changed = true;
}
const uint8_t stop_bits = this->usb_cdc_parent_->get_stop_bits();
if (this->uart_parent_->get_stop_bits() != stop_bits) {
this->uart_parent_->set_stop_bits(stop_bits);
changed = true;
}
const auto parity = this->usb_cdc_parent_->get_parity();
if (this->uart_parent_->get_parity() != parity) {
this->uart_parent_->set_parity(parity);
changed = true;
}
// USB CDC permits data-bit counts the UART cannot represent (up to 16).
const uint8_t data_bits = this->usb_cdc_parent_->get_data_bits();
if (data_bits < 5 || data_bits > 8) {
ESP_LOGW(TAG, "Ignoring unsupported data bits %u from host; keeping %u", data_bits,
this->uart_parent_->get_data_bits());
} else if (this->uart_parent_->get_data_bits() != data_bits) {
this->uart_parent_->set_data_bits(data_bits);
changed = true;
}
if (changed) {
ESP_LOGV(TAG, "Line coding: baud=%" PRIu32 ", data_bits=%u, stop_bits=%u, parity=%u",
this->uart_parent_->get_baud_rate(), this->uart_parent_->get_data_bits(),
this->uart_parent_->get_stop_bits(), static_cast<uint8_t>(this->uart_parent_->get_parity()));
}
return changed;
}
void CDCACMUARTBridge::pause() {
if (this->state_ == MainState::MAIN_STATE_PAUSING || this->state_ == MainState::MAIN_STATE_PAUSED) {
return;
}
this->paused_ = 1;
// A null RX task means setup() has not completed (or failed): nothing to stop, and
// the framing snapshot does not exist yet. Should setup() run later, the RX task
// starts parked.
if (this->uart_rx_task_handle_ == nullptr) {
this->state_ = MainState::MAIN_STATE_PAUSED;
return;
}
// Drops a coalesced host reload or a pending resume; loop() restores the framing
// once any host write in flight has drained.
this->state_ = MainState::MAIN_STATE_PAUSING;
this->enable_loop();
}
void CDCACMUARTBridge::resume() {
if (this->state_ != MainState::MAIN_STATE_PAUSING && this->state_ != MainState::MAIN_STATE_PAUSED) {
return;
}
if (this->uart_rx_task_handle_ == nullptr) {
this->paused_ = 0;
this->state_ = MainState::MAIN_STATE_RUNNING;
return;
}
// A restore still waiting on the TX side is moot: the host's framing is kept.
if (!this->tx_idle_()) {
this->state_ = MainState::MAIN_STATE_RESUMING;
this->enable_loop();
return;
}
this->finish_resume_();
this->disable_loop();
}
void CDCACMUARTBridge::finish_resume_() {
// Take the bus back at a known framing before either task runs again: the host's
// if it ever sent one, else the YAML framing (the other owner may have changed it).
if (this->host_coding_seen_) {
this->sync_host_framing_();
this->uart_parent_->apply_settings_live();
} else {
this->restore_configured_framing_();
}
this->paused_ = 0;
this->state_ = MainState::MAIN_STATE_RUNNING;
this->drive_line_state_();
xTaskNotifyGive(this->uart_rx_task_handle_);
}
bool CDCACMUARTBridge::tx_idle_() {
const auto uart_num = static_cast<uart_port_t>(this->uart_parent_->get_hw_serial_number());
return this->tx_busy_ == 0 && uart_wait_tx_done(uart_num, 0) == ESP_OK;
}
void CDCACMUARTBridge::restore_configured_framing_() {
// Always applied: the cached settings can lead the hardware by a pending reload,
// so they are no proof of what is live.
this->uart_parent_->set_baud_rate(this->configured_baud_rate_);
this->uart_parent_->set_parity(this->configured_parity_);
this->uart_parent_->set_stop_bits(this->configured_stop_bits_);
this->uart_parent_->set_data_bits(this->configured_data_bits_);
this->uart_parent_->apply_settings_live();
}
void CDCACMUARTBridge::set_line_state(bool dtr, bool rts) {
ESP_LOGV(TAG, "Line state: DTR=%d, RTS=%d", dtr, rts);
this->host_dtr_ = dtr;
this->host_rts_ = rts;
// Frozen while paused: a host opening the port must not reset a peer that another
// component is talking to.
if (this->paused_ == 0) {
this->drive_line_state_();
}
}
void CDCACMUARTBridge::drive_line_state_() {
if (this->dtr_pin_ != nullptr) {
this->dtr_pin_->digital_write(this->host_dtr_);
}
if (this->rts_pin_ != nullptr) {
this->rts_pin_->digital_write(this->host_rts_);
}
}
void CDCACMUARTBridge::uart_rx_task_fn(void *arg) {
auto *bridge = static_cast<CDCACMUARTBridge *>(arg);
bridge->uart_rx_task_();
}
void CDCACMUARTBridge::uart_tx_task_fn(void *arg) {
auto *bridge = static_cast<CDCACMUARTBridge *>(arg);
bridge->uart_tx_task_();
}
void CDCACMUARTBridge::uart_rx_task_() {
TaskHandle_t usb_tx_handle = this->usb_cdc_parent_->get_tx_task_handle();
RingbufHandle_t usb_tx_ringbuf = this->usb_cdc_parent_->get_tx_ringbuf();
uart_port_t uart_num = static_cast<uart_port_t>(this->uart_parent_->get_hw_serial_number());
// Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs.
uint32_t tx_full_log_ms = millis() - LOG_THROTTLE_MS;
uint32_t err_log_ms = millis() - LOG_THROTTLE_MS;
uint8_t *data = this->uart_rx_buffer_.data();
const size_t buf_size = this->uart_rx_buffer_.size();
// Released by setup() once both tasks exist.
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
while (true) {
if (this->paused_ != 0) {
// Parked until resume() notifies; nothing is read, so the other owner sees
// every byte.
this->rx_parked_ = 1;
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
this->rx_parked_ = 0;
continue;
}
// Block until at least one byte is available from UART.
int total_rx_size = uart_read_bytes(uart_num, data, 1, pdMS_TO_TICKS(UART_RX_WAIT_MS));
if (total_rx_size < 0) {
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
ESP_LOGE(TAG, "UART read failed: %d", total_rx_size);
}
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
if (total_rx_size == 0) {
continue;
}
// pause() landed during the read: don't forward a byte to a host that is gone.
if (this->paused_ != 0) {
continue;
}
// Drain the currently buffered burst without waiting.
while (true) {
int rx_data_size = uart_read_bytes(uart_num, data + total_rx_size, buf_size - total_rx_size, 0);
if (rx_data_size < 0) {
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
ESP_LOGE(TAG, "UART read failed: %d", rx_data_size);
}
break;
}
if (rx_data_size == 0) {
break;
}
ESP_LOGV(TAG, "UART RX: %d bytes", rx_data_size);
total_rx_size += rx_data_size;
if (total_rx_size >= (int) buf_size) {
break;
}
}
ringbuf_send_with_retry(usb_tx_ringbuf, data, total_rx_size, &tx_full_log_ms);
ESP_LOGV(TAG, "UART RX: waking up USB TX task");
xTaskNotifyGive(usb_tx_handle);
}
}
void CDCACMUARTBridge::uart_tx_task_() {
RingbufHandle_t usb_rx_ringbuf = this->usb_cdc_parent_->get_rx_ringbuf();
uart_port_t uart_num = static_cast<uart_port_t>(this->uart_parent_->get_hw_serial_number());
uint8_t *data_to_uart = this->uart_tx_buffer_.data();
const size_t buf_size = this->uart_tx_buffer_.size();
size_t rx_size;
// Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs.
uint32_t err_log_ms = millis() - LOG_THROTTLE_MS;
uint32_t drop_log_ms = millis() - LOG_THROTTLE_MS;
// Released by setup() once both tasks exist.
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
while (true) {
ESP_LOGV(TAG, "Waiting for data to send to UART");
esp_err_t ret = usb_cdc_acm::ringbuf_read_bytes(usb_rx_ringbuf, data_to_uart, buf_size, &rx_size, portMAX_DELAY);
if (ret != ESP_OK) {
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
ESP_LOGE(TAG, "USB RX RingBuf read failed");
}
// Yield: this task runs above the main loop, so a persistent failure must not
// become a tight loop.
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
// Another component owns the UART; host bytes must not interleave with its traffic.
// tx_busy_ goes up before the check so is_paused() cannot miss a write in flight.
this->tx_busy_ = 1;
if (this->paused_ != 0) {
this->tx_busy_ = 0;
if (should_log_now(&drop_log_ms, LOG_THROTTLE_MS)) {
ESP_LOGW(TAG, "Paused; dropping %zu bytes from host", rx_size);
}
continue;
}
ESP_LOGV(TAG, "Sending %zu bytes to UART", rx_size);
// Signed: uart_write_bytes() returns -1 on error.
int xfer_size = uart_write_bytes(uart_num, data_to_uart, rx_size);
this->tx_busy_ = 0;
if (xfer_size < 0) {
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
ESP_LOGE(TAG, "UART write failed: %d", xfer_size);
}
} else if (static_cast<size_t>(xfer_size) != rx_size) {
ESP_LOGW(TAG, "UART write incomplete (%d/%zu bytes)", xfer_size, rx_size);
}
}
}
} // namespace esphome::cdc_acm_uart
#endif
@@ -0,0 +1,117 @@
#pragma once
#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
#include "esphome/components/uart/uart_component_esp_idf.h"
#include "esphome/components/usb_cdc_acm/usb_cdc_acm.h"
#include "esphome/core/component.h"
#include <array>
#include <atomic>
#include "sdkconfig.h"
namespace esphome::cdc_acm_uart {
class CDCACMUARTBridge final : public Component {
public:
// Upper bound on the RX task's blocking read, so pause() takes effect without
// aborting the read. Arriving bytes still unblock it immediately.
static constexpr uint32_t UART_RX_WAIT_MS = 250;
CDCACMUARTBridge(uart::IDFUARTComponent *uart_parent, usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent)
: uart_parent_(uart_parent), usb_cdc_parent_(usb_cdc_parent) {}
void setup() override;
void loop() override;
void dump_config() override;
void on_shutdown() override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
void set_dtr_pin(GPIOPin *dtr_pin) { this->dtr_pin_ = dtr_pin; }
void set_rts_pin(GPIOPin *rts_pin) { this->rts_pin_ = rts_pin; }
void set_line_coding();
void set_line_state(bool dtr, bool rts);
/**
* Stop forwarding in both directions and hand the UART back to its configured
* framing, so another component may use the bus. Main-loop only. The RX task parks
* within UART_RX_WAIT_MS (a byte it was already reading is discarded). A host write
* already in flight is allowed to drain first, which at low baud rates can take
* seconds; the framing is restored only after that, so poll is_paused() rather than
* waiting a fixed interval. Host bytes not yet written to the UART are discarded.
* The DTR/RTS outputs hold their state while paused and follow the host again on
* resume().
*/
void pause();
/**
* Re-apply the host's line coding and line state, then resume forwarding. Main-loop
* only. Deferred until any host write still draining has finished, so the reload
* never truncates it.
*/
void resume();
/// True once both worker tasks are off the bus and the configured framing is restored.
/// With no RX task (setup() failed or has not run) there is nothing to wait for.
bool is_paused() const {
return this->state_ == MainState::MAIN_STATE_PAUSED &&
(this->uart_rx_task_handle_ == nullptr || this->rx_parked_ != 0);
}
protected:
static void uart_rx_task_fn(void *arg);
static void uart_tx_task_fn(void *arg);
void uart_rx_task_();
void uart_tx_task_();
void restore_configured_framing_();
// True when the TX task has no write in flight and the UART TX FIFO has drained.
bool tx_idle_();
void finish_resume_();
void drive_line_state_();
// Copy the host's line coding onto the UART settings; true if anything changed.
bool sync_host_framing_();
TaskHandle_t uart_rx_task_handle_{nullptr};
TaskHandle_t uart_tx_task_handle_{nullptr};
GPIOPin *dtr_pin_{nullptr};
GPIOPin *rts_pin_{nullptr};
uint32_t reload_requested_at_{0};
// Worker staging, each sized to the CDC ring buffer it feeds or drains.
std::array<uint8_t, CONFIG_TINYUSB_CDC_TX_BUFSIZE> uart_rx_buffer_{};
std::array<uint8_t, CONFIG_TINYUSB_CDC_RX_BUFSIZE> uart_tx_buffer_{};
uart::IDFUARTComponent *uart_parent_;
usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent_;
// YAML framing, captured at setup; the host's line coding overwrites the UART's
// settings, so pause() needs the original to restore.
uint32_t configured_baud_rate_{0};
uart::UARTParityOptions configured_parity_{uart::UART_CONFIG_PARITY_NONE};
uint8_t configured_stop_bits_{0};
uint8_t configured_data_bits_{0};
// Written on the main loop, read by both worker tasks. uint8_t rather than bool:
// GCC on Xtensa emits an out-of-line call for atomic<bool>.
std::atomic<uint8_t> paused_{0};
// Raised by the RX task while parked and by the TX task around each UART write, so
// the pause hand-off knows when the bus is actually free.
std::atomic<uint8_t> rx_parked_{0};
std::atomic<uint8_t> tx_busy_{0};
// Main-loop state; paused_ mirrors it for the worker tasks.
enum class MainState : uint8_t {
MAIN_STATE_RUNNING,
MAIN_STATE_RELOAD_PENDING, // host line coding debounced, forwarding continues
MAIN_STATE_PAUSING, // waiting for TX idle to restore the configured framing
MAIN_STATE_PAUSED,
MAIN_STATE_RESUMING, // resume() requested while a host write still drains
};
MainState state_{MainState::MAIN_STATE_RUNNING};
// Host line state, recorded even while paused so resume() can re-drive the pins.
bool host_dtr_{false};
bool host_rts_{false};
// True once the host has sent any line coding; resume() then re-syncs to it.
bool host_coding_seen_{false};
};
} // namespace esphome::cdc_acm_uart
#endif
+22 -13
View File
@@ -83,18 +83,23 @@ void ESP32BLE::setup() {
}
}
void ESP32BLE::enable() {
if (this->state_ != BLE_COMPONENT_STATE_DISABLED)
return;
this->state_ = BLE_COMPONENT_STATE_ENABLE;
}
void ESP32BLE::disable() {
if (this->state_ == BLE_COMPONENT_STATE_DISABLED)
return;
this->state_ = BLE_COMPONENT_STATE_DISABLE;
// Queue the transition for loop(). A pending transition the other way is
// cancelled instead, since nothing was torn down or brought up yet; any other
// state is already there or on its way.
void ESP32BLE::request_state_(bool enable) {
if (enable) {
if (this->state_ == BLE_COMPONENT_STATE_DISABLED) {
this->state_ = BLE_COMPONENT_STATE_ENABLE;
} else if (this->state_ == BLE_COMPONENT_STATE_DISABLE) {
this->state_ = BLE_COMPONENT_STATE_ACTIVE;
}
} else {
if (this->state_ == BLE_COMPONENT_STATE_ACTIVE) {
this->state_ = BLE_COMPONENT_STATE_DISABLE;
} else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) {
this->state_ = BLE_COMPONENT_STATE_DISABLED;
}
}
}
#ifdef USE_ESP32_BLE_ADVERTISING
@@ -580,7 +585,11 @@ void ESP32BLE::loop_handle_state_transition_not_active_() {
this->mark_failed();
return;
}
this->state_ = BLE_COMPONENT_STATE_DISABLED;
this->drain_ble_events_();
// A status callback may have asked for BLE back; the stack is down now, so
// that request becomes a bring-up.
this->state_ =
this->state_ == BLE_COMPONENT_STATE_ACTIVE ? BLE_COMPONENT_STATE_ENABLE : BLE_COMPONENT_STATE_DISABLED;
} else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) {
ESP_LOGD(TAG, "Enabling");
this->state_ = BLE_COMPONENT_STATE_OFF;
+11 -2
View File
@@ -102,8 +102,8 @@ class ESP32BLE final : public Component {
}
uint32_t get_advertising_cycle_time() const { return this->advertising_cycle_time_; }
void enable();
void disable();
void enable() { this->request_state_(true); }
void disable() { this->request_state_(false); }
ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; }
void setup() override;
void loop() override;
@@ -176,6 +176,15 @@ class ESP32BLE final : public Component {
bool ble_setup_();
bool ble_dismantle_();
void request_state_(bool enable);
// Drop what the old stack queued; the next stack reuses the same interface ids.
void drain_ble_events_() {
BLEEvent *ble_event;
while ((ble_event = this->ble_events_.pop()) != nullptr) {
this->ble_event_pool_.release(ble_event);
}
this->ble_events_.get_and_reset_dropped_count();
}
bool ble_pre_setup_();
#ifdef USE_ESP32_BLE_ADVERTISING
void advertising_init_();
@@ -42,7 +42,7 @@ void BLEClientBase::set_state(espbt::ClientState st) {
void BLEClientBase::loop() {
if (!esp32_ble::global_ble->is_active()) {
this->set_state(espbt::ClientState::INIT);
// ble_before_disabled_event_handler() resets the client.
return;
}
if (this->state() == espbt::ClientState::INIT) {
@@ -72,6 +72,21 @@ void BLEClientBase::loop() {
float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
void BLEClientBase::ble_before_disabled_event_handler() {
auto st = this->state();
if (st != espbt::ClientState::IDLE && st != espbt::ClientState::INIT) {
// No CLOSE_EVT will come: free the services and settle the link.
this->release_services();
this->set_idle_();
this->on_disconnect_complete(ESP_GATT_CONN_TERMINATE_LOCAL_HOST);
}
// The interface belongs to the torn-down stack.
this->gattc_if_ = ESP_GATT_IF_NONE;
this->set_state(espbt::ClientState::INIT);
// An idle client runs no loop; the INIT branch must run to register again.
this->enable_loop();
}
void BLEClientBase::dump_config() {
ESP_LOGCONFIG(TAG,
" Address: %s\n"
@@ -93,6 +108,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) {
return false;
if (this->state() != espbt::ClientState::IDLE)
return false;
// Not registered on this stack yet; promoting now would stop the scan for a
// connect that connect() rejects anyway.
if (this->gattc_if_ == ESP_GATT_IF_NONE)
return false;
this->log_event_("Found device");
if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG)
@@ -117,6 +136,15 @@ void BLEClientBase::connect() {
this->connection_index_, this->address_str_);
return;
}
if (this->gattc_if_ == ESP_GATT_IF_NONE) {
// Bluedroid drops an open on an unknown interface without any event.
this->log_warning_("Connect rejected, GATT app not registered");
// INIT stays so loop() still registers; only a promoted client goes back.
if (this->state() == espbt::ClientState::DISCOVERED) {
this->set_state(espbt::ClientState::IDLE);
}
return;
}
ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_);
this->paired_ = false;
// A registration whose event never arrived must not block this connection's release.
@@ -199,7 +227,10 @@ void BLEClientBase::release_services() {
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
// Only the cache clean makes the stack's database unsafe to walk.
this->services_released_ = true;
esp_ble_gattc_cache_clean(this->remote_bda_);
// A stack on its way down frees its own cache.
if (esp32_ble::global_ble->is_active()) {
esp_ble_gattc_cache_clean(this->remote_bda_);
}
#endif
}
@@ -41,6 +41,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void connect() override;
esp_err_t pair();
void disconnect() override;
void ble_before_disabled_event_handler() override;
void unconditional_disconnect();
void release_services();
@@ -114,7 +115,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
#endif
// Group 3: 4-byte types
int gattc_if_;
int gattc_if_{ESP_GATT_IF_NONE};
esp_gatt_status_t status_{ESP_GATT_OK};
// Group 4: Arrays
@@ -139,7 +140,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
uint8_t pending_notify_regs_{0};
bool auto_connect_{false};
bool paired_{false};
// Set only when release_services() cleans the stack's GATT cache, which no API may then walk
// Set by release_services() on RAM-cache builds; the stack's GATT database must not be walked after it
bool services_released_{false};
// 8 bytes used, no padding
@@ -155,10 +156,11 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
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.
/// set_idle_()): CLOSE_EVT, the DISCONNECTING safety timeout, or the BLE stack going down.
/// 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.
/// override this to release that state. `reason` is the controller reason code,
/// ESP_GATT_CONN_TIMEOUT for the safety timeout, or ESP_GATT_CONN_TERMINATE_LOCAL_HOST
/// for the stack going down.
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_() {
@@ -62,6 +62,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u
for (auto *client : this->clients_) {
client->disconnect();
}
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
// The OTA transfer blocks the main loop, so the revert in loop() cannot run. No
// active-connection gate here: every client was just told to disconnect.
this->update_coex_preference_(false);
#endif
#endif
} else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) {
this->scan_continuous_before_ota_ = false;
@@ -74,11 +79,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u
void ESP32BLETracker::loop() {
if (!this->parent_->is_active()) {
this->ble_was_disabled_ = true;
return;
} else if (this->ble_was_disabled_) {
}
if (this->ble_was_disabled_) {
this->ble_was_disabled_ = false;
// If the BLE stack was disabled, we need to start the scan again.
// First start after boot or after the stack came back.
if (this->scan_continuous_) {
this->start_scan();
}
@@ -218,7 +223,27 @@ void ESP32BLETracker::stop_scan() {
this->stop_scan_();
}
void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); }
void ESP32BLETracker::ble_before_disabled_event_handler() {
// Tell the controller to stop; a scan still starting has nothing to stop yet.
if (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::FAILED) {
this->stop_scan_();
}
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
client->ble_before_disabled_event_handler();
}
this->skip_next_scan_end_ = false;
#endif
// The stop above never completes (stack torn down, events dropped); settle
// here so start_scan_() sees IDLE once the stack is back.
if (this->scanner_state_ != ScannerState::IDLE) {
this->cleanup_scan_state_(true);
}
// A failure latched by the old stack must not be handled against the next.
this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS;
this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS;
this->ble_was_disabled_ = true;
}
bool ESP32BLETracker::stop_scan_() {
if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) {
@@ -113,6 +113,9 @@ class ESPBTClient : public ESPBTDeviceListener {
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0;
virtual void connect() = 0;
virtual void disconnect() = 0;
/// Called right before the BLE stack is dismantled. Nothing in flight will
/// complete, and the GATT app must register again once the stack is back.
virtual void ble_before_disabled_event_handler() {}
bool disconnect_pending() const { return this->want_disconnect_; }
void cancel_pending_disconnect() { this->want_disconnect_ = false; }
+1 -1
View File
@@ -29,7 +29,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component {
void write_state(float state) override;
InternalGPIOPin *pin_;
float frequency_{1000.0};
float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py
/// Cache last output level for dynamic frequency updating
float last_output_{0.0};
};
+8 -2
View File
@@ -22,6 +22,10 @@ ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Componen
SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action)
validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6))
# Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen
# skips the setter when the config equals it.
DEFAULT_FREQUENCY = 1000.0
CONFIG_SCHEMA = cv.All(
output.FLOAT_OUTPUT_SCHEMA.extend(
{
@@ -29,7 +33,7 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_PIN): cv.All(
pins.internal_gpio_output_pin_schema, valid_pwm_pin
),
cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency,
cv.Optional(CONF_FREQUENCY, default=DEFAULT_FREQUENCY): validate_frequency,
}
).extend(cv.COMPONENT_SCHEMA),
cv.require_framework_version(
@@ -48,7 +52,9 @@ async def to_code(config: ConfigType) -> None:
pin = await cg.gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
cg.add(var.set_frequency(config[CONF_FREQUENCY]))
# Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY).
if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY:
cg.add(var.set_frequency(frequency))
@automation.register_action(
@@ -842,7 +842,14 @@ bool ESPHomeOTAComponent::handle_auth_send_() {
const size_t hex_size = hasher.get_size() * 2;
const size_t nonce_len = hasher.get_size() / 4;
const size_t auth_buf_size = 1 + 3 * hex_size;
this->auth_buf_ = std::make_unique<uint8_t[]>(auth_buf_size);
// Internal RAM first: 128 of these bytes go straight into the hardware SHA engine
this->auth_buf_ =
RAMAllocator<uint8_t>(RAMAllocator<uint8_t>::PREFER_INTERNAL).make_unique_array_for_overwrite(auth_buf_size);
if (!this->auth_buf_) {
this->log_auth_warning_(LOG_STR("No memory"));
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN);
return false;
}
this->auth_buf_pos_ = 0;
char *buf = reinterpret_cast<char *>(this->auth_buf_.get() + 1);
+2 -2
View File
@@ -145,13 +145,13 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
#ifdef USE_OTA_PASSWORD
std::string password_;
std::unique_ptr<uint8_t[]> auth_buf_;
RAMUniquePtr<uint8_t[]> auth_buf_;
#endif // USE_OTA_PASSWORD
#ifdef USE_OTA_ENCRYPTION
#ifndef USE_OTA_ENCRYPTION_FROM_API
noise::NoiseContext noise_ctx_;
#endif
std::unique_ptr<NoiseSession> noise_;
RAMUniquePtr<NoiseSession> noise_;
#endif // USE_OTA_ENCRYPTION
socket::ListenSocket *server_{nullptr};
@@ -7,7 +7,6 @@
#include "esphome/core/log.h"
#include <cstring>
#include <new>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -43,9 +42,8 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
// A provisioned key cleared between the offer and here is not guarded: the
// session runs on the zero key load_psk fills in and fails the client's MAC.
// Default-init: the frame buffer is written before it is read
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession);
// Default placement, PSRAM first where present: the session only lives for one upload
this->noise_ = RAMAllocator<NoiseSession>().make_unique();
static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version
static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1;
static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags
+3 -1
View File
@@ -420,7 +420,9 @@ def _validate(config: ConfigType) -> ConfigType:
BASE_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(EthernetComponent),
cv.Optional(CONF_MANUAL_IP): MANUAL_IP_SCHEMA,
cv.Optional(
CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED
): MANUAL_IP_SCHEMA,
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
cv.Optional(CONF_USE_ADDRESS): cv.string_strict,
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
@@ -6,17 +6,21 @@
#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.
// Context returned by init() and handed back to read/write/deinit. There is one W5500 per device, so a
// single static instance replaces a heap allocation that could fail. It is always clear when init() runs:
// esp_eth_mac_new_w5500() calls deinit() on every failure after init() succeeded, and nothing else
// uninstalls the driver
struct W5500CustomSpiContext {
spi_device_handle_t handle;
SemaphoreHandle_t lock;
};
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state
W5500CustomSpiContext w5500_context{};
// 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.
@@ -25,23 +29,20 @@ 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;
}
auto *ctx = &w5500_context;
// 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;
ctx->handle = nullptr;
return nullptr;
}
ctx->lock = xSemaphoreCreateMutex();
if (ctx->lock == nullptr) {
spi_bus_remove_device(ctx->handle);
delete ctx;
ctx->handle = nullptr;
return nullptr;
}
return ctx;
@@ -51,7 +52,7 @@ 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;
*ctx = {};
return ESP_OK;
}
+6 -2
View File
@@ -15,9 +15,13 @@ CONFIG_SCHEMA = (
.extend(
{
cv.Required(CONF_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_INTERLOCK): cv.ensure_list(cv.use_id(switch.Switch)),
cv.Optional(
CONF_INTERLOCK_WAIT_TIME, default="0ms"
CONF_INTERLOCK, visibility=cv.Visibility.ADVANCED
): cv.ensure_list(cv.use_id(switch.Switch)),
cv.Optional(
CONF_INTERLOCK_WAIT_TIME,
default="0ms",
visibility=cv.Visibility.ADVANCED,
): cv.positive_time_period_milliseconds,
}
)
@@ -53,6 +53,13 @@ void I2SAudioSpeakerBase::dump_config() {
void I2SAudioSpeakerBase::loop() {
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
// A stop that arrives while stopped cancels any start that has not been processed yet
constexpr uint32_t stop_bits = SpeakerEventGroupBits::COMMAND_STOP | SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY;
if ((event_group_bits & stop_bits) && (this->state_ == speaker::STATE_STOPPED)) {
xEventGroupClearBits(this->event_group_, stop_bits | SpeakerEventGroupBits::COMMAND_START);
event_group_bits &= ~(stop_bits | SpeakerEventGroupBits::COMMAND_START);
}
if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) {
this->state_ = speaker::STATE_STARTING;
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START);
@@ -239,8 +246,6 @@ void I2SAudioSpeakerBase::start() {
if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING))
return;
// Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START.
this->state_ = speaker::STATE_STARTING;
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START);
}
@@ -249,11 +254,10 @@ void I2SAudioSpeakerBase::stop() { this->stop_(false); }
void I2SAudioSpeakerBase::finish() { this->stop_(true); }
void I2SAudioSpeakerBase::stop_(bool wait_on_empty) {
if (this->is_failed())
return;
if (this->state_ == speaker::STATE_STOPPED)
if (!this->is_ready() || this->is_failed())
return;
// Always set the bit, even when stopped, so loop() can cancel a start that is still pending
if (wait_on_empty) {
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY);
} else {
+11 -3
View File
@@ -340,6 +340,10 @@ RESTORE_MODES = {
"RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON,
}
# Schema default that also matches the C++ initializer in light_state.h; codegen
# skips the setter when the config equals it.
DEFAULT_FLASH_TRANSITION_LENGTH = "0s"
LIGHT_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
@@ -387,7 +391,7 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend(
CONF_DEFAULT_TRANSITION_LENGTH, default="1s"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_FLASH_TRANSITION_LENGTH, default="0s"
CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS),
}
@@ -502,9 +506,12 @@ async def setup_light_core_(light_var, config, output_var):
default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH)
) is not None:
cg.add(light_var.set_default_transition_length(default_transition_length))
# Skip the setter when the config matches the C++ initializer.
if (
flash_transition_length := config.get(CONF_FLASH_TRANSITION_LENGTH)
) is not None:
) is not None and flash_transition_length != cv.time_period(
DEFAULT_FLASH_TRANSITION_LENGTH
):
cg.add(light_var.set_flash_transition_length(flash_transition_length))
if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None:
cg.add(light_var.set_gamma_correct(gamma_correct))
@@ -514,7 +521,8 @@ async def setup_light_core_(light_var, config, output_var):
effects = await cg.build_registry_list(
EFFECTS_REGISTRY, config.get(CONF_EFFECTS, [])
)
cg.add(light_var.add_effects(effects))
if effects:
cg.add(light_var.add_effects(effects))
for conf in config.get(CONF_ON_TURN_ON, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var)
+1 -1
View File
@@ -356,7 +356,7 @@ class LightState : public EntityBase, public Component {
/// Default transition length for all transitions in ms.
uint32_t default_transition_length_{};
/// Transition length to use for flash transitions.
uint32_t flash_transition_length_{};
uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py
/// Gamma correction factor for the light.
float gamma_correct_{};
#ifdef USE_LIGHT_GAMMA_LUT
+7 -6
View File
@@ -362,12 +362,13 @@ async def to_code(config: ConfigType) -> None:
# pre_setup() switches on uart_ to decide which hardware to initialize
# (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the
# default UART_SELECTION_UART0 and the wrong hardware gets initialized.
if CONF_HARDWARE_UART in config:
cg.add(
log.set_uart_selection(
HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]]
)
)
# uart_ is UART0 in C++ except on LibreTiny where it is DEFAULT; skip the
# setter when the config matches it.
cpp_default_uart = DEFAULT if CORE.is_libretiny else UART0
if (
hardware_uart := config.get(CONF_HARDWARE_UART)
) is not None and hardware_uart != cpp_default_uart:
cg.add(log.set_uart_selection(HARDWARE_UART_TO_UART_SELECTION[hardware_uart]))
# pre_setup() sets global_logger and must run before any other code
# that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV).
cg.add(log.pre_setup())
+2 -2
View File
@@ -352,10 +352,10 @@ class Logger final : public Component {
// Group smaller types together at the end
uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE};
#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR)
UARTSelection uart_{UART_SELECTION_UART0};
UARTSelection uart_{UART_SELECTION_UART0}; // Must match cpp_default_uart in __init__.py
#endif
#ifdef USE_LIBRETINY
UARTSelection uart_{UART_SELECTION_DEFAULT};
UARTSelection uart_{UART_SELECTION_DEFAULT}; // Must match cpp_default_uart in __init__.py
#endif
#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR)
bool main_task_recursion_guard_{false};
@@ -227,6 +227,7 @@ LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)})
)
async def list_add_text_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
await _wait_list_triggers_completed()
async def do_add_text(w: Widget):
text = await lv_text.process(config[CONF_TEXT])
@@ -370,6 +371,7 @@ async def list_add_to_code(config, action_id, template_arg, args):
_register_lv_uses(w_type_name, w_conf)
_register_dynamic_widget_style_uses(w_conf)
widgets = await get_widgets(config)
await _wait_list_triggers_completed()
async def do_add(w: Widget):
index = None
@@ -503,6 +505,7 @@ LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend(
)
async def list_remove_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
await _wait_list_triggers_completed()
async def do_remove(w: Widget):
index = await lv_int.process(config[CONF_INDEX])
@@ -536,6 +539,7 @@ async def list_remove_to_code(config, action_id, template_arg, args):
)
async def list_clear_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
await _wait_list_triggers_completed()
async def do_clear(w: Widget):
await _wait_list_triggers_completed()
@@ -306,9 +306,9 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::RingBuffer
uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read);
if (samples_to_duck > 0) {
esp_audio_libs::ducking::apply(audio_source->mutable_data(),
static_cast<uint8_t>(this->audio_stream_info_.get_bits_per_sample() / 8),
samples_to_duck, this->ducking_state_);
this->ducking_ramp_.process(audio_source->mutable_data(),
static_cast<uint8_t>(this->audio_stream_info_.get_bits_per_sample() / 8),
samples_to_duck);
}
return bytes_read;
@@ -316,7 +316,7 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::RingBuffer
void SourceSpeaker::apply_ducking(uint8_t decibel_reduction, uint32_t duration) {
const uint32_t transition_samples = duration > 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0;
esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples);
this->ducking_ramp_.set_target_db_reduction_over(decibel_reduction, transition_samples);
}
void SourceSpeaker::enter_stopping_state_() {
@@ -11,7 +11,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/static_task.h"
#include <ducking.h> // esp-audio-libs
#include <gain.h> // esp-audio-libs
#include <freertos/event_groups.h>
@@ -108,7 +108,7 @@ class SourceSpeaker final : public speaker::Speaker, public Component {
bool pause_state_{false};
esp_audio_libs::ducking::DuckingState ducking_state_{};
esp_audio_libs::gain::GainRamp ducking_ramp_;
std::atomic<uint32_t> pending_playback_frames_{0};
std::atomic<uint32_t> playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing
+74 -78
View File
@@ -13,6 +13,11 @@ namespace esphome::nextion {
static const char *const TAG = "nextion";
// A user entity may be named sleep_wake too; only the internal NO_RESULT command clears the sleeping flag
static bool is_sleep_wake_command(const NextionComponentBase *component) {
return component->get_queue_type() == NextionQueueType::NO_RESULT && component->get_variable_name() == "sleep_wake";
}
// Nextion command terminator: three consecutive 0xFF bytes (per Nextion Instruction Set v1.1).
static constexpr uint8_t COMMAND_DELIMITER[3] = {0xFF, 0xFF, 0xFF};
static constexpr size_t DELIMITER_SIZE = sizeof(COMMAND_DELIMITER);
@@ -163,6 +168,17 @@ bool Nextion::check_connect_() {
#endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE
}
// NO_RESULT components are owned by their entry; every other component is a user entity. Entry and
// component storage comes from RAMAllocator, so delete is not valid for either.
void Nextion::release_queue_entry_(NextionQueue *nb) {
if (nb->component != nullptr && nb->component->get_queue_type() == NextionQueueType::NO_RESULT) {
nb->component->~NextionComponentBase();
RAMAllocator<NextionComponentBase>().deallocate(nb->component, 1);
}
nb->~NextionQueue();
RAMAllocator<NextionQueue>().deallocate(nb, 1);
}
void Nextion::reset_(bool reset_nextion) {
uint8_t d;
@@ -170,15 +186,12 @@ void Nextion::reset_(bool reset_nextion) {
this->read_byte(&d);
}
for (auto *entry : this->nextion_queue_) {
if (entry->component != nullptr && entry->component->get_queue_type() == NextionQueueType::NO_RESULT) {
delete entry->component; // NOLINT(cppcoreguidelines-owning-memory)
}
delete entry; // NOLINT(cppcoreguidelines-owning-memory)
this->release_queue_entry_(entry);
}
this->nextion_queue_.clear();
#ifdef USE_NEXTION_WAVEFORM
for (auto *entry : this->waveform_queue_) {
delete entry; // NOLINT(cppcoreguidelines-owning-memory)
this->release_queue_entry_(entry);
}
this->waveform_queue_.clear();
#endif // USE_NEXTION_WAVEFORM
@@ -421,6 +434,9 @@ bool Nextion::remove_from_q_(bool report_empty) {
NextionQueue *nb = this->nextion_queue_.front();
if (!nb || !nb->component) {
ESP_LOGE(TAG, "Invalid queue");
if (nb != nullptr) {
this->release_queue_entry_(nb);
}
this->nextion_queue_.pop_front();
return false;
}
@@ -428,13 +444,10 @@ bool Nextion::remove_from_q_(bool report_empty) {
ESP_LOGN(TAG, "Removed: %s", component->get_variable_name().c_str());
if (component->get_queue_type() == NextionQueueType::NO_RESULT) {
if (component->get_variable_name() == "sleep_wake") {
this->is_sleeping_ = false;
}
delete component; // NOLINT(cppcoreguidelines-owning-memory)
if (is_sleep_wake_command(component)) {
this->is_sleeping_ = false;
}
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
this->release_queue_entry_(nb);
this->nextion_queue_.pop_front();
return true;
}
@@ -544,7 +557,7 @@ void Nextion::process_nextion_commands_() {
ESP_LOGW(TAG, "Invalid waveform ID %d/ch %d", component->get_component_id(),
component->get_wave_channel_id());
ESP_LOGN(TAG, "Remove waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id());
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
this->release_queue_entry_(nb);
this->waveform_queue_.pop();
}
#else // USE_NEXTION_WAVEFORM
@@ -647,6 +660,9 @@ void Nextion::process_nextion_commands_() {
NextionQueue *nb = this->nextion_queue_.front();
if (!nb || !nb->component) {
ESP_LOGE(TAG, "Invalid queue entry");
if (nb != nullptr) {
this->release_queue_entry_(nb);
}
this->nextion_queue_.pop_front();
return;
}
@@ -660,7 +676,7 @@ void Nextion::process_nextion_commands_() {
component->set_state_from_string(to_process, true, false);
}
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
this->release_queue_entry_(nb);
this->nextion_queue_.pop_front();
break;
@@ -687,6 +703,9 @@ void Nextion::process_nextion_commands_() {
NextionQueue *nb = this->nextion_queue_.front();
if (!nb || !nb->component) {
ESP_LOGE(TAG, "Invalid queue");
if (nb != nullptr) {
this->release_queue_entry_(nb);
}
this->nextion_queue_.pop_front();
return;
}
@@ -703,7 +722,7 @@ void Nextion::process_nextion_commands_() {
component->set_state_from_int(value, true, false);
}
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
this->release_queue_entry_(nb);
this->nextion_queue_.pop_front();
break;
@@ -890,7 +909,7 @@ void Nextion::process_nextion_commands_() {
ESP_LOGN(TAG, "Send waveform: component id %d, waveform id %d, size %zu", component->get_component_id(),
component->get_wave_channel_id(), buffer_to_send);
component->clear_wave_buffer(buffer_to_send);
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
this->release_queue_entry_(nb);
this->waveform_queue_.pop();
#else // USE_NEXTION_WAVEFORM
ESP_LOGW(TAG, "Waveform transmit ready but waveform not enabled");
@@ -920,14 +939,10 @@ void Nextion::purge_stale_queue_entries_() {
ESP_LOGV(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string(),
component->get_variable_name().c_str());
if (component->get_queue_type() == NextionQueueType::NO_RESULT) {
if (component->get_variable_name() == "sleep_wake") {
this->is_sleeping_ = false;
}
delete component; // NOLINT(cppcoreguidelines-owning-memory)
if (is_sleep_wake_command(component)) {
this->is_sleeping_ = false;
}
delete *it; // NOLINT(cppcoreguidelines-owning-memory)
this->release_queue_entry_(*it);
it = this->nextion_queue_.erase(it);
} else {
@@ -1079,6 +1094,34 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool
return response.length();
}
// Allocates a queue entry owning a bare NO_RESULT component; nullptr when the queue is full or memory is out
NextionQueue *Nextion::make_no_result_entry_(const std::string &variable_name) {
#ifdef USE_NEXTION_MAX_QUEUE_SIZE
if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) {
ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str());
return nullptr;
}
#endif
auto *nextion_queue = RAMAllocator<nextion::NextionQueue>().allocate(1);
if (nextion_queue == nullptr) {
ESP_LOGW(TAG, "Queue alloc failed");
return nullptr;
}
new (nextion_queue) nextion::NextionQueue;
nextion_queue->component = RAMAllocator<nextion::NextionComponentBase>().allocate(1);
if (nextion_queue->component == nullptr) {
ESP_LOGW(TAG, "Component alloc failed");
this->release_queue_entry_(nextion_queue);
return nullptr;
}
new (nextion_queue->component) nextion::NextionComponentBase;
nextion_queue->component->set_variable_name(variable_name);
nextion_queue->queue_time = App.get_loop_component_start_time();
return nextion_queue;
}
/**
* @brief Add a command to the Nextion queue that expects no response.
*
@@ -1090,36 +1133,11 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool
* @param variable_name Name of the variable or component associated with the command.
*/
void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
#ifdef USE_NEXTION_MAX_QUEUE_SIZE
if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) {
ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str());
auto *nextion_queue = this->make_no_result_entry_(variable_name);
if (nextion_queue == nullptr)
return;
}
#endif
RAMAllocator<nextion::NextionQueue> allocator;
nextion::NextionQueue *nextion_queue = allocator.allocate(1);
if (nextion_queue == nullptr) {
ESP_LOGW(TAG, "Queue alloc failed");
return;
}
new (nextion_queue) nextion::NextionQueue();
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase;
if (nextion_queue->component == nullptr) {
ESP_LOGW(TAG, "Component alloc failed");
nextion_queue->~NextionQueue();
allocator.deallocate(nextion_queue, 1);
return;
}
nextion_queue->component->set_variable_name(variable_name);
nextion_queue->queue_time = App.get_loop_component_start_time();
this->nextion_queue_.push_back(nextion_queue);
ESP_LOGN(TAG, "Queue NORESULT: %s", nextion_queue->component->get_variable_name().c_str());
ESP_LOGN(TAG, "Queue NORESULT: %s", variable_name.c_str());
}
/**
@@ -1153,32 +1171,10 @@ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_n
#ifdef USE_NEXTION_COMMAND_SPACING
void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &variable_name,
const std::string &command) {
#ifdef USE_NEXTION_MAX_QUEUE_SIZE
if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) {
ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str());
auto *nextion_queue = this->make_no_result_entry_(variable_name);
if (nextion_queue == nullptr)
return;
}
#endif
RAMAllocator<nextion::NextionQueue> allocator;
nextion::NextionQueue *nextion_queue = allocator.allocate(1);
if (nextion_queue == nullptr) {
ESP_LOGW(TAG, "Queue alloc failed");
return;
}
new (nextion_queue) nextion::NextionQueue();
nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase;
if (nextion_queue->component == nullptr) {
ESP_LOGW(TAG, "Component alloc failed");
nextion_queue->~NextionQueue();
allocator.deallocate(nextion_queue, 1);
return;
}
nextion_queue->component->set_variable_name(variable_name);
nextion_queue->queue_time = App.get_loop_component_start_time();
nextion_queue->pending_command = command; // Store command for retry
this->nextion_queue_.push_back(nextion_queue);
ESP_LOGVV(TAG, "Queue with pending command: %s", variable_name.c_str());
}
@@ -1312,7 +1308,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
ESP_LOGW(TAG, "Queue alloc failed");
return;
}
new (nextion_queue) nextion::NextionQueue();
new (nextion_queue) nextion::NextionQueue;
nextion_queue->component = component;
nextion_queue->queue_time = App.get_loop_component_start_time();
@@ -1334,7 +1330,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
if (this->send_command_(command)) {
this->nextion_queue_.push_back(nextion_queue);
} else {
delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory)
this->release_queue_entry_(nextion_queue);
}
#endif // USE_NEXTION_COMMAND_SPACING
}
@@ -1355,14 +1351,14 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) {
ESP_LOGW(TAG, "Queue alloc failed");
return;
}
new (nextion_queue) nextion::NextionQueue();
new (nextion_queue) nextion::NextionQueue;
nextion_queue->component = component;
nextion_queue->queue_time = App.get_loop_component_start_time();
if (!this->waveform_queue_.push(nextion_queue)) {
ESP_LOGW(TAG, "Waveform queue full, drop");
delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory)
this->release_queue_entry_(nextion_queue);
return;
}
if (this->waveform_queue_.size() == 1)
+2
View File
@@ -1469,6 +1469,8 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
void all_components_send_state_(bool force_update = false);
uint32_t comok_sent_ = 0;
bool remove_from_q_(bool report_empty = true);
void release_queue_entry_(NextionQueue *nb);
NextionQueue *make_no_result_entry_(const std::string &variable_name);
/**
* @brief Status flags for Nextion display state management
@@ -23,8 +23,7 @@ class NextionComponentBase;
class NextionQueue {
public:
virtual ~NextionQueue() = default;
NextionComponentBase *component;
NextionComponentBase *component{nullptr};
uint32_t queue_time = 0;
// Store command for retry if spacing blocked it
@@ -105,6 +104,6 @@ class NextionComponentBase {
int wave_max_length_ = 255;
#endif // USE_NEXTION_WAVEFORM
bool needs_to_send_update_;
bool needs_to_send_update_{false};
};
} // namespace esphome::nextion
+2 -2
View File
@@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE")
cg.add_library("esphome/noise-c", "0.1.26")
cg.add_library("esphome/noise-c", "0.1.30")
# noise-c depends on libsodium, but declaring it here too lets the
# library manager see the full set up front instead of discovering
# libsodium only after noise-c has downloaded, so the two can download
# in parallel. The version must match noise-c's library.json.
cg.add_library("esphome/libsodium", "1.10021.8")
cg.add_library("esphome/libsodium", "1.10021.11")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
+9 -5
View File
@@ -174,6 +174,10 @@ NumberInRangeCondition = number_ns.class_(
NumberMode = number_ns.enum("NumberMode")
# Schema default that also matches the C++ initializer in number_traits.h; codegen
# skips the setter when the config equals it.
DEFAULT_MODE = "AUTO"
NUMBER_MODES = {
"AUTO": NumberMode.NUMBER_MODE_AUTO,
"BOX": NumberMode.NUMBER_MODE_BOX,
@@ -216,7 +220,7 @@ _NUMBER_SCHEMA = (
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
): validate_unit_of_measurement,
cv.Optional(
CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED
CONF_MODE, default=DEFAULT_MODE, visibility=cv.Visibility.ADVANCED
): cv.enum(NUMBER_MODES, upper=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
@@ -286,10 +290,10 @@ async def setup_number_core_(
cg.add(var.traits.set_max_value(max_value))
cg.add(var.traits.set_step(step))
# Only set if non-default to avoid bloating setup() function
# (mode_ is initialized to NUMBER_MODE_AUTO in the header)
if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO:
cg.add(var.traits.set_mode(config[CONF_MODE]))
# Skip the setter when the config matches the C++ initializer (DEFAULT_MODE).
# The validated value is the enum key string, not the C++ enum expression.
if (mode := config[CONF_MODE]) != DEFAULT_MODE:
cg.add(var.traits.set_mode(mode))
CORE.add_job(_build_number_automations, var, config)
+1 -1
View File
@@ -31,7 +31,7 @@ class NumberTraits {
float min_value_ = NAN;
float max_value_ = NAN;
float step_ = NAN;
NumberMode mode_{NUMBER_MODE_AUTO};
NumberMode mode_{NUMBER_MODE_AUTO}; // Keep in sync with DEFAULT_MODE in __init__.py
};
} // namespace esphome::number
@@ -9,7 +9,6 @@
#include <array>
#include <cstring>
#include <memory>
#include <new>
#include <esp_image_format.h>
#include <esp_partition.h>
#include <esp_rom_crc.h>
@@ -235,9 +234,11 @@ bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) {
// runs mid-OTA on the loop task, on top of the caller's live 1 KB OTA buffer
// and mbedtls's own ~1 KB verify scratch, so keeping it off the stack widens
// a thin margin. One short-lived allocation right before reboot is not the
// fragmentation pattern the project guards against. nothrow so an OOM here
// fails closed like every other error path, rather than aborting.
std::unique_ptr<uint8_t[]> block(new (std::nothrow) uint8_t[SIG_BLOCK_SIZE]);
// fragmentation pattern the project guards against. An OOM returns nullptr
// and fails closed like every other error path. Internal RAM first: the
// block is an esp_partition_read target.
auto block =
RAMAllocator<uint8_t>(RAMAllocator<uint8_t>::PREFER_INTERNAL).make_unique_array_for_overwrite(SIG_BLOCK_SIZE);
if (!block) {
OTA_IDF_SIG_LOG(ESP_LOGE, "out of memory");
return false;
+9 -4
View File
@@ -53,12 +53,17 @@ async def setup_output_platform_(obj, config):
if CONF_POWER_SUPPLY in config:
power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY])
cg.add(obj.set_power_supply(power_supply_))
if CONF_MAX_POWER in config:
# The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when
# the config matches them. The define stays whenever the key is present because
# platforms such as ac_dimmer read the scaling fields directly.
if (max_power := config.get(CONF_MAX_POWER)) is not None:
cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING")
cg.add(obj.set_max_power(config[CONF_MAX_POWER]))
if CONF_MIN_POWER in config:
if max_power != 1.0:
cg.add(obj.set_max_power(max_power))
if (min_power := config.get(CONF_MIN_POWER)) is not None:
cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING")
cg.add(obj.set_min_power(config[CONF_MIN_POWER]))
if min_power != 0.0:
cg.add(obj.set_min_power(min_power))
# Only emit when zero_means_zero is actually enabled. The schema defaults to False
# so this key is always present; emitting unconditionally would force
# USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate.
+1
View File
@@ -123,6 +123,7 @@ class FloatOutput : public BinaryOutput {
virtual void write_state(float state) = 0;
#ifdef USE_OUTPUT_FLOAT_POWER_SCALING
// Codegen skips the setters for these values; keep in sync with output/__init__.py
float max_power_{1.0f};
float min_power_{0.0f};
bool zero_means_zero_{false};
+5 -1
View File
@@ -88,7 +88,11 @@ void PMSA003IComponent::update() {
bool PMSA003IComponent::read_data_(PM25AQIData *data) {
uint8_t buffer[COUNT_DATA_BYTES];
this->read_bytes_raw(buffer, COUNT_DATA_BYTES);
const i2c::ErrorCode error = this->read(buffer, COUNT_DATA_BYTES);
if (error != i2c::ERROR_OK) {
ESP_LOGW(TAG, "I2C error %d", error);
return false;
}
// https://github.com/adafruit/Adafruit_PM25AQI
@@ -2,6 +2,8 @@
#ifdef USE_ESP32
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esp_timer.h"
@@ -12,6 +14,9 @@ namespace esphome::router {
static const char *const TAG = "router.speaker";
// Maximum time to wait for the active output to report running after start() before giving up
static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000;
static inline uint32_t atomic_subtract_clamped(std::atomic<uint32_t> &var, uint32_t amount) {
uint32_t current = var.load(std::memory_order_acquire);
uint32_t subtracted = 0;
@@ -72,6 +77,7 @@ void Router::loop() {
this->apply_cached_state_to_active_();
this->state_ = speaker::STATE_STARTING;
this->state_start_ms_ = App.get_loop_component_start_time();
active->start();
}
return;
@@ -86,10 +92,17 @@ void Router::loop() {
// set_audio_stream_info() and never reaches the output on its own; if the format
// changed while stopped, only start()'s apply_cached_state_to_active_() pushes it
// down before the output's play()-side auto-start locks in the stale format.
if (active->is_stopped()) {
// While STARTING, ignore a transient stopped report as speaker running state
// is set asynchronously from start(). Timeout if the speaker never transitions.
if (this->state_ == speaker::STATE_STARTING) {
if (active->is_running()) {
this->state_ = speaker::STATE_RUNNING;
} else if ((App.get_loop_component_start_time() - this->state_start_ms_) > STATE_TRANSITION_TIMEOUT_MS) {
ESP_LOGW(TAG, "Active output did not start; giving up");
this->state_ = speaker::STATE_STOPPED;
}
} else if (active->is_stopped()) {
this->state_ = speaker::STATE_STOPPED;
} else if (this->state_ == speaker::STATE_STARTING && active->is_running()) {
this->state_ = speaker::STATE_RUNNING;
}
}
@@ -133,6 +146,8 @@ void Router::start() {
this->frames_in_pipeline_.store(0, std::memory_order_release);
this->apply_cached_state_to_active_();
this->state_ = speaker::STATE_STARTING;
// May run on a producer task, so the cached loop timestamp is not usable here
this->state_start_ms_ = millis();
this->get_active_output()->start();
}
@@ -59,6 +59,9 @@ class Router final : public Component, public speaker::Speaker {
// frames_in_pipeline_.
std::atomic<uint32_t> frames_in_pipeline_{0};
// Set when entering STATE_STARTING; used to time out a start the output never acts on
uint32_t state_start_ms_{0};
bool cached_pause_{false};
void apply_cached_state_to_active_();
+3 -1
View File
@@ -344,7 +344,9 @@ _SENSOR_SCHEMA = (
cv.requires_component("mqtt"),
cv.Any(None, cv.positive_time_period_milliseconds),
),
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(
CONF_FILTERS, visibility=cv.Visibility.ADVANCED
): validate_filters,
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation(
+2 -10
View File
@@ -44,16 +44,8 @@ class SPIDelegateHw : public SPIDelegate {
#ifdef USE_RP2
this->channel_->transfer(ptr, nullptr, length);
#elif defined(USE_ESP8266)
// ESP8266 SPI library requires the pointer to be word aligned, but the data may not be
// so we need to copy the data to a temporary buffer
if (reinterpret_cast<uintptr_t>(ptr) & 0x3) {
ESP_LOGVV(TAG, "SPI write buffer not word aligned, copying to temporary buffer");
auto txbuf = std::vector<uint8_t>(length);
memcpy(txbuf.data(), ptr, length);
this->channel_->writeBytes(txbuf.data(), length);
} else {
this->channel_->writeBytes(ptr, length);
}
// writeBytes() needs a word aligned pointer; transferBytes() bounces unaligned chunks through a stack buffer
this->channel_->transferBytes(ptr, nullptr, length);
#else
this->channel_->writeBytes(ptr, length);
#endif
@@ -1,6 +1,8 @@
#include "template_water_heater.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::template_ {
static const char *const TAG = "template.water_heater";
@@ -45,9 +47,12 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
void TemplateWaterHeater::loop() {
bool changed = false;
// NAN is passed through so a source that has no value yet shows as unknown, but NAN never
// equals NAN, so an already-NAN value must not count as a change or it would republish forever.
auto curr_temp = this->current_temperature_f_.call();
if (curr_temp.has_value()) {
if (*curr_temp != this->current_temperature_) {
if (*curr_temp != this->current_temperature_ &&
!(std::isnan(*curr_temp) && std::isnan(this->current_temperature_))) {
this->current_temperature_ = *curr_temp;
changed = true;
}
@@ -55,7 +60,8 @@ void TemplateWaterHeater::loop() {
auto target_temp = this->target_temperature_f_.call();
if (target_temp.has_value()) {
if (*target_temp != this->target_temperature_) {
if (*target_temp != this->target_temperature_ &&
!(std::isnan(*target_temp) && std::isnan(this->target_temperature_))) {
this->target_temperature_ = *target_temp;
changed = true;
}
+3 -1
View File
@@ -148,7 +148,9 @@ _TEXT_SENSOR_SCHEMA = (
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(
CONF_FILTERS, visibility=cv.Visibility.ADVANCED
): validate_filters,
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}),
}
@@ -132,6 +132,9 @@ void IDFUARTComponent::load_settings(bool dump_config) {
this->mark_failed();
return;
}
// Only mark the driver gone once the delete actually succeeded; a failed
// delete leaves the old driver installed and working
this->driver_installed_ = false;
}
err = uart_driver_install(this->uart_num_, // UART number
this->rx_buffer_size_, // RX ring buffer size
@@ -146,6 +149,10 @@ void IDFUARTComponent::load_settings(bool dump_config) {
this->mark_failed();
return;
}
this->driver_installed_ = true;
// Re-arm the dropped-write warning so a later not-installed episode
// (a failed reinstall through load_settings) is loud again
this->warned_not_ready_ = false;
// uart_param_config must be called after uart_driver_install and before any
// other uart_set_*() calls. The driver installation resets the UART peripheral
@@ -282,7 +289,7 @@ esp_err_t IDFUARTComponent::apply_settings_live() {
return ESP_ERR_INVALID_STATE;
}
// No driver yet: nothing to reconfigure in place.
if (!uart_is_driver_installed(this->uart_num_)) {
if (!this->driver_installed_) {
this->load_settings(false);
return this->is_failed() ? ESP_FAIL : ESP_OK;
}
@@ -346,7 +353,7 @@ void IDFUARTComponent::dump_config() {
}
void IDFUARTComponent::set_rx_full_threshold(size_t rx_full_threshold) {
if (this->is_ready()) {
if (this->driver_installed_) {
esp_err_t err = uart_set_rx_full_threshold(this->uart_num_, rx_full_threshold);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err));
@@ -357,7 +364,7 @@ void IDFUARTComponent::set_rx_full_threshold(size_t rx_full_threshold) {
}
void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) {
if (this->is_ready()) {
if (this->driver_installed_) {
esp_err_t err = uart_set_rx_timeout(this->uart_num_, rx_timeout);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err));
@@ -368,6 +375,21 @@ void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) {
}
void IDFUARTComponent::write_array(const uint8_t *data, size_t len) {
if (!this->driver_installed_) {
// Another component used the bus before setup() installed the driver.
// Calling the driver would fail and mark this component failed, which
// would then skip the driver installation entirely and permanently
// disable the bus, so drop the data instead. Warn only once: consumers
// writing from loop() can hit this on every iteration of the setup
// phase's wait loops, which would flood the log.
if (!this->warned_not_ready_) {
this->warned_not_ready_ = true;
ESP_LOGW(TAG, "write_array called before the driver was installed; dropping %zu bytes", len);
} else {
ESP_LOGV(TAG, "write_array called before the driver was installed; dropping %zu bytes", len);
}
return;
}
int32_t write_len = uart_write_bytes(this->uart_num_, data, len);
if (write_len != (int32_t) len) {
ESP_LOGW(TAG, "uart_write_bytes failed: %" PRId32 " != %zu", write_len, len);
@@ -381,6 +403,9 @@ void IDFUARTComponent::write_array(const uint8_t *data, size_t len) {
}
bool IDFUARTComponent::peek_byte(uint8_t *data) {
if (!this->driver_installed_) {
return false;
}
if (!this->check_read_timeout_())
return false;
if (this->has_peek_) {
@@ -398,7 +423,7 @@ bool IDFUARTComponent::peek_byte(uint8_t *data) {
}
bool IDFUARTComponent::read_array(uint8_t *data, size_t len) {
if (len == 0) {
if (len == 0 || !this->driver_installed_) {
return false;
}
size_t length_to_read = len;
@@ -424,6 +449,15 @@ size_t IDFUARTComponent::available() {
size_t available = 0;
esp_err_t err;
if (!this->driver_installed_) {
// The driver is not installed yet; asking the driver would fail and mark
// the whole bus failed, so report no data instead. A stale peeked byte
// must not be counted either: the read paths refuse to deliver it while
// the driver is missing, so advertising it would make the common
// `while (available()) read()` pattern spin forever.
return 0;
}
err = uart_get_buffered_data_len(this->uart_num_, &available);
if (err != ESP_OK) {
@@ -437,6 +471,10 @@ size_t IDFUARTComponent::available() {
}
UARTFlushResult IDFUARTComponent::flush() {
if (!this->driver_installed_) {
// Nothing can be pending before the driver is installed
return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS;
}
ESP_LOGVV(TAG, " Flushing");
TickType_t ticks = this->flush_timeout_ms_ == 0 ? portMAX_DELAY : pdMS_TO_TICKS(this->flush_timeout_ms_);
esp_err_t err = uart_wait_tx_done(this->uart_num_, ticks);
@@ -468,7 +506,9 @@ void IDFUARTComponent::on_shutdown() {
esp_err_t err = uart_driver_delete(this->uart_num_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err));
return;
}
this->driver_installed_ = false;
}
} // namespace esphome::uart
@@ -75,11 +75,10 @@ class IDFUARTComponent final : public UARTComponent, public Component {
protected:
void check_logger_conflict() override;
uart_config_t get_config_();
uint32_t line_inversion_mask_();
// Re-applies what uart_param_config() resets: inversion, RX threshold/timeout, mode.
esp_err_t apply_line_settings_();
uart_port_t uart_num_{UART_NUM_MAX};
uart_config_t get_config_();
struct Framing {
uint32_t baud_rate;
@@ -92,12 +91,21 @@ class IDFUARTComponent final : public UARTComponent, public Component {
return {this->baud_rate_, this->data_bits_, this->stop_bits_, this->parity_, this->rx_full_threshold_};
}
void set_framing_(const Framing &framing);
// Members ordered largest to smallest to minimize padding
// Last framing the driver accepted; baud_rate 0 means none yet.
Framing last_good_framing_{};
bool has_peek_{false};
uint8_t peek_byte_;
uart_port_t uart_num_{UART_NUM_MAX};
uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY).
uint8_t peek_byte_;
bool has_peek_{false};
/// True while the driver this component installed for uart_num_ is in place. Gates all
/// driver-touching I/O: uart_is_driver_installed() cannot be used as the predicate since
/// it also reports a driver another component installed on the same port. Deliberately
/// not tied to the component state so a bus marked failed after a successful install
/// keeps serving I/O like it always did, and load_settings() can revive it.
bool driver_installed_{false};
bool warned_not_ready_{false};
#ifdef USE_UART_WAKE_LOOP_ON_RX
// ISR callback for UART RX data notification — wakes the main loop directly.
@@ -7,15 +7,47 @@
#include "esphome/core/lock_free_queue.h"
#include "esphome/components/uart/uart_component.h"
#include <array>
#include <atomic>
#include <cstring>
#include <functional>
#include "freertos/ringbuf.h"
#include "esp_err.h"
#include "tinyusb_cdc_acm.h"
namespace esphome::usb_cdc_acm {
static const uint8_t EVENT_QUEUE_SIZE = 12;
// Drain up to out_buf_sz bytes from a byte ring buffer, handling FreeRTOS's wrapped
// case with a second read. Shared with the cdc_acm_uart bridge platform, whose worker
// tasks drain the same ring buffers.
inline esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size,
TickType_t x_ticks_to_wait) {
size_t read_sz;
uint8_t *buf = static_cast<uint8_t *>(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz));
if (buf == nullptr) {
return ESP_FAIL;
}
memcpy(out_buf, buf, read_sz);
vRingbufferReturnItem(ring_buf, (void *) buf);
*rx_data_size = read_sz;
// Buffer's data can be wrapped, in which case we should perform another read
if (*rx_data_size < out_buf_sz) {
buf = static_cast<uint8_t *>(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size));
if (buf != nullptr) {
memcpy(out_buf + *rx_data_size, buf, read_sz);
vRingbufferReturnItem(ring_buf, (void *) buf);
*rx_data_size += read_sz;
}
}
return ESP_OK;
}
// Callback types for line coding and line state changes
using LineCodingCallback = std::function<void(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, uint8_t data_bits)>;
using LineStateCallback = std::function<void(bool dtr, bool rts)>;
@@ -103,6 +135,8 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented<USBC
RingbufHandle_t usb_tx_ringbuf_{nullptr};
RingbufHandle_t usb_rx_ringbuf_{nullptr};
// TX task staging; a member rather than a stack array so it does not size the task stack.
std::array<uint8_t, CONFIG_TINYUSB_CDC_TX_BUFSIZE> usb_tx_staging_{};
// Non-zero while the TX task holds bytes it has pulled from the ring buffer but not
// yet handed to TinyUSB; lets flush() account for data that is in neither the ring
// buffer nor TinyUSB's FIFO.
@@ -104,30 +104,6 @@ static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *ev
instance->queue_line_coding_event(bit_rate, stop_bits, parity, data_bits);
}
static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size,
TickType_t x_ticks_to_wait) {
size_t read_sz;
uint8_t *buf = static_cast<uint8_t *>(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz));
if (buf == nullptr) {
return ESP_FAIL;
}
memcpy(out_buf, buf, read_sz);
vRingbufferReturnItem(ring_buf, (void *) buf);
*rx_data_size = read_sz;
// Buffer's data can be wrapped, in which case we should perform another read
buf = static_cast<uint8_t *>(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size));
if (buf != nullptr) {
memcpy(out_buf + *rx_data_size, buf, read_sz);
vRingbufferReturnItem(ring_buf, (void *) buf);
*rx_data_size += read_sz;
}
return ESP_OK;
}
//==============================================================================
// USBCDCACMInstance Implementation
//==============================================================================
@@ -192,7 +168,7 @@ void USBCDCACMInstance::usb_tx_task_fn(void *arg) {
}
void USBCDCACMInstance::usb_tx_task() {
uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0};
uint8_t *data = this->usb_tx_staging_.data();
size_t tx_data_size = 0;
// Back-dated so a stall within the first LOG_THROTTLE_MS of uptime still logs
// immediately (unsigned arithmetic keeps this wrap-safe).
+15 -5
View File
@@ -56,6 +56,10 @@ CONF_SORTING_GROUPS = "sorting_groups"
CONF_SORTING_WEIGHT = "sorting_weight"
CONF_ALLOWED_ORIGINS = "allowed_origins"
# Schema default that also matches the C++ initializer in web_server_base.h; codegen
# skips the setter when the config equals it.
DEFAULT_PORT = 80
web_server_ns = cg.esphome_ns.namespace("web_server")
WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller)
@@ -251,7 +255,7 @@ CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(WebServer),
cv.Optional(CONF_PORT, default=80): cv.port,
cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
cv.Optional(CONF_VERSION, default=2): cv.one_of(1, 2, 3, int=True),
cv.Optional(CONF_CSS_URL): cv.string,
cv.Optional(CONF_CSS_INCLUDE): cv.file_,
@@ -379,9 +383,11 @@ async def to_code(config: ConfigType) -> None:
version = config[CONF_VERSION]
cg.add(paren.set_port(config[CONF_PORT]))
# Skip the setter when the config matches the C++ initializer (DEFAULT_PORT).
if (port := config[CONF_PORT]) != DEFAULT_PORT:
cg.add(paren.set_port(port))
cg.add_define("USE_WEBSERVER")
cg.add_define("USE_WEBSERVER_PORT", config[CONF_PORT])
cg.add_define("USE_WEBSERVER_PORT", port)
cg.add_define("USE_WEBSERVER_VERSION", version)
if version >= 2:
# Don't compress the index HTML as the data sizes are almost the same.
@@ -395,9 +401,11 @@ async def to_code(config: ConfigType) -> None:
# Captive portal will still be able to perform OTA updates even when this is set
if config.get(CONF_OTA) is False:
cg.add_define("USE_WEBSERVER_OTA_DISABLED")
cg.add(var.set_expose_log(config[CONF_LOG]))
# expose_log_ is true in C++; only emit the setter to turn it off.
if config[CONF_LOG]:
request_log_listener() # Request a log listener slot for web server log streaming
else:
cg.add(var.set_expose_log(False))
if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]:
cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS")
if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None:
@@ -433,7 +441,9 @@ async def to_code(config: ConfigType) -> None:
path = CORE.relative_config_path(config[CONF_JS_INCLUDE])
with path.open(encoding="utf-8") as js_file:
add_resource_as_progmem("JS_INCLUDE", js_file.read())
cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL]))
# include_internal_ is false in C++; only emit the setter to turn it on.
if config[CONF_INCLUDE_INTERNAL]:
cg.add(var.set_include_internal(True))
if CONF_LOCAL in config and config[CONF_LOCAL]:
cg.add_define("USE_WEBSERVER_LOCAL")
if config[CONF_COMPRESSION] == "gzip":
@@ -170,7 +170,7 @@ class WebServerBase final {
protected:
uint8_t initialized_{0};
uint16_t port_{80};
uint16_t port_{80}; // Keep in sync with DEFAULT_PORT in web_server/__init__.py
AsyncWebServer *server_{nullptr};
std::vector<AsyncWebHandler *> handlers_;
#ifdef USE_WEBSERVER_AUTH
+30 -9
View File
@@ -169,6 +169,9 @@ MAX_WIFI_NETWORKS = 127
# get best-effort connection attempts. Longer timeout ensures we exhaust all options
# before falling back to AP mode. Aligned with improv wifi_timeout default.
DEFAULT_AP_TIMEOUT = "90s"
DEFAULT_REBOOT_TIMEOUT = "15min"
# Both defaults also match the C++ initializers in wifi_component.h; codegen skips
# the setter when the config equals them.
wifi_ns = cg.esphome_ns.namespace("wifi")
EAPAuth = wifi_ns.struct("EAPAuth")
@@ -288,7 +291,9 @@ WIFI_NETWORK_BASE = cv.Schema(
cv.Optional(CONF_SSID): cv.sensitive(cv.ssid),
cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password),
cv.Optional(CONF_CHANNEL): validate_channel,
cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA,
cv.Optional(
CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED
): STA_MANUAL_IP_SCHEMA,
}
)
@@ -487,12 +492,14 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_SSID): cv.sensitive(cv.ssid),
cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password),
cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA,
cv.Optional(
CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED
): STA_MANUAL_IP_SCHEMA,
cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA,
cv.Optional(CONF_AP): wifi_network_ap,
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
cv.Optional(
CONF_REBOOT_TIMEOUT, default="15min"
CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT
): cv.positive_time_period_milliseconds,
cv.SplitDefault(
CONF_POWER_SAVE_MODE,
@@ -602,7 +609,8 @@ def wifi_network(config, ap, static_ip):
cg.add(ap.set_channel(config[CONF_CHANNEL]))
if static_ip is not None:
cg.add(ap.set_manual_ip(manual_ip(static_ip)))
if CONF_PRIORITY in config:
# priority_ is 0 in C++; skip the setter when the config matches it.
if config.get(CONF_PRIORITY, 0) != 0:
cg.add(ap.set_priority(config[CONF_PRIORITY]))
return ap
@@ -626,6 +634,9 @@ async def to_code(config):
networks = config.get(CONF_NETWORKS, [])
if networks:
cg.add(var.init_sta(len(networks)))
if len(networks) > 1:
# The ESP32 scan can filter one SSID in the driver; with several the whole list is kept
cg.add_define("USE_WIFI_MULTI_SSID")
def add_sta(ap: cg.MockObj, network: dict) -> None:
ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP))
@@ -648,7 +659,9 @@ async def to_code(config):
WiFiAP(),
lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))),
)
cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT]))
# Skip the setter when the config matches the C++ initializer.
if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT):
cg.add(var.set_ap_timeout(ap_timeout))
cg.add_define("USE_WIFI_AP")
# ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which
@@ -670,10 +683,18 @@ async def to_code(config):
if has_manual_ip:
cg.add_define("USE_WIFI_MANUAL_IP")
cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT]))
cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE]))
if CONF_MIN_AUTH_MODE in config:
cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE]))
# The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum
# auth WPA2; skip the setters when the config matches them.
if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period(
DEFAULT_REBOOT_TIMEOUT
):
cg.add(var.set_reboot_timeout(reboot_timeout))
if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE":
cg.add(var.set_power_save_mode(power_save_mode))
if (
min_auth_mode := config.get(CONF_MIN_AUTH_MODE)
) is not None and min_auth_mode != "WPA2":
cg.add(var.set_min_auth_mode(min_auth_mode))
fast_connect = config[CONF_FAST_CONNECT]
if fast_connect[CONF_ENABLED]:
cg.add_define("USE_WIFI_FAST_CONNECT")
+3 -3
View File
@@ -1499,8 +1499,8 @@ void WiFiComponent::check_scanning_finished() {
return;
}
this->scan_done_ = false;
this->has_completed_scan_after_captive_portal_start_ =
true; // Track that we've done a scan since captive portal started
// A driver filtered scan saw one SSID; a portal that started during it still needs a full scan
this->has_completed_scan_after_captive_portal_start_ = !this->is_scan_driver_filtered_();
this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED;
if (this->scan_result_.empty()) {
@@ -2416,7 +2416,7 @@ void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) {
void WiFiComponent::release_scan_results_() {
if (!this->keep_scan_results_) {
ScanResultsLock lock(this);
#if defined(USE_RP2) || defined(USE_ESP32)
#if defined(USE_RP2)
// std::vector - use swap trick since shrink_to_fit is non-binding
decltype(this->scan_result_)().swap(this->scan_result_);
#else
+13 -7
View File
@@ -178,12 +178,12 @@ struct EAPAuth {
using bssid_t = std::array<uint8_t, 6>;
/// Initial reserve size for filtered scan results (typical: 1-3 matching networks per SSID)
static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8;
// ESP32 with one configured network: the driver filters the scan by its SSID and only this many of
// its BSSIDs are kept, the strongest ones
static constexpr size_t WIFI_SCAN_RESULT_BOUND = 12;
// Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API)
// Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible
#if defined(USE_RP2) || defined(USE_ESP32)
// RP2040's callback delivers results one at a time with no count, so it needs a growable vector
#if defined(USE_RP2)
template<typename T> using wifi_scan_vector_t = std::vector<T>;
#else
template<typename T> using wifi_scan_vector_t = FixedVector<T>;
@@ -919,11 +919,11 @@ class WiFiComponent final : public Component {
float output_power_{NAN};
uint32_t action_started_;
uint32_t last_connected_{0};
uint32_t reboot_timeout_{};
uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py
uint32_t roaming_last_check_{0};
uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed
#ifdef USE_WIFI_AP
uint32_t ap_timeout_{};
uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py
#endif
// 1-byte enums and integers
@@ -954,6 +954,12 @@ class WiFiComponent final : public Component {
uint8_t num_ipv6_addresses_{0};
#endif /* USE_NETWORK_IPV6 */
bool error_from_callback_{false};
#if defined(USE_ESP32) && !defined(USE_WIFI_MULTI_SSID)
bool scan_driver_filtered_{false};
bool is_scan_driver_filtered_() const { return this->scan_driver_filtered_; }
#else
constexpr bool is_scan_driver_filtered_() const { return false; }
#endif
#if defined(USE_ESP8266) || defined(USE_LIBRETINY)
// Platform-specific STA state enum, defined in platform cpp file.
// On ESP8266, written from SDK system context (wifi_event_callback) —
@@ -773,7 +773,11 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
}
}
this->scan_result_.init(count); // Exact allocation
if (!this->scan_result_.try_init(count)) {
ESP_LOGW(TAG, "No memory for %zu scan results", count);
this->scan_done_ = true;
return;
}
// Second pass: store matching networks
for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) {
@@ -909,7 +909,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id);
uint16_t number = it.number;
bool needs_full = this->needs_full_scan_results_();
const bool filtered = this->is_scan_driver_filtered_();
const bool needs_full = this->needs_full_scan_results_();
{
// Mutate in place under the lock; blocking a portal request is fine and
// avoids scratch buffers
@@ -926,8 +927,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
return;
}
// Smart reserve: full capacity if needed, small reserve otherwise
this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE);
const size_t wanted = filtered ? std::min<size_t>(number, WIFI_SCAN_RESULT_BOUND) : number;
// Storage is reused across the scans of one retry cycle and freed on connect; an exhausted
// heap drops this scan and the retry logic scans again
if (this->scan_result_.capacity() < wanted && !this->scan_result_.try_init(wanted)) {
esp_wifi_clear_ap_list();
ESP_LOGW(TAG, "No memory for %zu scan results", wanted);
return;
}
#ifdef USE_ESP32_HOSTED
// getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor
@@ -955,22 +962,38 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
}
#endif // USE_ESP32_HOSTED
// Check C string first - avoid std::string construction for non-matching networks
const char *ssid_cstr = reinterpret_cast<const char *>(record.ssid);
// Only construct std::string and store if needed
if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) {
bssid_t bssid;
std::copy(record.bssid, record.bssid + 6, bssid.begin());
if (!needs_full && !this->matches_configured_network_(ssid_cstr, record.bssid)) {
this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary);
continue;
}
bssid_t bssid;
std::copy(record.bssid, record.bssid + 6, bssid.begin());
if (this->scan_result_.size() < wanted) {
this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi,
record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0');
} else {
this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary);
continue;
}
// Records arrive in scan order, not by signal, so a bounded store keeps the strongest by
// replacing its weakest entry. Only SSID and signal decide here; a channel or auth constrained
// network hidden behind 12 stronger APs of its own SSID is not a real deployment
WiFiScanResult *weakest = &this->scan_result_[0];
for (auto &res : this->scan_result_) {
if (res.get_rssi() < weakest->get_rssi())
weakest = &res;
}
if (record.rssi <= weakest->get_rssi()) {
this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary);
continue;
}
// Rebuilt in place rather than assigned; assignment pulls in CompactString's operators, 104 B of flash
weakest->~WiFiScanResult();
new (weakest) WiFiScanResult(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi,
record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0');
}
}
ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
filtered ? LOG_STR_LITERAL(" (driver filtered)") : LOG_STR_LITERAL(""));
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
this->notify_scan_results_listeners_();
#endif
@@ -1047,6 +1070,16 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
wifi_scan_config_t config{};
config.ssid = nullptr;
config.bssid = nullptr;
#ifndef USE_WIFI_MULTI_SSID
// One configured network with an SSID: let the driver keep only its APs, so the WiFi library
// holds fewer records during the scan. Full results (portal, provisioning, listeners) and a
// network configured by BSSID alone still scan everything
this->scan_driver_filtered_ =
!this->needs_full_scan_results_() && this->sta_.size() == 1 && !this->sta_[0].get_ssid().empty();
if (this->scan_driver_filtered_) {
config.ssid = const_cast<uint8_t *>(reinterpret_cast<const uint8_t *>(this->sta_[0].get_ssid().c_str()));
}
#endif
config.channel = 0;
config.show_hidden = true;
config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE;
@@ -709,7 +709,11 @@ void WiFiComponent::wifi_scan_done_callback_() {
}
}
this->scan_result_.init(count); // Exact allocation
if (!this->scan_result_.try_init(count)) {
ESP_LOGW(TAG, "No memory for %zu scan results", count);
WiFi.scanDelete();
return;
}
// Second pass: store matching networks
for (int i = 0; i < num; i++) {
+40 -26
View File
@@ -16,6 +16,7 @@ from ipaddress import (
ip_network,
)
import logging
import os
from pathlib import Path
import re
from string import ascii_letters, digits
@@ -1999,38 +2000,51 @@ def _remap_bundle_path(value: str) -> Path | None:
return remap_bundle_path(value)
def directory(value: object) -> Path:
value = string(value)
path = CORE.relative_config_path(value)
def _declaring_document(value: str) -> Path | None:
"""Return the on-disk YAML file *value* was loaded from, absolute, or None."""
esp_range = getattr(value, "esp_range", None)
if esp_range is None:
return None
document = Path(esp_range.start_mark.document).absolute()
return document if document.is_file() else None
if not path.exists():
remapped = _remap_bundle_path(value)
if remapped is None:
def _existing_path(value: str, kind: str, is_kind: Callable[[Path], bool]) -> Path:
"""Resolve *value* to a *kind* entry: config dir, then declaring document, then bundle remap."""
path = CORE.relative_config_path(value)
if is_kind(path):
return path
candidates = [path]
tried_document: Path | None = None
if (document := _declaring_document(value)) is not None:
beside_document = document.parent / Path(value).expanduser()
if os.path.normpath(beside_document) != os.path.normpath(path):
candidates.append(beside_document)
tried_document = document
if (remapped := _remap_bundle_path(value)) is not None:
candidates.append(remapped)
for candidate in candidates:
if is_kind(candidate):
return candidate
for candidate in candidates:
if candidate.exists():
raise Invalid(
f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})."
f"Path '{candidate}' is not a {kind} (full path: {candidate.resolve()})."
)
path = remapped
if not path.is_dir():
raise Invalid(
f"Path '{path}' is not a directory (full path: {path.resolve()})."
)
return path
also = (
f" Also looked next to {tried_document}." if tried_document is not None else ""
)
raise Invalid(
f"Could not find {kind} '{path}'. Please make sure it exists (full path: {path.resolve()}).{also}"
)
def directory(value: object) -> Path:
return _existing_path(string(value), "directory", Path.is_dir)
def file_(value: object) -> Path:
value = string(value)
path = CORE.relative_config_path(value)
if not path.exists():
remapped = _remap_bundle_path(value)
if remapped is None:
raise Invalid(
f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})."
)
path = remapped
if not path.is_file():
raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).")
return path
return _existing_path(string(value), "file", Path.is_file)
ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_"
+1
View File
@@ -715,6 +715,7 @@ class EsphomeCore:
self.defines = set()
self.platformio_options = {}
self.loaded_integrations = set()
self.loaded_platforms = set()
self.component_ids = set()
self.platform_counts = defaultdict(int)
self.unique_ids = {}
+4
View File
@@ -298,6 +298,8 @@
#ifdef USE_ARDUINO
#define USE_PROMETHEUS
#define USE_WIFI_WPA2_EAP
// Kept in the Arduino block so clang-tidy sees both scan storage paths
#define USE_WIFI_MULTI_SSID
#endif
// Platforms with native 64-bit time sources (no rollover tracking needed)
@@ -329,6 +331,8 @@
#else
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 0
#endif
// Defined here so static analysis parses the slot and its call site.
#define USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16
#endif
+80 -12
View File
@@ -5,16 +5,20 @@
#include <cassert>
#include <cmath>
#include <cstdarg>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <iterator>
#include <limits>
#include <memory>
#include <new>
#include <span>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
#include <concepts>
#include <strings.h>
@@ -38,6 +42,7 @@
#endif
#ifdef USE_ESP32
#include <esp_system.h>
#include <esp_heap_caps.h>
#endif
@@ -539,7 +544,15 @@ template<typename T, size_t N> inline void init_array_from(std::array<T, N> &des
}
}
/// Fixed-capacity vector - allocates once at runtime, never reallocates
// Abort with a reason that reaches the panic output on ESP32. Elsewhere the literal is dropped
// before it can land in rodata, which is RAM on ESP8266
#ifdef USE_ESP32
#define ESPHOME_ABORT_WITH_REASON(reason) esp_system_abort(reason)
#else
#define ESPHOME_ABORT_WITH_REASON(reason) abort()
#endif
/// Fixed-capacity vector - sized once through init() or try_init(); push_back never reallocates
/// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append)
/// when size is known at initialization but not at compile time
template<typename T> class FixedVector {
@@ -562,8 +575,7 @@ template<typename T> class FixedVector {
void cleanup_() {
if (data_ != nullptr) {
destroy_elements_();
// Free raw memory
::operator delete(data_);
free(data_); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc)
}
}
@@ -632,16 +644,27 @@ template<typename T> class FixedVector {
// Allocate capacity - can be called multiple times to reinit
// IMPORTANT: After calling init(), you MUST use push_back() to add elements.
// Direct assignment via operator[] does NOT update the size counter.
// Aborts on exhaustion; use try_init() to handle failure.
void init(size_t n) {
if (!try_init(n))
ESPHOME_ABORT_WITH_REASON("FixedVector: out of memory");
}
// Same as init(), but returns false when memory is exhausted; the previous storage is freed either way
bool try_init(size_t n) {
cleanup_();
reset_();
if (n > 0) {
// Allocate raw memory without calling constructors
// sizeof(T) is correct here for any type T (value types, pointers, etc.)
// NOLINTNEXTLINE(bugprone-sizeof-expression)
data_ = static_cast<T *>(::operator new(n * sizeof(T)));
capacity_ = n;
}
if (n == 0)
return true;
if (n > SIZE_MAX / sizeof(T))
return false; // the byte count would wrap into a small block
// sizeof(T) is correct here for any type T (value types, pointers, etc.)
// NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory)
data_ = static_cast<T *>(malloc(n * sizeof(T)));
if (data_ == nullptr)
return false;
capacity_ = n;
return true;
}
// Clear the vector (destroy all elements, reset size to 0, keep capacity)
@@ -738,14 +761,22 @@ template<typename T> class FixedVector {
template<size_t STACK_SIZE, typename T = uint8_t> class SmallBufferWithHeapFallback {
public:
explicit SmallBufferWithHeapFallback(size_t size) {
static_assert(std::is_trivially_default_constructible_v<T> && std::is_trivially_destructible_v<T>,
"the heap fallback leaves elements unconstructed");
if (size <= STACK_SIZE) {
this->buffer_ = this->stack_buffer_;
} else {
this->heap_buffer_ = new T[size];
if (size <= SIZE_MAX / sizeof(T)) {
// NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory)
this->heap_buffer_ = static_cast<T *>(malloc(size * sizeof(T)));
}
// Callers write through get() unchecked, so exhaustion aborts like the new[] it replaces
if (this->heap_buffer_ == nullptr)
ESPHOME_ABORT_WITH_REASON("SmallBufferWithHeapFallback: out of memory");
this->buffer_ = this->heap_buffer_;
}
}
~SmallBufferWithHeapFallback() { delete[] this->heap_buffer_; }
~SmallBufferWithHeapFallback() { free(this->heap_buffer_); } // NOLINT(cppcoreguidelines-no-malloc)
// Delete copy and move operations to prevent double-delete
SmallBufferWithHeapFallback(const SmallBufferWithHeapFallback &) = delete;
@@ -2095,6 +2126,10 @@ void delay_microseconds_safe(uint32_t us);
/// @name Memory management
///@{
template<typename T> struct RAMDeleter;
/// unique_ptr over RAMAllocator storage
template<typename T> using RAMUniquePtr = std::unique_ptr<T, RAMDeleter<T>>;
/** An STL allocator that uses SPI or internal RAM.
* Returns `nullptr` in case no memory is available.
*
@@ -2165,6 +2200,26 @@ template<class T> class RAMAllocator {
free(p); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc)
}
/// Value initialize one T; empty on exhaustion. new (std::nothrow) aborts on ESP-IDF instead.
/// Default flags prefer PSRAM; pass PREFER_INTERNAL to keep an object where plain new put it.
template<typename... Args> RAMUniquePtr<T> make_unique(Args &&...args) {
static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type");
T *p = this->allocate(1);
if (p == nullptr)
return {};
// ::new so a class scoped operator new cannot hide the global placement form
return RAMUniquePtr<T>(::new (p) T(std::forward<Args>(args)...));
}
/// n elements left uninitialized, as std::make_unique_for_overwrite does; empty on exhaustion, overflow, and n == 0
RAMUniquePtr<T[]> make_unique_array_for_overwrite(size_t n) {
static_assert(std::is_trivially_default_constructible_v<T>, "elements are left unconstructed");
static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type");
if (n == 0 || n > SIZE_MAX / sizeof(T))
return {};
return RAMUniquePtr<T[]>(this->allocate(n));
}
/**
* Return the total heap space available via this allocator
*/
@@ -2227,6 +2282,19 @@ template<class T> class RAMAllocator {
template<class T> using ExternalRAMAllocator = RAMAllocator<T>;
/// Destroys and frees RAMAllocator storage. Not convertible: free() needs the address malloc returned
template<typename T> struct RAMDeleter {
void operator()(T *p) const {
p->~T();
RAMAllocator<T>().deallocate(p, 1);
}
};
/// Array form: elements must be trivial, the count is not stored so only the storage is freed
template<typename T> struct RAMDeleter<T[]> {
static_assert(std::is_trivially_destructible_v<T>, "RAMUniquePtr<T[]> is for trivially destructible elements");
void operator()(T *p) const { RAMAllocator<T>().deallocate(p, 1); }
};
/**
* Functions to constrain the range of arithmetic values.
*/
+15 -3
View File
@@ -45,6 +45,7 @@ the last `yield` expression defines what is returned.
from __future__ import annotations
from collections.abc import Awaitable, Callable, Generator, Iterator
import contextvars
import enum
import functools
import heapq
@@ -277,14 +278,22 @@ class _Task:
id_number: int,
iterator: Iterator[None],
original_function: Any,
context: contextvars.Context,
):
self.priority = priority
self.id_number = id_number
self.iterator = iterator
self.original_function = original_function
self.context = context
def with_priority(self, priority: float) -> _Task:
return _Task(priority, self.id_number, self.iterator, self.original_function)
return _Task(
priority,
self.id_number,
self.iterator,
self.original_function,
self.context,
)
@property
def _cmp_tuple(self) -> tuple[float, int]:
@@ -321,7 +330,10 @@ class FakeEventLoop:
coro = coroutine(func)
gen = coro(*args, **kwargs)
prio = getattr(coro, "priority", 0.0)
task = _Task(prio, self._task_counter, gen, func)
# Each task gets its own copy of the current context, isolating any
# contextvars it sets from other tasks the scheduler interleaves it with
# (mirrors what asyncio.Task does internally).
task = _Task(prio, self._task_counter, gen, func, contextvars.copy_context())
self._task_counter += 1
heapq.heappush(self._pending_tasks, task)
@@ -352,7 +364,7 @@ class FakeEventLoop:
)
try:
next(task.iterator)
task.context.run(next, task.iterator)
# Decrease priority over time, so that if this task is blocked
# due to a dependency others will clear the dependency
# This could be improved with a less naive approach
+1 -1
View File
@@ -4,7 +4,7 @@ dependencies:
esphome/dlms_parser:
version: 1.1.0
esphome/esp-audio-libs:
version: 3.2.1
version: 4.0.0
esphome/esp-micro-speech-features:
version: 1.2.3
esphome/micro-decoder:
+3 -3
View File
@@ -45,7 +45,7 @@ lib_deps_base =
lib_deps =
${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
esphome/noise-c@0.1.26 ; noise (api, ota)
esphome/noise-c@0.1.30 ; noise (api, ota)
improv/Improv@1.2.7 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -244,7 +244,7 @@ lib_deps =
${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard
esphome/noise-c@0.1.26 ; noise (api, ota)
esphome/noise-c@0.1.30 ; noise (api, ota)
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -641,7 +641,7 @@ build_unflags =
extends = common
platform = platformio/native
lib_deps =
esphome/noise-c@0.1.26 ; used by noise (api, ota)
esphome/noise-c@0.1.30 ; used by noise (api, ota)
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
+1
View File
@@ -81,6 +81,7 @@ ISOLATED_SIGNATURE_PREFIX = "isolated_"
# NOTE: This should be kept in sync with both test_build_components and split_components_for_ci.py
ISOLATED_COMPONENTS = {
"animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display",
"cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host",
"esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged",
"ethernet": "Defines ethernet: which conflicts with wifi: used by most components",
"ethernet_info": "Related to ethernet component which conflicts with wifi",
+2 -1
View File
@@ -36,7 +36,8 @@ PLATFORMIO_OPTIONS = {
def run_tests(selected_components: list[str]) -> int:
os.environ["ASAN_OPTIONS"] = "detect_leaks=0"
# allocator_may_return_null: an oversized request must come back empty, not abort the run
os.environ["ASAN_OPTIONS"] = "detect_leaks=0:allocator_may_return_null=1"
return build_and_run(
selected_components=selected_components,
tests_dir=COMPONENTS_TESTS_DIR,
@@ -0,0 +1,13 @@
"""The codegen hook external filtering components use to turn on the filter slot."""
from esphome.components import bluetooth_proxy
from esphome.core import CORE
def test_enable_advertisement_filter_emits_define() -> None:
"""External components call this rather than emitting the define."""
bluetooth_proxy.enable_advertisement_filter()
assert "USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER" in {
define.name for define in CORE.defines
}
@@ -0,0 +1,154 @@
"""Tests for the bridge cdc_acm_uart platform's final validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.cdc_acm_uart import bridge
from esphome.components.cdc_acm_uart.bridge import CONF_USB_CDC_ACM_ID
from esphome.config import Config
from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID, PlatformFramework
from esphome.core import ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
_final_validate = bridge._final_validate
def _set_esp32_s3(set_core_config: SetCoreConfigCallable, **kwargs) -> None:
from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32S3},
**kwargs,
)
def _full_config(uarts: list[ConfigType] | None = None, **domains) -> Config:
"""A full config declaring uart_0 and uart_1 (plus any extra entries), as the ID
pass leaves it, so the debug check can resolve a uart_id to its declaration."""
uarts = uarts or [{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1")}]
full = Config()
full["uart"] = uarts
for index, uart_conf in enumerate(uarts):
full.declare_ids.append((uart_conf[CONF_ID], ["uart", index, CONF_ID]))
full.update(domains)
return full
def _bridge_config(uart_id: str, cdc_id: str) -> dict:
return {CONF_UART_ID: ID(uart_id), CONF_USB_CDC_ACM_ID: ID(cdc_id)}
def test_accepts_distinct_uart_and_cdc_interfaces(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config, full_config=_full_config())
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
_final_validate(_bridge_config("uart_1", "cdc_acm_2"))
def test_rejects_two_bridges_sharing_a_uart(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config, full_config=_full_config())
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
with pytest.raises(cv.Invalid, match="already bridged"):
_final_validate(_bridge_config("uart_0", "cdc_acm_2"))
def test_rejects_two_bridges_sharing_a_cdc_interface(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config, full_config=_full_config())
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
with pytest.raises(cv.Invalid, match="already bridged"):
_final_validate(_bridge_config("uart_1", "cdc_acm_1"))
def test_rejects_uart_shared_with_another_component(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(
set_core_config,
full_config=_full_config(
sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_0")}],
),
)
with pytest.raises(cv.Invalid, match="exclusive"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_rejects_cdc_interface_shared_with_another_component(
set_core_config: SetCoreConfigCallable,
) -> None:
# The CDC instance is itself a uart::UARTComponent, so other components can bind
# it as a plain UART via uart_id -- that must be rejected just like UART sharing.
_set_esp32_s3(
set_core_config,
full_config=_full_config(
sensor=[{"platform": "pzemac", CONF_UART_ID: ID("cdc_acm_1")}],
),
)
with pytest.raises(cv.Invalid, match="exclusive"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_rejects_uart_referenced_from_nested_config(
set_core_config: SetCoreConfigCallable,
) -> None:
# References can sit arbitrarily deep, e.g. inside an automation's action list.
_set_esp32_s3(
set_core_config,
full_config=_full_config(
binary_sensor=[
{
"platform": "gpio",
"on_press": [{"then": [{CONF_UART_ID: ID("uart_0")}]}],
}
],
),
)
with pytest.raises(cv.Invalid, match="exclusive"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_ignores_other_components_on_other_uarts(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(
set_core_config,
full_config=_full_config(
sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_1")}],
# The bridge domain itself is skipped: this bridge's own entry (and any
# bridge-vs-bridge sharing, which the seen-set already rejects) must not
# trip the exclusivity scan.
bridge=[_bridge_config("uart_0", "cdc_acm_1")],
),
)
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_rejects_debug_on_bridged_uart(
set_core_config: SetCoreConfigCallable,
) -> None:
# The bridge talks to the IDF driver directly, so the uart debugger would see
# nothing and its dummy_receiver would steal RX bytes.
_set_esp32_s3(
set_core_config,
full_config=_full_config(uarts=[{CONF_ID: ID("uart_0"), CONF_DEBUG: {}}]),
)
with pytest.raises(cv.Invalid, match="debug"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_allows_debug_on_other_uart(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(
set_core_config,
full_config=_full_config(
uarts=[{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1"), CONF_DEBUG: {}}]
),
)
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
@@ -0,0 +1,53 @@
"""Power-user fields are marked as advanced on the shared schemas.
``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs
whose defaults suit nearly every user, so a schema-aware editor should keep
them behind its "advanced settings" disclosure rather than on the main form.
"""
from __future__ import annotations
import importlib
import pytest
from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi
import esphome.config_validation as cv
def _markers(schema: cv.Schema) -> dict[str, object]:
s = schema
if hasattr(s, "validators"):
# cv.All -> the schema is the first validator.
s = s.validators[0]
return {str(k): k for k in s.schema}
def _gpio_switch_schema() -> cv.Schema:
return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA
@pytest.mark.parametrize(
("label", "schema_factory", "fields"),
[
("sensor", sensor.sensor_schema, ["filters"]),
("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]),
("text_sensor", text_sensor.text_sensor_schema, ["filters"]),
("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]),
("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]),
("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]),
("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]),
],
)
def test_power_user_fields_are_advanced(
label: str, schema_factory, fields: list[str]
) -> None:
markers = _markers(schema_factory())
for field in fields:
assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}"
def test_interlock_wait_time_keeps_its_default() -> None:
"""Marking the field advanced must not drop its default."""
markers = _markers(_gpio_switch_schema())
assert markers["interlock_wait_time"].default() == "0ms"
+8 -3
View File
@@ -60,7 +60,7 @@ def reset_core() -> Generator[None]:
@pytest.fixture(autouse=True)
def reset_full_config() -> Generator[None]:
"""Give each test a clean final-validate config and restore it after."""
token = final_validate.full_config.set({})
token = final_validate.full_config.set(Config())
yield
final_validate.full_config.reset(token)
@@ -75,7 +75,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]:
*,
core_data: ConfigType | None = None,
platform_data: ConfigType | None = None,
full_config: dict[str, ConfigType] | None = None,
full_config: dict[str, ConfigType] | Config | None = None,
) -> None:
platform, framework = platform_framework.value
@@ -94,7 +94,12 @@ def set_core_config() -> Generator[SetCoreConfigCallable]:
CORE.data[platform.value] = platform_data
config.path_context.set([])
final_validate.full_config.set(full_config or Config())
# Production always installs a Config (a FinalValidateConfig), never a plain dict.
if not isinstance(full_config, Config):
full = Config()
full.update(full_config or {})
full_config = full
final_validate.full_config.set(full_config)
yield setter
@@ -0,0 +1,19 @@
---
esphome:
name: test
esp8266:
board: d1_mini
output:
- platform: esp8266_pwm
id: default_frequency
pin: GPIO4
frequency: 1kHz
- platform: esp8266_pwm
id: custom_frequency
pin: GPIO5
frequency: 2kHz
- platform: esp8266_pwm
id: schema_default_frequency
pin: GPIO12
@@ -0,0 +1,16 @@
"""Tests for the esp8266_pwm output codegen."""
from collections.abc import Callable
from pathlib import Path
def test_default_frequency_is_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The 1 kHz default already lives in the C++ initializer."""
main_cpp = generate_main(component_config_path("frequency.yaml"))
assert "default_frequency->set_frequency(" not in main_cpp
assert "schema_default_frequency->set_frequency(" not in main_cpp
assert "custom_frequency->set_frequency(2000.0f);" in main_cpp
@@ -0,0 +1,29 @@
---
esphome:
name: test
esp32:
board: esp32dev
output:
- platform: ledc
id: out_a
pin: GPIO4
- platform: ledc
id: out_b
pin: GPIO5
light:
- platform: monochromatic
id: plain_light
output: out_a
flash_transition_length: 0s
- platform: monochromatic
id: fancy_light
output: out_b
flash_transition_length: 500ms
effects:
- pulse:
- platform: monochromatic
id: bare_light
output: out_a
@@ -0,0 +1,19 @@
"""Tests that light codegen skips setters for default values."""
from collections.abc import Callable
from pathlib import Path
def test_default_flash_length_and_empty_effects_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A 0 ms flash transition and an empty effect list match the C++ defaults."""
main_cpp = generate_main(component_config_path("transitions.yaml"))
assert "plain_light->set_flash_transition_length(" not in main_cpp
assert "plain_light->add_effects(" not in main_cpp
assert "bare_light->set_flash_transition_length(" not in main_cpp
assert "bare_light->add_effects(" not in main_cpp
assert "fancy_light->set_flash_transition_length(500);" in main_cpp
assert "fancy_light->add_effects({" in main_cpp
@@ -52,3 +52,35 @@ def test_logger_pre_setup_before_other_components(generate_main):
f"Component allocation '{alloc.group()}' at position {alloc.start()} "
f"appears before logger pre_setup() at position {logger_pre_setup.start()}"
)
def test_default_uart_selection_is_not_emitted(generate_main):
"""UART0 is the C++ initializer on ESP8266, so the setter is skipped."""
main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml")
assert "set_uart_selection(" not in main_cpp
def test_custom_uart_selection_is_emitted(generate_main):
"""A non default UART still reaches the setter before pre_setup()."""
main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml")
assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp
def test_libretiny_default_uart_selection_is_not_emitted(generate_main):
"""DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped."""
main_cpp = generate_main(
"tests/component_tests/logger/test_logger_libretiny_default.yaml"
)
assert "set_uart_selection(" not in main_cpp
def test_libretiny_uart0_is_emitted(generate_main):
"""UART0 is not the LibreTiny initializer, so it must still be set."""
main_cpp = generate_main(
"tests/component_tests/logger/test_logger_libretiny_uart0.yaml"
)
assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp
@@ -0,0 +1,8 @@
---
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
logger:
@@ -0,0 +1,9 @@
---
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
logger:
hardware_uart: UART0
@@ -0,0 +1,9 @@
---
esphome:
name: test
esp8266:
board: d1_mini_lite
logger:
hardware_uart: UART1
@@ -0,0 +1,28 @@
---
esphome:
name: test
esp32:
board: esp32dev
number:
- platform: template
id: auto_number
min_value: 0
max_value: 10
step: 1
optimistic: true
- platform: template
id: box_number
min_value: 0
max_value: 10
step: 1
mode: box
optimistic: true
- platform: template
id: explicit_auto_number
min_value: 0
max_value: 10
step: 1
mode: auto
optimistic: true
@@ -0,0 +1,16 @@
"""Tests for the number component codegen."""
from collections.abc import Callable
from pathlib import Path
def test_default_mode_is_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Mode auto is the C++ initializer, so only a non default mode is set."""
main_cpp = generate_main(component_config_path("mode.yaml"))
assert "auto_number->traits.set_mode(" not in main_cpp
assert "explicit_auto_number->traits.set_mode(" not in main_cpp
assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp

Some files were not shown because too many files have changed in this diff Show More