mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 18:22:48 +00:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06668c742d | ||
|
|
54b8e2e6dc | ||
|
|
651863323b | ||
|
|
1e627a31f1 | ||
|
|
e5eb577b49 | ||
|
|
c378ea1300 | ||
|
|
cba4f5bc05 | ||
|
|
8109aa96f6 | ||
|
|
4067f572cc | ||
|
|
1e22861d11 | ||
|
|
282be54d1e | ||
|
|
abadfbfd20 | ||
|
|
93b3dc1876 | ||
|
|
e7c9c5ad0b | ||
|
|
82b608706c | ||
|
|
02648d3547 | ||
|
|
41e34c19eb | ||
|
|
a0821c225a | ||
|
|
7fe0689fb8 | ||
|
|
93fa95c833 | ||
|
|
501009073d | ||
|
|
81a54ea9db | ||
|
|
e28b4eb2a0 | ||
|
|
ebe72c3cef | ||
|
|
977542061d | ||
|
|
b0b75f705a | ||
|
|
be2c3dda94 | ||
|
|
64fd87f436 | ||
|
|
cd5d4ff254 | ||
|
|
1a555d5848 | ||
|
|
b17cd89469 | ||
|
|
9064bfcc85 | ||
|
|
0803d7b37c | ||
|
|
9814966fe7 | ||
|
|
3eda3060b8 | ||
|
|
6cc2b9bf17 | ||
|
|
1975b17eac | ||
|
|
3b499ecb3e | ||
|
|
6bd6603d52 | ||
|
|
ad4ee1d34e | ||
|
|
ccec6e72bf | ||
|
|
91b1a82a66 | ||
|
|
7801cf4a8a | ||
|
|
e431bfcb38 | ||
|
|
f5afd141de | ||
|
|
e38ee343b4 | ||
|
|
cf398ea8b2 | ||
|
|
bc1841c1b5 | ||
|
|
ed5a570e17 | ||
|
|
16df92212e | ||
|
|
67871bcf55 | ||
|
|
c51020dbaf | ||
|
|
eb1ea4aefe | ||
|
|
b1bfc512ac | ||
|
|
ce8fad1435 | ||
|
|
4f0faac148 | ||
|
|
fc8611a212 | ||
|
|
6b08aa60e6 | ||
|
|
eea66fc32b | ||
|
|
5bbfe12e4f | ||
|
|
533002c41e | ||
|
|
ebb9037ea1 | ||
|
|
9a7ab80c43 | ||
|
|
575311f0e8 | ||
|
|
11b37e1861 | ||
|
|
752f36458b | ||
|
|
d98484c283 | ||
|
|
7f9636b7f2 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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_;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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_"
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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
Reference in New Issue
Block a user