mirror of
https://github.com/esphome/esphome.git
synced 2026-10-18 04:02:54 +00:00
Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b2b16e664 | ||
|
|
7a4ffa98f6 | ||
|
|
eca4c2d0a5 | ||
|
|
43b6b41d16 | ||
|
|
b6d70d4e34 | ||
|
|
4af2db7e8a | ||
|
|
92059abf6c | ||
|
|
e6d124d229 | ||
|
|
0575eb14f2 | ||
|
|
c732903390 | ||
|
|
eaee1cdd86 | ||
|
|
964a54f7a7 | ||
|
|
3e5797d153 | ||
|
|
7e99301644 | ||
|
|
08387ad289 | ||
|
|
c6e4c87e52 | ||
|
|
160bc29c7c | ||
|
|
b0cc913884 | ||
|
|
3f7725a684 | ||
|
|
0f500628dd | ||
|
|
fe0f04b2e4 | ||
|
|
45362dbc5b | ||
|
|
a1ad794d03 | ||
|
|
f8bda9fbad | ||
|
|
808b7210db | ||
|
|
81be397056 | ||
|
|
eae6af437b | ||
|
|
6582c618f1 | ||
|
|
8c999d3152 | ||
|
|
076dc017ab | ||
|
|
b7acd9c0dc | ||
|
|
20d199ae20 | ||
|
|
e7c9c5ad0b | ||
|
|
82b608706c | ||
|
|
7fe0689fb8 | ||
|
|
93fa95c833 | ||
|
|
501009073d | ||
|
|
81a54ea9db | ||
|
|
e28b4eb2a0 | ||
|
|
ebe72c3cef | ||
|
|
977542061d | ||
|
|
b0b75f705a | ||
|
|
be2c3dda94 | ||
|
|
64fd87f436 | ||
|
|
cd5d4ff254 | ||
|
|
1a555d5848 | ||
|
|
b17cd89469 | ||
|
|
9064bfcc85 | ||
|
|
0803d7b37c | ||
|
|
9814966fe7 | ||
|
|
3eda3060b8 | ||
|
|
6cc2b9bf17 | ||
|
|
1975b17eac | ||
|
|
3b499ecb3e | ||
|
|
6bd6603d52 | ||
|
|
ad4ee1d34e | ||
|
|
ccec6e72bf | ||
|
|
69f2a7d556 | ||
|
|
008677298a | ||
|
|
d2bc056f0a | ||
|
|
42fffd16fe | ||
|
|
9c16aba6f7 | ||
|
|
ac79173f4a | ||
|
|
0a1e2acbcb | ||
|
|
9b6facb20d | ||
|
|
f89b9e704c | ||
|
|
f8b2e53609 | ||
|
|
7660dd7fa7 | ||
|
|
c3ce07755f | ||
|
|
f8a4cfa945 | ||
|
|
866ddb6e57 | ||
|
|
ca864c22b4 | ||
|
|
c9729244af | ||
|
|
442e4a1ec2 | ||
|
|
199acdf522 | ||
|
|
9340863652 | ||
|
|
d5cff6e9df | ||
|
|
e7f45a0d31 | ||
|
|
628ebe23ec | ||
|
|
823d79c948 |
@@ -244,20 +244,11 @@ jobs:
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Read prek version from requirements_test.txt
|
||||
id: prek
|
||||
# requirements_test.txt is the only place the version is pinned, so a
|
||||
# Dependabot bump there is picked up here without a second edit.
|
||||
run: |
|
||||
if ! version=$(sed -nE 's/^prek==([^[:space:]#]+).*/\1/p' requirements_test.txt) || [ -z "$version" ]; then
|
||||
echo "::error::No prek== pin found in requirements_test.txt."
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
||||
with:
|
||||
prek-version: ${{ steps.prek.outputs.version }}
|
||||
# Keep in sync with requirements_test.txt.
|
||||
prek-version: "0.4.11"
|
||||
# This job only runs on pull requests, so nothing ever populates
|
||||
# the cache on dev. Every run would miss and then write a per-pull
|
||||
# request copy, which is what the old seed-cache job existed to
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# Keeps pre-commit hook revs in sync with the requirements files.
|
||||
#
|
||||
# Dependabot only bumps the pins in requirements*.txt. Some of those tools
|
||||
# are pinned again as hook revs in .pre-commit-config.yaml. This workflow
|
||||
# runs script/sync_dependency_versions.py against the pull request branch
|
||||
# and pushes a commit with the revs updated.
|
||||
|
||||
name: Sync dependency versions
|
||||
|
||||
on:
|
||||
# pull_request_target rather than pull_request so the App secret is
|
||||
# available on Dependabot pull requests (pull_request runs opened by
|
||||
# Dependabot only see Dependabot secrets). The job below only touches
|
||||
# branches in this repository and only ever executes the script from the
|
||||
# base branch checkout, so fork code never runs with the token.
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- requirements_dev.txt
|
||||
- requirements_test.txt
|
||||
- .pre-commit-config.yaml
|
||||
- script/sync_dependency_versions.py
|
||||
|
||||
# The push to the pull request branch uses the App token minted below, so
|
||||
# the workflow's GITHUB_TOKEN does not need any scopes.
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync pinned versions
|
||||
runs-on: ubuntu-latest
|
||||
# Same-repository branches only: a push to a fork is not possible with
|
||||
# this token, and it keeps untrusted heads out of a privileged job.
|
||||
if: >-
|
||||
github.repository == 'esphome/esphome'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
# A push made with the workflow's own GITHUB_TOKEN would not start
|
||||
# CI on the new commit; a push with the App token does.
|
||||
permission-contents: write # git push of the sync commit to the pull request branch
|
||||
|
||||
- name: Check out base branch
|
||||
# Provides the script that runs below. Deliberately the base branch
|
||||
# so the pull request cannot change what executes here.
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check out pull request branch
|
||||
# No allow-unsafe-pr-checkout here on purpose: checkout v7 only
|
||||
# refuses heads that live in a different repository, and the job
|
||||
# condition above already limits runs to same-repository branches.
|
||||
# Leaving it off keeps that refusal as a backstop for fork heads.
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
path: pull-request
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install yamlrocks
|
||||
# The script edits YAML through yamlrocks. Take the pin from the
|
||||
# base branch requirements so this workflow has no copy of its own.
|
||||
run: pip install "$(grep -E '^yamlrocks==' requirements_test.txt | cut -d'#' -f1)"
|
||||
|
||||
- name: Sync pinned versions
|
||||
run: python script/sync_dependency_versions.py --root pull-request
|
||||
|
||||
- name: Push changes
|
||||
working-directory: pull-request
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "All pinned versions already match the requirements files."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "esphome[bot]"
|
||||
git config user.email "115708604+esphome[bot]@users.noreply.github.com"
|
||||
git commit -am "Sync pinned tool versions with requirements files"
|
||||
git push
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
|
||||
ci:
|
||||
autoupdate_commit_msg: 'pre-commit: autoupdate'
|
||||
autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit
|
||||
@@ -10,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.6
|
||||
rev: v0.16.3
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
@@ -41,7 +42,7 @@ repos:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
- repo: https://github.com/adrienverge/yamllint.git
|
||||
rev: v1.38.0
|
||||
rev: v1.37.1
|
||||
hooks:
|
||||
- id: yamllint
|
||||
exclude: ^(\.clang-format|\.clang-tidy)$
|
||||
|
||||
@@ -553,7 +553,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
4. **Lint:** Run `prek` to ensure code is compliant.
|
||||
5. **Commit:** Commit your changes. There is no strict format for commit messages.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
7. **Comments:** When commenting on GitHub PRs or issues, don't tag contributors, especially bots. Avoid referring to list items (e.g. from reviews) with the form #nn - this will be interpreted by GitHub as a reference to issue or PR nn. Keep comments short and exclude irrelevant details, backstories, restatement of previous comments and anything that is already obvious to the reader.
|
||||
|
||||
* **Documentation Contributions:**
|
||||
* Documentation is hosted in the separate `esphome/esphome.io` repository.
|
||||
@@ -840,7 +839,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({...}),
|
||||
cv.Schema({ ... }),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.10.0-dev
|
||||
PROJECT_NUMBER = 2026.9.1
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+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.5
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.9
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -23,7 +23,9 @@ from esphome.util import safe_print
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
SubscribeLogsResponse, # pylint: disable=no-name-in-module
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -893,20 +893,6 @@ message NoiseEncryptionSetKeyResponse {
|
||||
bool success = 1;
|
||||
}
|
||||
|
||||
// Single-use session resume ticket, sent unsolicited by the device after a
|
||||
// Noise connection authenticates. A client presents it in the ClientHello of
|
||||
// its next connection to skip the curve25519 handshake; the device then
|
||||
// issues a fresh ticket on that connection. Never sent on plaintext
|
||||
// connections. Clients that do not understand it drop it silently.
|
||||
// Contents are secret; the device generator redacts this message from dump_to
|
||||
message NoiseResumeTicket {
|
||||
option (id) = 152;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_API_NOISE";
|
||||
|
||||
bytes ticket = 1; // session_id(8) || secret(32)
|
||||
}
|
||||
|
||||
// ==================== HOMEASSISTANT.SERVICE ====================
|
||||
message SubscribeHomeassistantServicesRequest {
|
||||
option (id) = 34;
|
||||
|
||||
@@ -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");
|
||||
@@ -1779,9 +1782,6 @@ void APIConnection::complete_authentication_() {
|
||||
this->send_time_request();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
this->send_resume_ticket_();
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
if (zwave_proxy::global_zwave_proxy != nullptr) {
|
||||
zwave_proxy::global_zwave_proxy->api_connection_authenticated(this);
|
||||
@@ -1789,27 +1789,6 @@ void APIConnection::complete_authentication_() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
void APIConnection::send_resume_ticket_() {
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
// Only encrypted transports get a ticket: on dual-mode builds a plaintext
|
||||
// connection has no frame footer
|
||||
if (this->helper_->frame_footer_size() == 0) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
noise::ResumeTicket ticket;
|
||||
if (!this->parent_->get_noise_ctx().resume_cache().issue(ticket)) {
|
||||
return;
|
||||
}
|
||||
NoiseResumeTicket msg;
|
||||
msg.set_ticket(reinterpret_cast<const uint8_t *>(&ticket), sizeof(ticket));
|
||||
// A dropped ticket is harmless: the client does a full handshake next time
|
||||
static_cast<void>(this->send_message(msg));
|
||||
noise_clean(&ticket, sizeof(ticket));
|
||||
}
|
||||
#endif
|
||||
|
||||
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Copy client name with truncation if needed (set_client_name handles truncation)
|
||||
this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size());
|
||||
|
||||
@@ -381,11 +381,6 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Helper function to handle authentication completion
|
||||
void complete_authentication_();
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
// Issue a fresh single-use session resume ticket over the encrypted channel
|
||||
void send_resume_ticket_();
|
||||
#endif
|
||||
|
||||
// Pattern B helpers: send response and return success/failure
|
||||
bool send_hello_response_(const HelloRequest &msg);
|
||||
bool send_disconnect_response_();
|
||||
|
||||
@@ -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;
|
||||
@@ -271,26 +271,24 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
if (aerr != APIError::OK) {
|
||||
return handle_handshake_frame_error_(aerr);
|
||||
}
|
||||
// Contents are extension flags (today: the resume offer); mixed into the
|
||||
// prologue either way. Resize for: existing prologue + 2 size bytes + frame data
|
||||
size_t old_size = this->prologue_.size();
|
||||
// ignore contents, may be used in future for flags
|
||||
// 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;
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// A verified resume offer (still in rx_buf_ from the client hello step)
|
||||
// replaces the whole handshake; any failure falls back to the full one.
|
||||
// send server hello
|
||||
const auto &name = App.get_name();
|
||||
char mac[MAC_ADDRESS_BUFFER_SIZE];
|
||||
@@ -304,9 +302,7 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
|
||||
// 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null)
|
||||
// + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null)
|
||||
// + optional resume accept extension
|
||||
constexpr size_t max_msg_size =
|
||||
1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE + noise::RESUME_ACCEPT_SIZE;
|
||||
constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE;
|
||||
uint8_t msg[max_msg_size];
|
||||
|
||||
// chosen proto
|
||||
@@ -317,32 +313,16 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// node mac, terminated by null byte
|
||||
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
// The accept extension, if any, is written straight after the mac
|
||||
size_t ext_len = this->ctx_.resume_cache().try_accept(
|
||||
this->rx_buf_.data(), this->rx_buf_.size(), this->prologue_.data(), this->prologue_.size(), msg + total_size,
|
||||
sizeof(msg) - total_size, send_cipher_, recv_cipher_);
|
||||
bool resume = ext_len != 0;
|
||||
total_size += ext_len;
|
||||
|
||||
APIError aerr = write_frame_(msg, total_size);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
if (resume) {
|
||||
// A resuming client waits for this hello instead of pipelining
|
||||
// handshake message 1, so the transport is ready now
|
||||
this->frame_footer_size_ = noise_cipherstate_get_mac_length(this->send_cipher_);
|
||||
HELPER_LOG("Session resumed!");
|
||||
state_ = State::DATA;
|
||||
} else {
|
||||
aerr = init_handshake_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
state_ = State::HANDSHAKE;
|
||||
}
|
||||
// init_handshake_ copied the prologue into the handshake state; the resume
|
||||
// path is done with it too
|
||||
this->prologue_.release();
|
||||
// start handshake
|
||||
aerr = init_handshake_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
state_ = State::HANDSHAKE;
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_handshake_() {
|
||||
@@ -572,6 +552,8 @@ APIError APINoiseFrameHelper::init_handshake_() {
|
||||
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
// init copies the prologue into the handshakestate, so we can get rid of it now
|
||||
prologue_.release();
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1061,16 +1061,6 @@ uint32_t NoiseEncryptionSetKeyResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_bool(1, this->success);
|
||||
return size;
|
||||
}
|
||||
uint8_t *NoiseResumeTicket::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->ticket_ptr_, this->ticket_len_);
|
||||
return pos;
|
||||
}
|
||||
uint32_t NoiseResumeTicket::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_length(1, this->ticket_len_);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
uint8_t *HomeassistantServiceMap::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
|
||||
@@ -1147,27 +1147,6 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
class NoiseResumeTicket final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 152;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 19;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("noise_resume_ticket"); }
|
||||
#endif
|
||||
const uint8_t *ticket_ptr_{nullptr};
|
||||
size_t ticket_len_{0};
|
||||
void set_ticket(const uint8_t *data, size_t len) {
|
||||
this->ticket_ptr_ = data;
|
||||
this->ticket_len_ = len;
|
||||
}
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
class HomeassistantServiceMap final : public ProtoMessage {
|
||||
|
||||
@@ -1393,10 +1393,6 @@ const char *NoiseEncryptionSetKeyResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("success"), this->success);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *NoiseResumeTicket::dump_to(DumpBuffer &out) const {
|
||||
out.append_p(ESPHOME_PSTR("NoiseResumeTicket {}"));
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
const char *HomeassistantServiceMap::dump_to(DumpBuffer &out) const {
|
||||
|
||||
@@ -29,6 +29,29 @@ static const char *const TAG = "api";
|
||||
// APIServer
|
||||
APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
static constexpr uint32_t NOISE_PSK_PREF_HASH = 88491486UL;
|
||||
#endif
|
||||
|
||||
#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED)
|
||||
bool load_saved_noise_psk(noise::psk_t &out) {
|
||||
SavedNoisePsk saved;
|
||||
#ifdef USE_PREFERENCE_KEY_LOOKUP
|
||||
const bool loaded =
|
||||
global_preferences->load_from_key(NOISE_PSK_PREF_HASH, reinterpret_cast<uint8_t *>(&saved), sizeof(saved));
|
||||
#else
|
||||
// Slot backends need the reservation walk; it only lands on the record when the reservations before
|
||||
// it match a normal boot, otherwise the type checked checksum fails the load
|
||||
const bool loaded = global_preferences->make_preference<SavedNoisePsk>(NOISE_PSK_PREF_HASH, true).load(&saved);
|
||||
#endif
|
||||
// The all-zeros record means no key
|
||||
if (!loaded || noise::NoiseContext::is_all_zeros(saved.psk))
|
||||
return false;
|
||||
out = saved.psk;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
APIServer::APIServer() { global_api_server = this; }
|
||||
|
||||
void APIServer::socket_failed_(const LogString *msg) {
|
||||
@@ -43,8 +66,7 @@ void APIServer::setup() {
|
||||
#ifdef USE_API_NOISE
|
||||
// Always reserve the slot: flash preferences are positional on esp8266, so
|
||||
// a yaml key build must keep the layout of a runtime key build
|
||||
uint32_t hash = 88491486UL;
|
||||
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true);
|
||||
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(NOISE_PSK_PREF_HASH, true);
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
// A cleared record loads fine but holds no key
|
||||
if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) {
|
||||
|
||||
@@ -43,6 +43,11 @@ struct SavedNoisePsk {
|
||||
noise::psk_t psk;
|
||||
} PACKED; // NOLINT
|
||||
#endif
|
||||
#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED)
|
||||
/// One-shot read of the provisioned key for a boot without an api server (safe mode); false when
|
||||
/// there is no key
|
||||
bool load_saved_noise_psk(noise::psk_t &out);
|
||||
#endif
|
||||
|
||||
class APIServer final : public Component,
|
||||
public Controller
|
||||
|
||||
@@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
|
||||
if (current_audio_file_ != nullptr) {
|
||||
// A transfer buffer isn't ncessary for a local file
|
||||
this->file_ring_buffer_ = output_ring_buffer.lock();
|
||||
if (this->file_ring_buffer_ == nullptr) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le
|
||||
|
||||
void AudioTransferBuffer::clear_buffered_data() {
|
||||
this->buffer_length_ = 0;
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
if (this->ring_buffer_ != nullptr) {
|
||||
this->ring_buffer_->reset();
|
||||
}
|
||||
}
|
||||
|
||||
void AudioSinkTransferBuffer::clear_buffered_data() {
|
||||
this->buffer_length_ = 0;
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
if (this->ring_buffer_ != nullptr) {
|
||||
this->ring_buffer_->reset();
|
||||
}
|
||||
#ifdef USE_SPEAKER
|
||||
@@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() {
|
||||
}
|
||||
|
||||
bool AudioTransferBuffer::has_buffered_data() const {
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
if (this->ring_buffer_ != nullptr) {
|
||||
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
||||
}
|
||||
return (this->available() > 0);
|
||||
@@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_
|
||||
size_t bytes_to_read = AudioTransferBuffer::free();
|
||||
size_t bytes_read = 0;
|
||||
if (bytes_to_read > 0) {
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
if (this->ring_buffer_ != nullptr) {
|
||||
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait,
|
||||
bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait);
|
||||
} else
|
||||
#endif
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
if (this->ring_buffer_ != nullptr) {
|
||||
bytes_written =
|
||||
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
|
||||
} else if (this->sink_callback_ != nullptr) {
|
||||
@@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const {
|
||||
return (this->speaker_->has_buffered_data() || (this->available() > 0));
|
||||
}
|
||||
#endif
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
if (this->ring_buffer_ != nullptr) {
|
||||
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
||||
}
|
||||
return (this->available() > 0);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -23,7 +23,7 @@ public ble_api.h.
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import libretiny
|
||||
from esphome.components import libretiny, wifi
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7231Q,
|
||||
@@ -84,6 +84,15 @@ def _final_validate(config: ConfigType) -> None:
|
||||
# which run on a BLE 4.2 board. The hard error is raised at codegen.
|
||||
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
|
||||
_LOGGER.warning("%s (this configuration cannot compile)", msg)
|
||||
# Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU
|
||||
# sleep. With the BLE controller running, that sleep never wakes up once the
|
||||
# station is stopped (adapter restart after failed roams, wifi.disable): the
|
||||
# device is dead until a power cycle (esphome#18592). Keep power save off
|
||||
# until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414).
|
||||
wifi.force_power_save_off(
|
||||
"with BLE running, the Beken SDK's MCU sleep halts the device once the "
|
||||
"station is stopped (https://github.com/esphome/esphome/issues/18592)"
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -23,7 +23,11 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import
|
||||
from . import ( # noqa: F401 pylint: disable=unused-import
|
||||
CONF_DEBUG_ID,
|
||||
FILTER_SOURCE_FILES,
|
||||
DebugComponent,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["debug"]
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import
|
||||
from . import ( # noqa: F401 pylint: disable=unused-import
|
||||
CONF_DEBUG_ID,
|
||||
FILTER_SOURCE_FILES,
|
||||
DebugComponent,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["debug"]
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
|
||||
"esp_lcd", # LCD controller drivers - only needed by display component
|
||||
"esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API
|
||||
"esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back
|
||||
"esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds
|
||||
"esp_wifi", # WiFi stack - re-included by request_wifi(), espnow, esp32_hosted; bt pulls it back for BLE builds
|
||||
"espcoredump", # Core dump support - ESPHome has its own debug component
|
||||
"fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage
|
||||
"ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back
|
||||
@@ -2628,6 +2628,11 @@ async def to_code(config):
|
||||
config.get(CONF_ENGINEERING_SAMPLE, False),
|
||||
)
|
||||
|
||||
# ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not
|
||||
# understand %zu or %lld and crashes on any %s that follows one.
|
||||
if variant == VARIANT_ESP32C2:
|
||||
add_idf_sdkconfig_option("CONFIG_LIBC_NEWLIB_NANO_FORMAT", False)
|
||||
|
||||
# Set minimum chip revision for ESP32 variant
|
||||
# Setting this to 3.0 or higher reduces flash size by excluding workaround code,
|
||||
# and for PSRAM users saves significant IRAM by keeping C library functions in ROM.
|
||||
|
||||
@@ -3,11 +3,18 @@ import esphome.codegen as cg
|
||||
# Re-exported for the many esp32-side users; defined in esphome.const
|
||||
# and esphome.espidf so the upload/logs fast path can use them without
|
||||
# importing this package.
|
||||
from esphome.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION, KEY_VARIANT # noqa: F401 # pylint: disable=unused-import
|
||||
from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
|
||||
KEY_ESP32,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_VARIANT,
|
||||
)
|
||||
|
||||
# Back compat for external components only; in-tree callers import it
|
||||
# from esphome.espidf directly.
|
||||
from esphome.espidf import variant_to_idf_target # noqa: F401 # pylint: disable=unused-import
|
||||
from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import
|
||||
variant_to_idf_target,
|
||||
)
|
||||
|
||||
KEY_BOARD = "board"
|
||||
KEY_SDKCONFIG_OPTIONS = "sdkconfig_options"
|
||||
|
||||
@@ -173,7 +173,10 @@ static const char *const TAG = "esp32.crash";
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static uint32_t s_current_build_time = static_cast<uint32_t>(ESPHOME_BUILD_TIME);
|
||||
|
||||
void crash_handler_read_and_clear() {
|
||||
// Validate the NOINIT record. Runs on every has_data() call; re-running is
|
||||
// harmless and the magic is left alone so the record survives an OTA
|
||||
// rollback reboot, crash_handler_clear() drops it once an API client has it.
|
||||
static void read_crash_data() {
|
||||
if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) {
|
||||
s_crash_data_valid = true;
|
||||
// Clamp counts to prevent out-of-bounds reads from corrupt .noinit data
|
||||
@@ -194,11 +197,12 @@ void crash_handler_read_and_clear() {
|
||||
s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count;
|
||||
#endif
|
||||
}
|
||||
// Don't clear magic here — crash data must survive OTA rollback reboots.
|
||||
// Magic is cleared by crash_handler_clear() after an API client receives the data.
|
||||
}
|
||||
|
||||
bool crash_handler_has_data() { return s_crash_data_valid; }
|
||||
bool crash_handler_has_data() {
|
||||
read_crash_data();
|
||||
return s_crash_data_valid;
|
||||
}
|
||||
|
||||
void crash_handler_clear() {
|
||||
// Only clear the magic so data doesn't survive the next reboot.
|
||||
@@ -426,7 +430,7 @@ static void log_foreign_addresses() {
|
||||
// crashes again during boot, and allowing the CLI's process_stacktrace to match
|
||||
// and decode each address individually.
|
||||
void crash_handler_log() {
|
||||
if (!s_crash_data_valid)
|
||||
if (!crash_handler_has_data())
|
||||
return;
|
||||
|
||||
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
|
||||
namespace esphome::esp32 {
|
||||
|
||||
/// Read and validate crash data from NOINIT memory.
|
||||
/// Does not clear the magic marker — call crash_handler_clear() after
|
||||
/// the data has been delivered to an API client so it survives OTA rollback reboots.
|
||||
void crash_handler_read_and_clear();
|
||||
|
||||
/// Log crash data if a crash was detected on previous boot.
|
||||
void crash_handler_log();
|
||||
|
||||
@@ -16,7 +11,7 @@ void crash_handler_log();
|
||||
/// Call after the data has been delivered to an API client.
|
||||
void crash_handler_clear();
|
||||
|
||||
/// Returns true if crash data was found this boot.
|
||||
/// Returns true if crash data was found this boot, reading it first if needed.
|
||||
bool crash_handler_has_data();
|
||||
|
||||
} // namespace esphome::esp32
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
#ifdef USE_ESP32
|
||||
|
||||
// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set
|
||||
// before crash_handler.h's #ifdef-guarded namespace block is parsed.
|
||||
#include "esphome/core/defines.h"
|
||||
#include "crash_handler.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
#include <esp_clk_tree.h>
|
||||
@@ -45,11 +42,6 @@ void arch_restart() {
|
||||
}
|
||||
|
||||
void arch_init() {
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
// Read crash data from previous boot before anything else
|
||||
esp32::crash_handler_read_and_clear();
|
||||
#endif
|
||||
|
||||
// Enable the task watchdog only on the loop task (from which we're currently running)
|
||||
esp_task_wdt_add(nullptr);
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -290,6 +290,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
# symbols are simply unused and never register a callback at runtime.
|
||||
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
|
||||
add_define("USE_ESP_NOW_HOSTED")
|
||||
# esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides
|
||||
esp32.include_builtin_idf_component("esp_wifi")
|
||||
# esp-hosted's CustomRpc ("peer data transfer") path — off by default.
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -166,9 +166,17 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
||||
CONF_PASSWORD,
|
||||
)
|
||||
# web_server and prometheus keep the shared listener up; the captive
|
||||
# portal's copy only exists on the fallback AP and is the recovery path
|
||||
# portal's copy only exists on the fallback AP and is the recovery path.
|
||||
# web_server `ota: false` gates /update behind the captive portal on
|
||||
# every listener
|
||||
web_server_conf = full_conf.get(CONF_WEB_SERVER)
|
||||
plaintext_update_reachable = (
|
||||
web_server_conf.get(CONF_OTA) is not False
|
||||
if web_server_conf is not None
|
||||
else "prometheus" in full_conf
|
||||
)
|
||||
if (
|
||||
(CONF_WEB_SERVER in full_conf or "prometheus" in full_conf)
|
||||
plaintext_update_reachable
|
||||
and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf)
|
||||
and any(
|
||||
CONF_ENCRYPTION in conf
|
||||
@@ -308,20 +316,16 @@ async def to_code(config: ConfigType) -> None:
|
||||
# One key per device: an api encryption block supplies it (static or
|
||||
# runtime) and offers; the ota block only adds the requirement
|
||||
api_conf = CORE.config.get(CONF_API) or {}
|
||||
encryption_conf = config.get(CONF_ENCRYPTION)
|
||||
own_key = None
|
||||
if encryption_conf is not None and static_encryption_key(api_conf) is None:
|
||||
own_key = encryption_conf[CONF_KEY]
|
||||
if own_key is not None:
|
||||
if key := static_encryption_key(config) or static_encryption_key(api_conf):
|
||||
# Build time key: the ota keeps its own pointer so safe mode, which
|
||||
# has no api server, still has it
|
||||
cg.add_define("USE_OTA_ENCRYPTION")
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key)))
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
|
||||
elif CONF_ENCRYPTION in api_conf:
|
||||
# Runtime key: found in the api server, or in preferences in safe mode
|
||||
cg.add_define("USE_OTA_ENCRYPTION")
|
||||
cg.add_define("USE_OTA_ENCRYPTION_FROM_API")
|
||||
if static_encryption_key(api_conf) is None:
|
||||
# The key arrives at runtime, so the offer has to look for it
|
||||
cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED")
|
||||
if encryption_conf is not None:
|
||||
cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED")
|
||||
if CONF_ENCRYPTION in config:
|
||||
cg.add_define("USE_OTA_ENCRYPTION_REQUIRED")
|
||||
|
||||
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#include "ota_esphome.h"
|
||||
#ifdef USE_OTA_ENCRYPTION_FROM_API
|
||||
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
#ifdef USE_OTA
|
||||
@@ -32,16 +32,21 @@ static const char *const TAG = "esphome.ota";
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
|
||||
#ifdef USE_OTA_ENCRYPTION_FROM_API
|
||||
return api::global_api_server->get_noise_ctx();
|
||||
#else
|
||||
return this->noise_ctx_;
|
||||
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
|
||||
// The api server holds the live key; safe mode never constructs it, and then
|
||||
// noise_ctx_ holds the saved key setup() found, if any
|
||||
if (api::global_api_server != nullptr)
|
||||
return api::global_api_server->get_noise_ctx();
|
||||
#endif
|
||||
return this->noise_ctx_;
|
||||
}
|
||||
#endif
|
||||
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
|
||||
// Milliseconds for data transfer. Covers the lwIP retransmit run seen in
|
||||
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
|
||||
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
|
||||
|
||||
// Single-instance pointer — multi-port configs are rejected in final_validate.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -55,6 +60,16 @@ extern "C" void esphome_wake_ota_component_any_context() {
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::setup() {
|
||||
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
|
||||
// Safe mode never constructs the api server, so read the key it saved
|
||||
noise::psk_t psk;
|
||||
if (api::global_api_server == nullptr && api::load_saved_noise_psk(psk)) {
|
||||
this->saved_psk_ = RAMAllocator<noise::psk_t>().make_unique(psk);
|
||||
if (this->saved_psk_ != nullptr) {
|
||||
this->noise_ctx_.set_psk(this->saved_psk_->data());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->server_ == nullptr) {
|
||||
this->server_failed_(LOG_STR("creation"));
|
||||
@@ -839,7 +854,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);
|
||||
|
||||
@@ -44,7 +44,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
}
|
||||
#endif // USE_OTA_PASSWORD
|
||||
|
||||
#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API)
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
/// psk points at 32 bytes that live in flash for the life of the program
|
||||
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||
#endif
|
||||
@@ -86,7 +86,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
bool writing{false}; // a produced handshake frame is still being flushed
|
||||
uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE];
|
||||
};
|
||||
// The api server's live context when the api has encryption, else our own
|
||||
// The api server's live context when it exists, otherwise our own (a build
|
||||
// time key, or the saved key loaded in safe mode)
|
||||
const noise::NoiseContext &noise_context_() const;
|
||||
bool noise_start_session_(uint8_t server_feature_flags);
|
||||
bool handle_noise_handshake_();
|
||||
@@ -145,13 +146,15 @@ 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_;
|
||||
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
|
||||
// Backs noise_ctx_ in safe mode, where no api server holds the saved key
|
||||
RAMUniquePtr<noise::psk_t> saved_psk_;
|
||||
#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,
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
#include <lwip/dns.h>
|
||||
#include <cinttypes>
|
||||
#include "esp_event.h"
|
||||
#ifdef USE_PSRAM
|
||||
#include <esp_psram.h>
|
||||
#endif
|
||||
|
||||
// IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry;
|
||||
// they are no longer included via esp_eth.h and need explicit includes.
|
||||
@@ -71,6 +74,32 @@ static const char *const TAG = "ethernet";
|
||||
// PHY register size for hex logging
|
||||
static constexpr size_t PHY_REG_SIZE = 2;
|
||||
|
||||
// Dual wifi + ethernet SPI builds: the one place internal RAM is short and lwip's other buffers are
|
||||
// already in PSRAM. Not with L2 TAP, whose filter lives in the glue's input path this replaces.
|
||||
#if defined(USE_PSRAM) && defined(USE_ETHERNET_SPI) && defined(USE_WIFI) && !defined(CONFIG_ESP_NETIF_L2_TAP)
|
||||
#define USE_ETHERNET_RX_PSRAM
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - reported by dump_config()
|
||||
static bool rx_psram_installed = false;
|
||||
|
||||
// ESP-IDF ethernet drivers malloc() every received frame in internal RAM, where it stays until lwIP
|
||||
// hands it to the application. Move it to PSRAM; if that fails the frame is passed on where it is.
|
||||
static esp_err_t eth_input_to_psram(esp_eth_handle_t handle, uint8_t *buffer, uint32_t length, void *priv) {
|
||||
auto *copy = static_cast<uint8_t *>(heap_caps_malloc(length, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
|
||||
if (copy != nullptr) {
|
||||
memcpy(copy, buffer, length);
|
||||
free(buffer); // NOLINT(cppcoreguidelines-no-malloc) - allocated by the driver with malloc()
|
||||
buffer = copy;
|
||||
} else {
|
||||
static bool warned = false; // once, this runs per frame in the driver's task
|
||||
if (!warned) {
|
||||
warned = true;
|
||||
ESP_LOGW(TAG, "PSRAM allocation failed, frame kept in internal RAM (reported once)");
|
||||
}
|
||||
}
|
||||
return esp_netif_receive(static_cast<esp_netif_t *>(priv), buffer, length, nullptr);
|
||||
}
|
||||
#endif
|
||||
|
||||
void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) {
|
||||
ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err));
|
||||
this->mark_failed();
|
||||
@@ -453,6 +482,16 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
/* attach Ethernet driver to TCP/IP stack */
|
||||
err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_));
|
||||
ESPHL_ERROR_CHECK(err, "ETH netif attach error");
|
||||
#ifdef USE_ETHERNET_RX_PSRAM
|
||||
// The glue frees every receive buffer with free(), so the replacement buffer must come from the heap
|
||||
if (esp_psram_is_initialized()) {
|
||||
err = esp_eth_update_input_path(this->eth_handle_, eth_input_to_psram, this->eth_netif_);
|
||||
rx_psram_installed = err == ESP_OK;
|
||||
if (!rx_psram_installed) {
|
||||
ESP_LOGW(TAG, "PSRAM RX path not installed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Register user defined event handers
|
||||
err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr);
|
||||
@@ -630,6 +669,13 @@ void EthernetComponent::dump_config() {
|
||||
this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_);
|
||||
#endif
|
||||
ESP_LOGCONFIG(TAG, " Type: %s", eth_type);
|
||||
#ifdef USE_ETHERNET_RX_PSRAM
|
||||
// Only known once the driver is up; with enable_on_boot: false that is after this dump
|
||||
if (this->ethernet_initialized_) {
|
||||
ESP_LOGCONFIG(TAG, " RX frames: %s",
|
||||
rx_psram_installed ? LOG_STR_LITERAL("PSRAM") : LOG_STR_LITERAL("internal RAM"));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
network::IPAddresses EthernetComponent::get_ip_addresses() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ from esphome.const import (
|
||||
CONF_TYPE,
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.core import HexInt
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.types import ConfigType
|
||||
@@ -76,16 +76,18 @@ def compute_local_image_path(value: str | ConfigType) -> Path:
|
||||
return external_files.compute_local_file_path(DOMAIN, url)
|
||||
|
||||
|
||||
def local_path(value: str | ConfigType) -> str:
|
||||
value = value[CONF_PATH] if isinstance(value, dict) else value
|
||||
return str(CORE.relative_config_path(value))
|
||||
def local_path(value: Path | ConfigType) -> Path:
|
||||
# cv.file_ has already resolved the path against the config dir.
|
||||
return value[CONF_PATH] if isinstance(value, dict) else value
|
||||
|
||||
|
||||
def download_file(url: str, path: Path) -> str:
|
||||
def download_file(url: str, path: Path) -> Path:
|
||||
# The shared NETWORK_TIMEOUT applies; a per-caller timeout would be
|
||||
# silently ignored on a per-run memo hit anyway (memos key by path).
|
||||
external_files.download_content(url, path)
|
||||
return str(path)
|
||||
# Keep the Path: config-hash normalizes Path values under the data dir,
|
||||
# which a str would dump verbatim and break the CLI/add-on comparison.
|
||||
return path
|
||||
|
||||
|
||||
def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]:
|
||||
@@ -93,13 +95,13 @@ def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]:
|
||||
return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg"
|
||||
|
||||
|
||||
def download_gh_svg(value: str | ConfigType, source: str) -> str:
|
||||
def download_gh_svg(value: str | ConfigType, source: str) -> Path:
|
||||
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
|
||||
url, path = _gh_svg_url_path(mdi_id, source)
|
||||
return download_file(url, path)
|
||||
|
||||
|
||||
def download_image(value: str | ConfigType) -> str:
|
||||
def download_image(value: str | ConfigType) -> Path:
|
||||
value = value[CONF_URL] if isinstance(value, dict) else value
|
||||
return download_file(value, compute_local_image_path(value))
|
||||
|
||||
@@ -147,7 +149,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None:
|
||||
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref)
|
||||
|
||||
|
||||
def validate_file_shorthand(value: Any) -> str:
|
||||
def validate_file_shorthand(value: Any) -> Path:
|
||||
value = cv.string_strict(value)
|
||||
if (remote := _parse_remote_shorthand(value)) is not None:
|
||||
return download_file(remote.url, remote.path)
|
||||
@@ -165,7 +167,7 @@ LOCAL_SCHEMA = cv.All(
|
||||
|
||||
|
||||
def mdi_schema(source: str) -> cv.All:
|
||||
def validate_mdi(value: ConfigType) -> str:
|
||||
def validate_mdi(value: ConfigType) -> Path:
|
||||
return download_gh_svg(value, source)
|
||||
|
||||
return cv.All(
|
||||
|
||||
@@ -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);
|
||||
@@ -118,21 +125,24 @@ void I2SAudioSpeakerBase::loop() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Still starting up or winding down from a previous run
|
||||
if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
|
||||
this->status_momentary_error("driver-failure", 1000);
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||
&this->speaker_task_handle_);
|
||||
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||
&this->speaker_task_handle_);
|
||||
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
||||
this->status_momentary_error("task-failure", 1000);
|
||||
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
||||
}
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
||||
this->status_momentary_error("task-failure", 1000);
|
||||
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
||||
}
|
||||
break;
|
||||
case speaker::STATE_RUNNING: // Intentional fallthrough
|
||||
@@ -218,8 +228,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t
|
||||
}
|
||||
|
||||
bool I2SAudioSpeakerBase::has_buffered_data() const {
|
||||
if (this->audio_ring_buffer_.use_count() > 0) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
return temp_ring_buffer->available() > 0;
|
||||
}
|
||||
return false;
|
||||
@@ -236,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);
|
||||
}
|
||||
|
||||
@@ -246,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};
|
||||
|
||||
@@ -31,6 +31,7 @@ from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_BUILD_FLAGS,
|
||||
CONF_ESPHOME,
|
||||
CONF_GROUP,
|
||||
CONF_ID,
|
||||
@@ -169,11 +170,17 @@ def generate_lv_conf_h():
|
||||
all_defines = set(
|
||||
df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES)
|
||||
)
|
||||
build_flags = (
|
||||
CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", [])
|
||||
esphome_config = CORE.config[CONF_ESPHOME]
|
||||
# User build flags come from esphome->build_flags and from the deprecated
|
||||
# esphome->platformio_options->build_flags (a string or a list).
|
||||
# Remove before 2026.12.0
|
||||
|
||||
pio_build_flags = esphome_config.get(CONF_PLATFORMIO_OPTIONS, {}).get(
|
||||
CONF_BUILD_FLAGS, []
|
||||
)
|
||||
if not isinstance(build_flags, list):
|
||||
build_flags = [build_flags]
|
||||
if not isinstance(pio_build_flags, list):
|
||||
pio_build_flags = [pio_build_flags]
|
||||
build_flags = [*esphome_config.get(CONF_BUILD_FLAGS, []), *pio_build_flags]
|
||||
# Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART',
|
||||
# or multiple defines in one string.
|
||||
define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -129,7 +129,7 @@ void MicroWakeWord::setup() {
|
||||
return;
|
||||
}
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (this->ring_buffer_.use_count() > 1) {
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
|
||||
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
|
||||
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
|
||||
@@ -446,9 +446,9 @@ void MicroWakeWord::loop() {
|
||||
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
|
||||
}
|
||||
|
||||
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
|
||||
// Retries on a subsequent loop if the task is still running on the other core
|
||||
if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) {
|
||||
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
|
||||
this->inference_task_.deallocate();
|
||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||
xQueueReset(this->detection_queue_);
|
||||
this->set_state_(State::STOPPED);
|
||||
|
||||
@@ -48,7 +48,7 @@ class MicrophoneSource final {
|
||||
template<typename F> void add_data_callback(F &&data_callback) {
|
||||
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
|
||||
if (this->enabled_ || this->passive_) {
|
||||
if (this->processed_samples_.use_count() == 0) {
|
||||
if (this->processed_samples_ == nullptr) {
|
||||
// Create vector if its unused
|
||||
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
|
||||
}
|
||||
|
||||
@@ -246,20 +246,18 @@ class MipiSpi : public display::Display,
|
||||
this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len);
|
||||
this->disable();
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) {
|
||||
// Toggle D/C only while holding the bus; on boards where D/C doubles as
|
||||
// another bus signal, driving it while another device owns the bus
|
||||
// corrupts that device's transfer.
|
||||
this->enable();
|
||||
this->dc_pin_->digital_write(false);
|
||||
this->write_cmd_addr_data(0, 0, 0, 0, &cmd, 1, 8);
|
||||
this->dc_pin_->digital_write(true);
|
||||
this->disable();
|
||||
// hold the bus between command and data to avoid a glitch on the D/C line
|
||||
if (len != 0) {
|
||||
this->enable();
|
||||
this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8);
|
||||
this->disable();
|
||||
}
|
||||
this->disable();
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) {
|
||||
// Toggle D/C only while holding the bus; works around a quirk in the CoreS3 and W5500 ethernet combination.
|
||||
// See https://github.com/esphome/esphome/pull/18529
|
||||
this->enable();
|
||||
this->dc_pin_->digital_write(false);
|
||||
this->write_byte(cmd);
|
||||
@@ -271,12 +269,14 @@ class MipiSpi : public display::Display,
|
||||
this->disable();
|
||||
}
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) {
|
||||
this->enable();
|
||||
// DC must be stable before CS as the clock is gated by CS
|
||||
this->dc_pin_->digital_write(false);
|
||||
this->enable();
|
||||
this->write_byte(cmd);
|
||||
this->dc_pin_->digital_write(true);
|
||||
this->disable();
|
||||
this->dc_pin_->digital_write(true);
|
||||
for (size_t i = 0; i != len; i++) {
|
||||
// must enable and disable for each byte based on empirical testing
|
||||
this->enable();
|
||||
this->write_byte(0);
|
||||
this->write_byte(bytes[i]);
|
||||
|
||||
@@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_
|
||||
}
|
||||
size_t bytes_written = 0;
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (temp_ring_buffer.use_count() > 0) {
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
// Only write to the ring buffer if the reference is valid
|
||||
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
||||
if (bytes_written > 0) {
|
||||
@@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() {
|
||||
// avoids unnecessary single-frame splices.
|
||||
const size_t ring_buffer_size =
|
||||
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
|
||||
if (this->audio_source_.use_count() == 0) {
|
||||
if (this->audio_source_ == nullptr) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (!temp_ring_buffer) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||
this->ring_buffer_ = temp_ring_buffer;
|
||||
}
|
||||
|
||||
if (!temp_ring_buffer) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
|
||||
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
|
||||
|
||||
bool SourceSpeaker::has_buffered_data() const {
|
||||
return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
|
||||
return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data());
|
||||
}
|
||||
|
||||
void SourceSpeaker::set_mute_state(bool mute_state) {
|
||||
@@ -382,10 +382,11 @@ void MixerSpeaker::loop() {
|
||||
ESP_LOGV(TAG, "Stopping");
|
||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
|
||||
}
|
||||
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
|
||||
this->task_.deallocate();
|
||||
// Retries on a subsequent loop if the task is still running on the other core
|
||||
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
|
||||
ESP_LOGD(TAG, "Stopped");
|
||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
|
||||
// Keep a start request that arrived while the task was stopping, otherwise it is lost for good
|
||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START);
|
||||
this->all_stopped_since_ms_ = 0;
|
||||
}
|
||||
|
||||
@@ -496,7 +497,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
if (speaker->is_running() && !speaker->get_pause_state()) {
|
||||
// Speaker is running and not paused, so it possibly can provide audio data
|
||||
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
|
||||
if (audio_source.use_count() == 0) {
|
||||
if (audio_source == nullptr) {
|
||||
// No audio source allocated, so skip processing this speaker
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
@@ -48,6 +49,8 @@ ModbusServerDevice = modbus_ns.class_("ModbusServerDevice")
|
||||
CommandOptions = modbus_ns.struct("CommandOptions")
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read"
|
||||
CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response"
|
||||
CONF_ROLE = "role"
|
||||
CONF_MODBUS_ID = "modbus_id"
|
||||
CONF_SEND_WAIT_TIME = "send_wait_time"
|
||||
@@ -56,6 +59,28 @@ CONF_TURNAROUND_TIME = "turnaround_time"
|
||||
MODBUS_ROLES = ["client", "server"]
|
||||
|
||||
|
||||
# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17
|
||||
# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half.
|
||||
_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
|
||||
|
||||
# Codes the hub refuses at address 0; keep in sync with modbus::helpers::is_function_code_broadcastable().
|
||||
_NON_BROADCASTABLE_FUNCTION_CODES = frozenset(
|
||||
{0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18}
|
||||
)
|
||||
|
||||
|
||||
def is_function_code_write(function_code: int) -> bool:
|
||||
"""True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first,
|
||||
so an exception-flagged code still classifies by its base code (the runtime hub never queues one:
|
||||
queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write()."""
|
||||
return function_code & 0x7F in _WRITE_FUNCTION_CODES
|
||||
|
||||
|
||||
def is_function_code_broadcastable(function_code: int) -> bool:
|
||||
"""True if the hub accepts the function code at address 0 without allow_broadcast_read."""
|
||||
return function_code & 0x7F not in _NON_BROADCASTABLE_FUNCTION_CODES
|
||||
|
||||
|
||||
class _CommandOption(NamedTuple):
|
||||
"""One per-command option forwarded to the hub (modbus::CommandOptions)."""
|
||||
|
||||
@@ -64,14 +89,47 @@ class _CommandOption(NamedTuple):
|
||||
validator: Any # the static (non-templatable) validator for the key
|
||||
cpp_type: Any # the C++ type the value is generated as
|
||||
default: Any
|
||||
# Function codes the hub honours the option on; it is stripped from any other.
|
||||
applies_to: Callable[[int], bool]
|
||||
requires_broadcast_address: bool = False
|
||||
|
||||
|
||||
# Per-direction command options. Single-sourcing the schema and the setter generation here keeps
|
||||
# them from drifting; the C++ side must add the matching field per the rules documented on
|
||||
# CommandOptions (modbus.h).
|
||||
def _not_write(function_code: int) -> bool:
|
||||
return not is_function_code_write(function_code)
|
||||
|
||||
|
||||
def _not_broadcastable(function_code: int) -> bool:
|
||||
return not is_function_code_broadcastable(function_code)
|
||||
|
||||
|
||||
# Per-direction command options, single-sourced so the schema, setters and applicability rule cannot
|
||||
# drift; the C++ side adds the matching field per the rules on CommandOptions (modbus.h).
|
||||
_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = {
|
||||
"read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)],
|
||||
"write": [],
|
||||
"read": [
|
||||
_CommandOption(
|
||||
CONF_CONTINUOUS, "continuous", cv.boolean, bool, False, _not_write
|
||||
),
|
||||
_CommandOption(
|
||||
CONF_ALLOW_BROADCAST_READ,
|
||||
"allow_broadcast_read",
|
||||
cv.boolean,
|
||||
bool,
|
||||
False,
|
||||
_not_broadcastable,
|
||||
requires_broadcast_address=True,
|
||||
),
|
||||
],
|
||||
"write": [
|
||||
_CommandOption(
|
||||
CONF_EXPECT_BROADCAST_WRITE_RESPONSE,
|
||||
"expect_broadcast_write_response",
|
||||
cv.boolean,
|
||||
bool,
|
||||
False,
|
||||
is_function_code_broadcastable,
|
||||
requires_broadcast_address=True,
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -82,32 +140,75 @@ def _command_options(direction: str) -> list[_CommandOption]:
|
||||
raise ValueError(f"unknown command-options direction {direction!r}") from None
|
||||
|
||||
|
||||
# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17
|
||||
# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half.
|
||||
_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
|
||||
def broadcast_only_option_keys() -> list[str]:
|
||||
return [
|
||||
option.conf_key
|
||||
for options in _COMMAND_OPTIONS.values()
|
||||
for option in options
|
||||
if option.requires_broadcast_address
|
||||
]
|
||||
|
||||
|
||||
def is_function_code_write(function_code: int) -> bool:
|
||||
"""True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first,
|
||||
so an exception-flagged code still classifies by its base code (the runtime hub never queues one:
|
||||
queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write()."""
|
||||
return function_code & 0x7F in _WRITE_FUNCTION_CODES
|
||||
def reject_broadcast_options_for_unicast(
|
||||
address_key: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Reject a broadcast-only option set true on a literal address other than 0."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
address = config.get(address_key)
|
||||
if not isinstance(address, int) or address == BROADCAST_ADDRESS:
|
||||
return config
|
||||
for key in broadcast_only_option_keys():
|
||||
if config.get(key) is True:
|
||||
raise cv.Invalid(
|
||||
f"'{key}' only applies to the broadcast address; set '{address_key}: 0' or "
|
||||
f"remove the option.",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def reject_inapplicable_command_options(
|
||||
pdu_key: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Reject an option set true that the hub would strip from a literal PDU's function code."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
pdu = config[pdu_key]
|
||||
if not isinstance(pdu, list):
|
||||
return config
|
||||
for direction in _COMMAND_OPTIONS:
|
||||
for option in _command_options(direction):
|
||||
if config.get(option.conf_key) is True and not option.applies_to(
|
||||
pdu[0]
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{option.conf_key}: true' does not apply to function code "
|
||||
f"0x{pdu[0]:02X}",
|
||||
path=[option.conf_key],
|
||||
)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def command_options_schema(
|
||||
*, direction: Literal["read", "write"], templatable: bool = False
|
||||
*,
|
||||
direction: Literal["read", "write"],
|
||||
templatable: bool = False,
|
||||
function_code: int | None = None,
|
||||
) -> dict[cv.Optional, Any]:
|
||||
"""Schema fragment for the per-command options a component forwards to the hub
|
||||
(modbus::CommandOptions). Extend this into any schema that queues commands. Keys are
|
||||
direction-specific so a schema never offers an option the hub would strip (e.g.
|
||||
continuous on a write); the write side has no options yet. For actions (templatable=True the
|
||||
keys also accept lambdas), register the values with register_templatable_command_options().
|
||||
"""Schema fragment for the per-command options of one direction; `function_code` (a typed
|
||||
action's fixed code) leaves out the options that do not apply to it.
|
||||
"""
|
||||
return {
|
||||
cv.Optional(option.conf_key, default=option.default): (
|
||||
cv.templatable(option.validator) if templatable else option.validator
|
||||
)
|
||||
for option in _command_options(direction)
|
||||
if function_code is None or option.applies_to(function_code)
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +231,25 @@ def command_options_expression(
|
||||
)
|
||||
|
||||
|
||||
def add_command_options(
|
||||
var: MockObj,
|
||||
setter: str,
|
||||
config: ConfigType,
|
||||
*,
|
||||
direction: Literal["read", "write"],
|
||||
) -> None:
|
||||
"""Emit `var.<setter>(<options>)` for a config validated with command_options_schema() of the
|
||||
same direction, skipped when every option is at its C++ default."""
|
||||
if all(
|
||||
config.get(option.conf_key, option.default) == option.default
|
||||
for option in _command_options(direction)
|
||||
):
|
||||
return
|
||||
cg.add(
|
||||
getattr(var, setter)(command_options_expression(config, direction=direction))
|
||||
)
|
||||
|
||||
|
||||
async def register_templatable_command_options(
|
||||
var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str
|
||||
) -> None:
|
||||
|
||||
@@ -832,7 +832,7 @@ void ModbusClientHub::send_next_frame_() {
|
||||
}
|
||||
|
||||
cmd->sent();
|
||||
if (cmd->frame.address() == BROADCAST_ADDRESS) {
|
||||
if (cmd->fire_and_forget()) {
|
||||
// A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above
|
||||
// reports the transmission, and the entry then retires with no terminal callback instead of
|
||||
// occupying the waiting slot until the send-wait timeout expires. The turnaround delay already
|
||||
@@ -1074,11 +1074,6 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) {
|
||||
ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize the caller's options in place (the param is a by-value copy) so everything stored or
|
||||
// merged below carries effective options, never the raw request.
|
||||
// continuous is ignored for every mutating code (re-writing a value forever is never intended).
|
||||
@@ -1086,6 +1081,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
|
||||
options.continuous = false;
|
||||
}
|
||||
if (address != BROADCAST_ADDRESS) {
|
||||
options.allow_broadcast_read = false;
|
||||
options.expect_broadcast_write_response = false;
|
||||
} else {
|
||||
const bool broadcastable = helpers::is_function_code_broadcastable(pdu[0]);
|
||||
if (options.allow_broadcast_read && broadcastable) {
|
||||
ESP_LOGV(TAG, "allow_broadcast_read is ignored for function 0x%X: it is broadcastable", pdu[0]);
|
||||
options.allow_broadcast_read = false;
|
||||
}
|
||||
if (options.expect_broadcast_write_response && !broadcastable) {
|
||||
ESP_LOGV(TAG, "expect_broadcast_write_response is ignored for function 0x%X: it is not broadcastable", pdu[0]);
|
||||
options.expect_broadcast_write_response = false;
|
||||
}
|
||||
if (!broadcastable && !options.allow_broadcast_read) {
|
||||
ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A duplicate of a live entry with the same owner is not queued twice; it resolves against that
|
||||
// entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a
|
||||
@@ -1126,6 +1139,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address,
|
||||
item.pending);
|
||||
}
|
||||
item.options.expect_broadcast_write_response |= options.expect_broadcast_write_response;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,11 +111,15 @@ enum class FrameState : uint8_t {
|
||||
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
|
||||
// A new field reaches the queue with no plumbing but arrives inert until it defines three rules:
|
||||
// normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in
|
||||
// retire()/silent_retire().
|
||||
// retire()/silent_retire(). Bit-packed: stored per entry, controller and writer entity, passed by value.
|
||||
struct CommandOptions {
|
||||
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
|
||||
bool continuous{false};
|
||||
bool continuous : 1 {false};
|
||||
// Wait for the reply to a read sent to address 0, for a device that answers the broadcast address.
|
||||
bool allow_broadcast_read : 1 {false};
|
||||
bool expect_broadcast_write_response : 1 {false};
|
||||
};
|
||||
static_assert(sizeof(CommandOptions) == 1, "CommandOptions must stay one byte");
|
||||
|
||||
struct ModbusDeviceCommand {
|
||||
ModbusClientDevice *device;
|
||||
@@ -158,6 +162,10 @@ struct ModbusDeviceCommand {
|
||||
this->pending = 0;
|
||||
this->device = nullptr;
|
||||
}
|
||||
bool fire_and_forget() const {
|
||||
return this->frame.address() == BROADCAST_ADDRESS && !this->options.allow_broadcast_read &&
|
||||
!this->options.expect_broadcast_write_response;
|
||||
}
|
||||
// Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already
|
||||
// fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback.
|
||||
void complete_broadcast() {
|
||||
@@ -191,7 +199,8 @@ struct ModbusDeviceCommand {
|
||||
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
|
||||
this->state = FrameState::RETIRED;
|
||||
}
|
||||
this->options = {}; // reset every option
|
||||
// Only continuous ends with the clear; the delivery flags must survive for a granted retry.
|
||||
this->options.continuous = false;
|
||||
}
|
||||
|
||||
// True while the entry is still waiting for a response
|
||||
@@ -534,27 +543,27 @@ class ModbusClientDevice {
|
||||
return this->queue_pdu(
|
||||
helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options);
|
||||
}
|
||||
bool write_single_register(uint16_t start_address, uint16_t value) {
|
||||
return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value));
|
||||
bool write_single_register(uint16_t start_address, uint16_t value, CommandOptions options = {}) {
|
||||
return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value), options);
|
||||
}
|
||||
bool write_single_coil(uint16_t address, bool value) {
|
||||
return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value));
|
||||
bool write_single_coil(uint16_t address, bool value, CommandOptions options = {}) {
|
||||
return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value), options);
|
||||
}
|
||||
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) {
|
||||
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values, CommandOptions options = {}) {
|
||||
// Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's.
|
||||
if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS)
|
||||
return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values));
|
||||
return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values));
|
||||
return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values), options);
|
||||
return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values), options);
|
||||
}
|
||||
/// Note: std::vector<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed
|
||||
/// overload.
|
||||
bool write_multiple_coils(uint16_t start_address, std::span<const bool> values) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values));
|
||||
bool write_multiple_coils(uint16_t start_address, std::span<const bool> values, CommandOptions options = {}) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values), options);
|
||||
}
|
||||
/// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so
|
||||
/// read-modify-write needs no unpack/repack.
|
||||
bool write_multiple_coils(uint16_t start_address, PackedBits bits) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits));
|
||||
bool write_multiple_coils(uint16_t start_address, PackedBits bits, CommandOptions options = {}) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits), options);
|
||||
}
|
||||
/// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception
|
||||
/// (typically a rejected write half) arrives there too via its status - one callback handles both
|
||||
|
||||
@@ -7,7 +7,6 @@ from esphome.components import modbus
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_COUNT,
|
||||
CONF_ID,
|
||||
CONF_ON_ERROR,
|
||||
@@ -158,24 +157,6 @@ _ACTION_BASE_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def _no_continuous_on_write(config: ConfigType) -> ConfigType:
|
||||
"""Reject `continuous: true` on a static write PDU: continuous polling only applies to reads.
|
||||
Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at
|
||||
runtime, so a templated pdu or continuous falls through to that backstop."""
|
||||
pdu = config[CONF_PDU]
|
||||
if (
|
||||
isinstance(pdu, list)
|
||||
and config.get(CONF_CONTINUOUS) is True
|
||||
and modbus.is_function_code_write(pdu[0])
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code "
|
||||
f"0x{pdu[0]:02X}); continuous polling only applies to reads",
|
||||
path=[CONF_CONTINUOUS],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
MODBUS_CLIENT_SEND_SCHEMA = cv.All(
|
||||
_ACTION_BASE_SCHEMA.extend(
|
||||
{
|
||||
@@ -186,10 +167,12 @@ MODBUS_CLIENT_SEND_SCHEMA = cv.All(
|
||||
)
|
||||
),
|
||||
**modbus.command_options_schema(direction="read", templatable=True),
|
||||
**modbus.command_options_schema(direction="write", templatable=True),
|
||||
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
|
||||
}
|
||||
),
|
||||
_no_continuous_on_write,
|
||||
modbus.reject_inapplicable_command_options(CONF_PDU),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
@@ -261,8 +244,7 @@ async def register_client_action(
|
||||
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
|
||||
)
|
||||
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
|
||||
# matching direction so a write action never generates a read option's setter; the write side
|
||||
# has no options yet, so this is a no-op there.
|
||||
# matching direction so a write action never generates a read option's setter.
|
||||
await modbus.register_templatable_command_options(
|
||||
var, config, args, command_direction
|
||||
)
|
||||
@@ -279,6 +261,8 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args):
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER)
|
||||
cg.add(var.set_pdu(template_))
|
||||
# The read set is wired by register_client_action() below.
|
||||
await modbus.register_templatable_command_options(var, config, args, "write")
|
||||
return await register_client_action(
|
||||
var,
|
||||
config,
|
||||
@@ -353,6 +337,7 @@ def _read_schema(max_count: int) -> cv.All:
|
||||
}
|
||||
),
|
||||
_no_address_overflow(CONF_COUNT),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
@@ -364,21 +349,35 @@ def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.Al
|
||||
cv.Required(CONF_VALUES): cv.templatable(
|
||||
cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values))
|
||||
),
|
||||
**modbus.command_options_schema(direction="write", templatable=True),
|
||||
}
|
||||
),
|
||||
_no_address_overflow(CONF_VALUES),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
_READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ)
|
||||
|
||||
_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend(
|
||||
{cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)}
|
||||
_WRITE_SINGLE_REGISTER_SCHEMA = cv.All(
|
||||
_TYPED_ACTION_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t),
|
||||
**modbus.command_options_schema(direction="write", templatable=True),
|
||||
}
|
||||
),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
# A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00.
|
||||
_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend(
|
||||
{cv.Required(CONF_VALUE): cv.templatable(cv.boolean)}
|
||||
_WRITE_SINGLE_COIL_SCHEMA = cv.All(
|
||||
_TYPED_ACTION_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.boolean),
|
||||
**modbus.command_options_schema(direction="write", templatable=True),
|
||||
}
|
||||
),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
@@ -542,10 +541,15 @@ _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All(
|
||||
cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW),
|
||||
)
|
||||
),
|
||||
# 0x17 counts as a read at address 0, so it takes allow_broadcast_read only.
|
||||
**modbus.command_options_schema(
|
||||
direction="read", templatable=True, function_code=0x17
|
||||
),
|
||||
}
|
||||
),
|
||||
_no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS),
|
||||
_no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -85,18 +85,36 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
|
||||
/// builds its static struct; declaring the values here instead of per action means a new read option
|
||||
/// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up.
|
||||
/// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python
|
||||
/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option
|
||||
/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members.
|
||||
/// (command_options_schema(direction="read") adds exactly these keys); WriteCommandOptions is the twin.
|
||||
template<typename... Ts> class ReadCommandOptions {
|
||||
public:
|
||||
// Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips
|
||||
// it for mutating function codes at the door (see modbus::CommandOptions).
|
||||
TEMPLATABLE_VALUE(bool, continuous)
|
||||
TEMPLATABLE_VALUE(bool, allow_broadcast_read)
|
||||
|
||||
protected:
|
||||
/// The options for this send, with every templatable value resolved against the action's arguments.
|
||||
modbus::CommandOptions command_options_(const Ts &...x) const {
|
||||
return {.continuous = this->continuous_.value(x...)};
|
||||
return {.continuous = this->continuous_.value(x...),
|
||||
.allow_broadcast_read = this->allow_broadcast_read_.value(x...)};
|
||||
}
|
||||
};
|
||||
|
||||
/// The write-side per-command options (command_options_schema(direction="write") adds exactly these keys).
|
||||
template<typename... Ts> class WriteCommandOptions {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(bool, expect_broadcast_write_response)
|
||||
|
||||
protected:
|
||||
/// Resolves every write option into `options`, so send's merge of both sets stays exhaustive.
|
||||
void apply_write_command_options_(modbus::CommandOptions &options, const Ts &...x) const {
|
||||
options.expect_broadcast_write_response = this->expect_broadcast_write_response_.value(x...);
|
||||
}
|
||||
modbus::CommandOptions write_command_options_(const Ts &...x) const {
|
||||
modbus::CommandOptions options{};
|
||||
this->apply_write_command_options_(options, x...);
|
||||
return options;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,8 +125,11 @@ template<typename... Ts> class ReadCommandOptions {
|
||||
/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert).
|
||||
/// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check
|
||||
/// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated.
|
||||
/// A raw PDU may be a read or a write, so this action carries both option sets.
|
||||
template<typename... Ts>
|
||||
class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
|
||||
class ModbusClientSendAction : public ClientActionBase<Ts...>,
|
||||
public ReadCommandOptions<Ts...>,
|
||||
public WriteCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
|
||||
|
||||
@@ -116,7 +137,11 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadComman
|
||||
return &this->response_trigger_;
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); }
|
||||
void play(const Ts &...x) override {
|
||||
modbus::CommandOptions options = this->command_options_(x...);
|
||||
this->apply_write_command_options_(options, x...);
|
||||
this->send_or_resolve_(this->pdu_.value(x...), options);
|
||||
}
|
||||
|
||||
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
|
||||
this->response_trigger_.trigger(request_pdu, response_pdu);
|
||||
@@ -218,7 +243,8 @@ template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts.
|
||||
|
||||
/// modbus_client.write_single_register: on_response is the acknowledgement (the ack only echoes the
|
||||
/// request, so it carries no arguments).
|
||||
template<typename... Ts> class WriteSingleRegisterAction : public TypedClientActionBase<Ts...> {
|
||||
template<typename... Ts>
|
||||
class WriteSingleRegisterAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
TEMPLATABLE_VALUE(uint16_t, value)
|
||||
@@ -227,7 +253,8 @@ template<typename... Ts> class WriteSingleRegisterAction : public TypedClientAct
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
this->send_or_resolve_(
|
||||
modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)));
|
||||
modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)),
|
||||
this->write_command_options_(x...));
|
||||
}
|
||||
void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override {
|
||||
if (modbus::succeeded(status))
|
||||
@@ -240,7 +267,8 @@ template<typename... Ts> class WriteSingleRegisterAction : public TypedClientAct
|
||||
|
||||
/// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one
|
||||
/// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00.
|
||||
template<typename... Ts> class WriteSingleCoilAction : public TypedClientActionBase<Ts...> {
|
||||
template<typename... Ts>
|
||||
class WriteSingleCoilAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
TEMPLATABLE_VALUE(bool, value)
|
||||
@@ -249,7 +277,8 @@ template<typename... Ts> class WriteSingleCoilAction : public TypedClientActionB
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
this->send_or_resolve_(
|
||||
modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)));
|
||||
modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)),
|
||||
this->write_command_options_(x...));
|
||||
}
|
||||
void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override {
|
||||
if (modbus::succeeded(status))
|
||||
@@ -264,7 +293,8 @@ template<typename... Ts> class WriteSingleCoilAction : public TypedClientActionB
|
||||
/// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a
|
||||
/// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static
|
||||
/// list must not allocate on every play().
|
||||
template<typename... Ts> class WriteMultipleRegistersAction : public TypedClientActionBase<Ts...> {
|
||||
template<typename... Ts>
|
||||
class WriteMultipleRegistersAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
|
||||
@@ -288,11 +318,13 @@ template<typename... Ts> class WriteMultipleRegistersAction : public TypedClient
|
||||
// the empty PDU then resolves via on_not_sent like any refused send.
|
||||
if (this->len_ >= 0) {
|
||||
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(
|
||||
start, std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))));
|
||||
start, std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))),
|
||||
this->write_command_options_(x...));
|
||||
return;
|
||||
}
|
||||
const std::vector<uint16_t> values = this->values_.func(x...);
|
||||
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span<const uint16_t>(values)));
|
||||
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span<const uint16_t>(values)),
|
||||
this->write_command_options_(x...));
|
||||
}
|
||||
void on_write_multiple_registers(uint16_t start_address, std::span<const uint16_t> registers,
|
||||
modbus::ResponseStatus status) override {
|
||||
@@ -313,7 +345,8 @@ template<typename... Ts> class WriteMultipleRegistersAction : public TypedClient
|
||||
/// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play()
|
||||
/// neither allocates nor packs. A lambda returns std::vector<bool> - already a bit per coil rather than
|
||||
/// a byte - and is packed into a stack buffer on the way to the builder.
|
||||
template<typename... Ts> class WriteMultipleCoilsAction : public TypedClientActionBase<Ts...> {
|
||||
template<typename... Ts>
|
||||
class WriteMultipleCoilsAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
|
||||
@@ -334,13 +367,16 @@ template<typename... Ts> class WriteMultipleCoilsAction : public TypedClientActi
|
||||
const uint16_t start = this->start_address_.value(x...);
|
||||
if (this->count_ >= 0) {
|
||||
const auto count = static_cast<uint16_t>(this->count_);
|
||||
this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(
|
||||
start,
|
||||
modbus::PackedBits(std::span<const uint8_t>(this->values_.packed, modbus::packed_bit_bytes(count)), count)));
|
||||
this->send_or_resolve_(
|
||||
modbus::helpers::create_write_coils_pdu(
|
||||
start, modbus::PackedBits(std::span<const uint8_t>(this->values_.packed, modbus::packed_bit_bytes(count)),
|
||||
count)),
|
||||
this->write_command_options_(x...));
|
||||
return;
|
||||
}
|
||||
// The builder packs and bound-checks; an over-long set is rejected and logged there.
|
||||
this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)));
|
||||
this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)),
|
||||
this->write_command_options_(x...));
|
||||
}
|
||||
void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits,
|
||||
modbus::ResponseStatus status) override {
|
||||
@@ -359,7 +395,8 @@ template<typename... Ts> class WriteMultipleCoilsAction : public TypedClientActi
|
||||
|
||||
/// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in
|
||||
/// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`.
|
||||
template<typename... Ts> class ReadWriteMultipleRegistersAction : public TypedClientActionBase<Ts...> {
|
||||
template<typename... Ts>
|
||||
class ReadWriteMultipleRegistersAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint16_t, read_address)
|
||||
TEMPLATABLE_VALUE(uint16_t, read_count)
|
||||
@@ -385,13 +422,15 @@ template<typename... Ts> class ReadWriteMultipleRegistersAction : public TypedCl
|
||||
// An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent.
|
||||
if (this->len_ >= 0) {
|
||||
this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu(
|
||||
read_start, read_count, write_start,
|
||||
std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))));
|
||||
read_start, read_count, write_start,
|
||||
std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))),
|
||||
this->command_options_(x...));
|
||||
return;
|
||||
}
|
||||
const std::vector<uint16_t> values = this->values_.func(x...);
|
||||
this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu(
|
||||
read_start, read_count, write_start, std::span<const uint16_t>(values)));
|
||||
read_start, read_count, write_start, std::span<const uint16_t>(values)),
|
||||
this->command_options_(x...));
|
||||
}
|
||||
// The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read.
|
||||
void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
|
||||
|
||||
@@ -103,12 +103,20 @@ def _warn_removed_options(config: ConfigType) -> ConfigType:
|
||||
|
||||
|
||||
def _reject_broadcast_address(config: ConfigType) -> ConfigType:
|
||||
"""A modbus_controller polls one device, so its address cannot be the broadcast address (0):
|
||||
a broadcast is never answered (Modbus 4.1), so no register could ever read back."""
|
||||
"""Address 0 is rejected unless allow_broadcast_read, which in turn requires address 0."""
|
||||
if config[modbus.CONF_ALLOW_BROADCAST_READ]:
|
||||
if config.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS:
|
||||
raise cv.Invalid(
|
||||
f"'{modbus.CONF_ALLOW_BROADCAST_READ}' only applies to the broadcast address; "
|
||||
f"set 'address: 0' or remove the option.",
|
||||
[modbus.CONF_ALLOW_BROADCAST_READ],
|
||||
)
|
||||
return config
|
||||
modbus.reject_broadcast_address(
|
||||
config.get(CONF_ADDRESS),
|
||||
"a modbus_controller device address",
|
||||
"Assign the unit address of the device you want to poll.",
|
||||
"Assign the unit address of the device you want to poll, or set allow_broadcast_read if "
|
||||
"it answers address 0.",
|
||||
[CONF_ADDRESS],
|
||||
)
|
||||
return config
|
||||
@@ -346,12 +354,52 @@ def _reject_continuous_write_custom_pdu(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _reject_broadcastable_custom_pdu(config: ConfigType) -> None:
|
||||
"""A broadcastable custom_pdu under an address-0 controller is a real broadcast, never answered."""
|
||||
pdu = config.get(CONF_CUSTOM_PDU)
|
||||
if pdu is None or not modbus.is_function_code_broadcastable(pdu[0]):
|
||||
return
|
||||
fconf = fv.full_config.get()
|
||||
path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1]
|
||||
controller = fconf.get_config_for_path(path)
|
||||
if (
|
||||
controller.get(CONF_ADDRESS) == modbus.BROADCAST_ADDRESS
|
||||
and controller.get(modbus.CONF_ALLOW_BROADCAST_READ) is True
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"a '{CONF_CUSTOM_PDU}' with function code 0x{pdu[0] & 0x7F:02X} is a real broadcast at "
|
||||
f"address 0 and is never answered, so it can't be polled through the "
|
||||
f"'{controller[CONF_ID]}' modbus_controller; use a read function code.",
|
||||
[CONF_CUSTOM_PDU],
|
||||
)
|
||||
|
||||
|
||||
def validate_custom_pdu_item(config: ConfigType) -> None:
|
||||
"""Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor,
|
||||
text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a
|
||||
continuously-polling controller."""
|
||||
"""Final-validate for the platforms that accept custom_pdu."""
|
||||
migrate_custom_command(config)
|
||||
_reject_continuous_write_custom_pdu(config)
|
||||
_reject_broadcastable_custom_pdu(config)
|
||||
|
||||
|
||||
def _reject_write_option_off_broadcast(config: ConfigType) -> None:
|
||||
if not any(config.get(key) is True for key in modbus.broadcast_only_option_keys()):
|
||||
return
|
||||
fconf = fv.full_config.get()
|
||||
path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1]
|
||||
controller = fconf.get_config_for_path(path)
|
||||
if controller.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS:
|
||||
raise cv.Invalid(
|
||||
f"'{modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE}' only applies when the "
|
||||
f"'{controller[CONF_ID]}' modbus_controller is at address 0; remove the option.",
|
||||
[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE],
|
||||
)
|
||||
|
||||
|
||||
def validate_writer_item(config: ConfigType) -> None:
|
||||
"""Final-validate for the writer platforms (number, output, select, switch)."""
|
||||
if CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config:
|
||||
validate_custom_pdu_item(config)
|
||||
_reject_write_option_off_broadcast(config)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
@@ -448,11 +496,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
|
||||
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
|
||||
cg.add(
|
||||
var.set_read_options(
|
||||
modbus.command_options_expression(config, direction="read")
|
||||
)
|
||||
)
|
||||
modbus.add_command_options(var, "set_read_options", config, direction="read")
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1
|
||||
bool WriterDevice::send_raw_frame_deprecated(std::span<const uint8_t> frame) {
|
||||
if (frame.empty())
|
||||
return false;
|
||||
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this);
|
||||
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this, this->write_options_);
|
||||
}
|
||||
|
||||
void ControllerDevice::set_controller(ModbusController *controller) {
|
||||
@@ -234,10 +234,13 @@ void ModbusCommandItem::on_sent(std::span<const uint8_t> request_pdu) {
|
||||
// (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.)
|
||||
// A custom polling command sends its PDU to this controller's own address, so only a factory custom
|
||||
// command (a raw frame staged in payload) can carry a different address byte.
|
||||
// An address-0 read with allow_broadcast_read is answered, so it keeps its terminal callback.
|
||||
uint8_t wire_address = this->address_;
|
||||
if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty())
|
||||
wire_address = this->payload.data()[0];
|
||||
if (wire_address == modbus::BROADCAST_ADDRESS)
|
||||
const bool answered = this->controller_->read_options().allow_broadcast_read &&
|
||||
!modbus::helpers::is_function_code_broadcastable(request_pdu[0]);
|
||||
if (wire_address == modbus::BROADCAST_ADDRESS && !answered)
|
||||
this->controller_->unqueue_command(this);
|
||||
}
|
||||
|
||||
@@ -285,8 +288,8 @@ void ModbusController::queue_command(ModbusCommandItem command) {
|
||||
this->one_shot_command_items_.push_back(make_unique<ModbusCommandItem>(std::move(command)));
|
||||
// A refused frame gets no terminal callback (see the hub contract), so reclaim the item here.
|
||||
auto &item = this->one_shot_command_items_.back();
|
||||
// We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling.
|
||||
if (!item->send()) {
|
||||
// One-shots never poll, so only the broadcast flag is passed (the hub strips it from writes).
|
||||
if (!item->send({.allow_broadcast_read = this->read_options_.allow_broadcast_read})) {
|
||||
// The caller (e.g. a write entity) has usually already published optimistically - surface the loss.
|
||||
ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast<uint8_t>(item->register_type()),
|
||||
item->register_address());
|
||||
@@ -340,7 +343,7 @@ void ModbusController::update() {
|
||||
if (this->can_send()) {
|
||||
for (auto &poll : this->polling_devices_) {
|
||||
ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address());
|
||||
// read_options_ carries the controller's continuous flag (the offline probe above sends it too).
|
||||
// read_options_ carries the controller's read-side flags (the offline probe above sends them too).
|
||||
// A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
|
||||
if (!poll.queue(this->read_options_)) {
|
||||
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address());
|
||||
|
||||
@@ -280,10 +280,11 @@ class ControllerDevice : protected modbus::ModbusClientDevice {
|
||||
|
||||
void notify_online_(std::span<const uint8_t> request_pdu);
|
||||
|
||||
/// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's
|
||||
/// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0.
|
||||
bool dispatched_{false};
|
||||
bool write_buffer_deprecated_warned_{false};
|
||||
/// Write-path state for WriterEntity's forwarders, packed into the base's tail padding. The warn flag
|
||||
/// leaves in 2027.3.0.
|
||||
bool dispatched_ : 1 {false};
|
||||
bool write_buffer_deprecated_warned_ : 1 {false};
|
||||
modbus::CommandOptions write_options_{};
|
||||
ModbusController *controller_{nullptr};
|
||||
};
|
||||
|
||||
@@ -305,6 +306,8 @@ class WriterDevice final : public ControllerDevice {
|
||||
bool dispatched() const { return this->dispatched_; }
|
||||
void set_dispatched() { this->dispatched_ = true; }
|
||||
void clear_dispatched() { this->dispatched_ = false; }
|
||||
modbus::CommandOptions write_options() const { return this->write_options_; }
|
||||
void set_write_options(modbus::CommandOptions options) { this->write_options_ = options; }
|
||||
/// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the
|
||||
/// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0.
|
||||
void warn_write_buffer_deprecated(const LogString *platform, uint16_t address);
|
||||
@@ -326,27 +329,29 @@ class WriterEntity {
|
||||
/// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records
|
||||
/// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write.
|
||||
bool dispatched() const { return this->device_.dispatched(); }
|
||||
void set_write_options(modbus::CommandOptions options) { this->device_.set_write_options(options); }
|
||||
bool write_single_register(uint16_t address, uint16_t value) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_single_register(address, value);
|
||||
return this->device_.write_single_register(address, value, this->device_.write_options());
|
||||
}
|
||||
bool write_single_coil(uint16_t address, bool value) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_single_coil(address, value);
|
||||
return this->device_.write_single_coil(address, value, this->device_.write_options());
|
||||
}
|
||||
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_multiple_registers(address, values);
|
||||
return this->device_.write_multiple_registers(address, values, this->device_.write_options());
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_multiple_coils(address, values);
|
||||
return this->device_.write_multiple_coils(address, values, this->device_.write_options());
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_multiple_coils(address, bits);
|
||||
return this->device_.write_multiple_coils(address, bits, this->device_.write_options());
|
||||
}
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
|
||||
bool queue_pdu(std::span<const uint8_t> pdu) { return this->queue_pdu(pdu, this->device_.write_options()); }
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.queue_pdu(pdu, options);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import number
|
||||
from esphome.components import modbus, number
|
||||
from esphome.components.modbus.helpers import (
|
||||
MODBUS_WRITE_REGISTER_TYPE,
|
||||
SENSOR_VALUE_TYPE,
|
||||
@@ -23,8 +23,8 @@ from .. import (
|
||||
add_modbus_base_properties,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
validate_custom_pdu_item,
|
||||
validate_range_reuse_migration,
|
||||
validate_writer_item,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_BITMASK,
|
||||
@@ -84,6 +84,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_STEP, default=1): cv.positive_float,
|
||||
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
**modbus.command_options_schema(direction="write"),
|
||||
}
|
||||
),
|
||||
validate_min_max,
|
||||
@@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_range_reuse_migration,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
|
||||
FINAL_VALIDATE_SCHEMA = validate_writer_item
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
@@ -122,6 +123,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(parent.add_sensor_item(var))
|
||||
await add_modbus_base_properties(var, config, ModbusNumber)
|
||||
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
|
||||
modbus.add_command_options(var, "set_write_options", config, direction="write")
|
||||
if CONF_WRITE_LAMBDA in config:
|
||||
template_ = await cg.process_lambda(
|
||||
config[CONF_WRITE_LAMBDA],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import output
|
||||
from esphome.components import modbus, output
|
||||
from esphome.components.modbus.helpers import (
|
||||
SENSOR_VALUE_TYPE,
|
||||
PduBuffer,
|
||||
@@ -18,6 +18,7 @@ from .. import (
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
reject_odd_holding_write_offset,
|
||||
validate_writer_item,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_CUSTOM_COMMAND,
|
||||
@@ -79,6 +80,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
**modbus.command_options_schema(direction="write"),
|
||||
}
|
||||
),
|
||||
"holding": cv.All(
|
||||
@@ -98,6 +100,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
**modbus.command_options_schema(direction="write"),
|
||||
}
|
||||
),
|
||||
reject_odd_holding_write_offset,
|
||||
@@ -111,6 +114,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_writer_item
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
byte_offset = modbus_calc_properties(config)
|
||||
# Binary Output
|
||||
@@ -153,6 +159,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
await output.register_output(var, config)
|
||||
parent = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID])
|
||||
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
|
||||
modbus.add_command_options(var, "set_write_options", config, direction="write")
|
||||
cg.add(var.set_parent(parent))
|
||||
if write_template:
|
||||
cg.add(var.set_write_template(write_template))
|
||||
|
||||
@@ -2,7 +2,7 @@ from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
from esphome.components import modbus, select
|
||||
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC
|
||||
@@ -15,6 +15,7 @@ from .. import (
|
||||
modbus_controller_ns,
|
||||
validate_range_reuse_migration,
|
||||
validate_skip_updates_deprecated,
|
||||
validate_writer_item,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_FORCE_NEW_RANGE,
|
||||
@@ -77,6 +78,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_REGISTER_COUNT): cv.positive_int,
|
||||
cv.Required(CONF_OPTIONSMAP): ensure_option_map(),
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
**modbus.command_options_schema(direction="write"),
|
||||
cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
@@ -86,6 +88,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_writer_item
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
options_map = config[CONF_OPTIONSMAP]
|
||||
|
||||
@@ -104,6 +109,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(parent.add_sensor_item(var))
|
||||
cg.add(var.set_parent(parent))
|
||||
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
|
||||
modbus.add_command_options(var, "set_write_options", config, direction="write")
|
||||
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
|
||||
|
||||
if CONF_LAMBDA in config:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import switch
|
||||
from esphome.components import modbus, switch
|
||||
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID
|
||||
@@ -13,9 +13,9 @@ from .. import (
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
reject_odd_holding_write_offset,
|
||||
validate_custom_pdu_item,
|
||||
validate_modbus_register,
|
||||
validate_range_reuse_migration,
|
||||
validate_writer_item,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_BITMASK,
|
||||
@@ -51,6 +51,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE),
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
**modbus.command_options_schema(direction="write"),
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
}
|
||||
),
|
||||
@@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_range_reuse_migration,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
|
||||
FINAL_VALIDATE_SCHEMA = validate_writer_item
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
@@ -78,6 +79,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID])
|
||||
cg.add(var.set_parent(paren))
|
||||
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
|
||||
modbus.add_command_options(var, "set_write_options", config, direction="write")
|
||||
assumed_state = config[CONF_ASSUMED_STATE]
|
||||
cg.add(var.set_assumed_state(assumed_state))
|
||||
if not assumed_state:
|
||||
|
||||
@@ -26,30 +26,34 @@ namespace esphome::network {
|
||||
|
||||
/// Return whether the node is connected to the network (through wifi, eth, ...)
|
||||
ESPHOME_ALWAYS_INLINE inline bool is_connected() {
|
||||
// With a single interface enabled the checks below collapse to `if (x) return true; return false;`, which
|
||||
// clang-tidy wants folded into one return. Keep the per-interface form so every enabled interface is checked.
|
||||
// NOLINTBEGIN(readability-simplify-boolean-expr)
|
||||
#ifdef USE_ETHERNET
|
||||
if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected())
|
||||
return true;
|
||||
#endif
|
||||
|
||||
#ifdef USE_MODEM
|
||||
if (modem::global_modem_component != nullptr)
|
||||
return modem::global_modem_component->is_connected();
|
||||
if (modem::global_modem_component != nullptr && modem::global_modem_component->is_connected())
|
||||
return true;
|
||||
#endif
|
||||
|
||||
#ifdef USE_WIFI
|
||||
if (wifi::global_wifi_component != nullptr)
|
||||
return wifi::global_wifi_component->is_connected();
|
||||
if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected())
|
||||
return true;
|
||||
#endif
|
||||
|
||||
#ifdef USE_OPENTHREAD
|
||||
if (openthread::global_openthread_component != nullptr)
|
||||
return openthread::global_openthread_component->is_connected();
|
||||
if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected())
|
||||
return true;
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
return true; // Assume it's connected
|
||||
#endif
|
||||
return false;
|
||||
// NOLINTEND(readability-simplify-boolean-expr)
|
||||
}
|
||||
|
||||
/// Return whether the network is disabled: every configured interface with a
|
||||
|
||||
@@ -14,7 +14,12 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, TimePeriod
|
||||
|
||||
from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import
|
||||
from . import ( # noqa: F401 pylint: disable=unused-import
|
||||
FILTER_SOURCE_FILES,
|
||||
Nextion,
|
||||
nextion_ns,
|
||||
nextion_ref,
|
||||
)
|
||||
from .base_component import (
|
||||
CONF_AUTO_WAKE_ON_TOUCH,
|
||||
CONF_COMMAND_SPACING,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,11 +5,12 @@ from typing import Any
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENCRYPTION, CONF_KEY
|
||||
from esphome.core import ID
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
DOMAIN = "noise"
|
||||
|
||||
noise_ns = cg.esphome_ns.namespace("noise")
|
||||
|
||||
@@ -70,11 +71,16 @@ def static_encryption_key(conf: ConfigType) -> str | None:
|
||||
|
||||
def new_psk_progmem(parent_id: ID, key: str) -> MockObj:
|
||||
"""Emit the decoded key as a PROGMEM array; the component keeps a pointer
|
||||
so the key never occupies RAM."""
|
||||
return cg.progmem_array(
|
||||
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
|
||||
list(decode_encryption_key(key)),
|
||||
)
|
||||
so the key never occupies RAM. Components sharing one key (api and ota)
|
||||
share the array."""
|
||||
decoded = decode_encryption_key(key)
|
||||
arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {})
|
||||
if (array := arrays.get(decoded)) is None:
|
||||
array = arrays[decoded] = cg.progmem_array(
|
||||
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
|
||||
list(decoded),
|
||||
)
|
||||
return array
|
||||
|
||||
|
||||
def encryption_schema(config: ConfigType | None) -> ConfigType:
|
||||
@@ -88,12 +94,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.24")
|
||||
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.6")
|
||||
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")
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
#include <cstdint>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include "noise_resume.h"
|
||||
|
||||
namespace esphome::noise {
|
||||
|
||||
using psk_t = std::array<uint8_t, 32>;
|
||||
@@ -28,19 +26,13 @@ class NoiseContext {
|
||||
/// psk points at 32 bytes that outlive the context (PROGMEM or caller owned
|
||||
/// RAM); nullptr means no key. Runtime callers map the all-zeros key to
|
||||
/// nullptr themselves; validation keeps it out of yaml.
|
||||
void set_psk(const uint8_t *psk) {
|
||||
this->psk_ = psk;
|
||||
// Resume tickets were minted under the old key; forget them
|
||||
this->resume_cache_.clear();
|
||||
}
|
||||
void set_psk(const uint8_t *psk) { this->psk_ = psk; }
|
||||
/// Copy the key out (flash-aware on ESP8266); all zeros when none is set.
|
||||
void load_psk(psk_t &out) const;
|
||||
bool has_psk() const { return this->psk_ != nullptr; }
|
||||
ResumeTicketCache &resume_cache() { return this->resume_cache_; }
|
||||
|
||||
protected:
|
||||
const uint8_t *psk_{nullptr};
|
||||
ResumeTicketCache resume_cache_;
|
||||
};
|
||||
|
||||
/// Convert a noise error code to a readable error
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
#include "noise_resume.h"
|
||||
#ifdef USE_NOISE
|
||||
#include <cstring>
|
||||
|
||||
#include <noise/protocol.h>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::noise {
|
||||
|
||||
const char RESUME_LABEL_OFFER[6] PROGMEM = "offer";
|
||||
const char RESUME_LABEL_CONFIRM[8] PROGMEM = "confirm";
|
||||
const char RESUME_LABEL_KEYS[5] PROGMEM = "keys";
|
||||
bool resume_kdf(const uint8_t *secret, const char *label, size_t label_len, const uint8_t *a, size_t a_len,
|
||||
const uint8_t *b, size_t b_len, const uint8_t *hash_in, size_t hash_in_len, uint8_t *out1,
|
||||
size_t out1_len, uint8_t *out2) {
|
||||
uint8_t data[RESUME_KDF_MAX_DATA];
|
||||
uint8_t scratch[32];
|
||||
size_t len = label_len + a_len + b_len;
|
||||
progmem_memcpy(data, label, label_len);
|
||||
std::memcpy(data + label_len, a, a_len);
|
||||
std::memcpy(data + label_len + a_len, b, b_len);
|
||||
NoiseHashState *hash = nullptr;
|
||||
if (noise_hashstate_new_by_id(&hash, NOISE_HASH_SHA256) != NOISE_ERROR_NONE) {
|
||||
return false;
|
||||
}
|
||||
int err = NOISE_ERROR_NONE;
|
||||
if (hash_in != nullptr) {
|
||||
err = noise_hashstate_hash_one(hash, hash_in, hash_in_len, data + len, 32);
|
||||
len += 32;
|
||||
}
|
||||
if (err == NOISE_ERROR_NONE) {
|
||||
err = noise_hashstate_hkdf(hash, secret, RESUME_SECRET_SIZE, data, len, out1, out1_len,
|
||||
out2 != nullptr ? out2 : scratch, 32);
|
||||
}
|
||||
noise_hashstate_free(hash);
|
||||
noise_clean(data, sizeof(data));
|
||||
noise_clean(scratch, sizeof(scratch));
|
||||
return err == NOISE_ERROR_NONE;
|
||||
}
|
||||
|
||||
bool ResumeTicketCache::issue(ResumeTicket &out) {
|
||||
if (!random_bytes(reinterpret_cast<uint8_t *>(&out), sizeof(out))) {
|
||||
return false;
|
||||
}
|
||||
uint8_t slot = this->next_;
|
||||
this->next_ = static_cast<uint8_t>((slot + 1) % SLOTS);
|
||||
this->slots_[slot] = out;
|
||||
this->used_mask_ |= static_cast<uint8_t>(1u << slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue,
|
||||
size_t prologue_len, uint8_t *out_ext, size_t out_capacity,
|
||||
NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher) {
|
||||
if (offer_len != RESUME_OFFER_SIZE || offer[0] != RESUME_OFFER_VERSION || out_capacity < RESUME_ACCEPT_SIZE) {
|
||||
return 0;
|
||||
}
|
||||
const uint8_t *session_id = offer + RESUME_OFFER_SESSION_ID_OFFSET;
|
||||
const uint8_t *client_nonce = offer + RESUME_OFFER_NONCE_OFFSET;
|
||||
ResumeTicket *ticket = nullptr;
|
||||
for (uint8_t i = 0; i < SLOTS; i++) {
|
||||
if ((this->used_mask_ & (1u << i)) &&
|
||||
std::memcmp(this->slots_[i].session_id, session_id, RESUME_SESSION_ID_SIZE) == 0) {
|
||||
ticket = &this->slots_[i];
|
||||
this->used_mask_ &= static_cast<uint8_t>(~(1u << i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ticket == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
uint8_t expected[RESUME_MAC_SIZE];
|
||||
bool ok = resume_compute_offer_mac(ticket->secret, session_id, client_nonce, expected) &&
|
||||
noise_is_equal(expected, offer + RESUME_OFFER_MAC_OFFSET, RESUME_MAC_SIZE);
|
||||
noise_clean(expected, sizeof(expected));
|
||||
if (!ok) {
|
||||
// Bad MAC: keep the ticket so a forger cannot burn it
|
||||
this->used_mask_ |= static_cast<uint8_t>(1u << static_cast<uint8_t>(ticket - this->slots_));
|
||||
return 0;
|
||||
}
|
||||
// The ticket is spent from here; any later failure falls back to the full
|
||||
// handshake and the client gets a fresh one.
|
||||
uint8_t *server_nonce = out_ext + 1;
|
||||
uint8_t k_c2d[32];
|
||||
uint8_t k_d2c[32];
|
||||
out_ext[0] = RESUME_ACCEPT_VERSION;
|
||||
ok = random_bytes(server_nonce, RESUME_NONCE_SIZE) &&
|
||||
resume_compute_confirm_mac(ticket->secret, client_nonce, server_nonce, out_ext + 1 + RESUME_NONCE_SIZE) &&
|
||||
resume_derive_keys(ticket->secret, client_nonce, server_nonce, prologue, prologue_len, k_c2d, k_d2c);
|
||||
noise_clean(ticket, sizeof(*ticket));
|
||||
if (ok) {
|
||||
recv_cipher = resume_make_cipher(k_c2d);
|
||||
send_cipher = resume_make_cipher(k_d2c);
|
||||
ok = recv_cipher != nullptr && send_cipher != nullptr;
|
||||
if (!ok) {
|
||||
noise_cipherstate_free(recv_cipher);
|
||||
noise_cipherstate_free(send_cipher);
|
||||
recv_cipher = nullptr;
|
||||
send_cipher = nullptr;
|
||||
}
|
||||
}
|
||||
noise_clean(k_c2d, sizeof(k_c2d));
|
||||
noise_clean(k_d2c, sizeof(k_d2c));
|
||||
return ok ? RESUME_ACCEPT_SIZE : 0;
|
||||
}
|
||||
|
||||
void ResumeTicketCache::clear() {
|
||||
noise_clean(this->slots_, sizeof(this->slots_));
|
||||
this->used_mask_ = 0;
|
||||
}
|
||||
|
||||
NoiseCipherState *resume_make_cipher(const uint8_t *key) {
|
||||
NoiseCipherState *cipher = nullptr;
|
||||
if (noise_cipherstate_new_by_id(&cipher, NOISE_CIPHER_CHACHAPOLY) != NOISE_ERROR_NONE) {
|
||||
return nullptr;
|
||||
}
|
||||
if (noise_cipherstate_init_key(cipher, key, 32) != NOISE_ERROR_NONE) {
|
||||
noise_cipherstate_free(cipher);
|
||||
return nullptr;
|
||||
}
|
||||
return cipher;
|
||||
}
|
||||
|
||||
} // namespace esphome::noise
|
||||
#endif // USE_NOISE
|
||||
@@ -1,132 +0,0 @@
|
||||
#pragma once
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_NOISE
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
// Forward declaration matching <noise/protocol/cipherstate.h>; keeps noise-c
|
||||
// headers out of everything that includes noise.h.
|
||||
extern "C" {
|
||||
typedef struct NoiseCipherState_s NoiseCipherState; // NOLINT(modernize-use-using)
|
||||
}
|
||||
|
||||
namespace esphome::noise {
|
||||
|
||||
/** Session resume for the noise transports.
|
||||
*
|
||||
* After a full handshake the responder issues a single-use ticket over the
|
||||
* encrypted channel. A client presents it in its next ClientHello and both
|
||||
* sides derive the transport keys with HKDF-SHA256 alone, skipping the two
|
||||
* curve25519 operations. Old peers ignore the extension bytes on both
|
||||
* sides, so every mismatch degrades to a normal full handshake.
|
||||
*
|
||||
* HKDF is the Noise construction (noise_hashstate_hkdf). Derivations:
|
||||
* offer_mac = HKDF(secret, "offer" || session_id || client_nonce).out1[:16]
|
||||
* confirm_mac = HKDF(secret, "confirm" || client_nonce || server_nonce).out1[:16]
|
||||
* k_c2d, k_d2c = HKDF(secret, "keys" || client_nonce || server_nonce || SHA256(prologue))
|
||||
*
|
||||
* An offering client sends handshake message 1 only after a decline.
|
||||
* Resumed sessions have no ephemeral DH; the ticket is wiped on use.
|
||||
*/
|
||||
|
||||
static constexpr uint8_t RESUME_OFFER_VERSION = 0x01;
|
||||
static constexpr uint8_t RESUME_ACCEPT_VERSION = 0x01;
|
||||
static constexpr size_t RESUME_SESSION_ID_SIZE = 8;
|
||||
static constexpr size_t RESUME_NONCE_SIZE = 16;
|
||||
static constexpr size_t RESUME_MAC_SIZE = 16;
|
||||
static constexpr size_t RESUME_SECRET_SIZE = 32;
|
||||
|
||||
// ClientHello body: version | session_id | client_nonce | offer_mac
|
||||
static constexpr size_t RESUME_OFFER_SIZE = 1 + RESUME_SESSION_ID_SIZE + RESUME_NONCE_SIZE + RESUME_MAC_SIZE; // 41
|
||||
static constexpr size_t RESUME_OFFER_SESSION_ID_OFFSET = 1;
|
||||
static constexpr size_t RESUME_OFFER_NONCE_OFFSET = RESUME_OFFER_SESSION_ID_OFFSET + RESUME_SESSION_ID_SIZE;
|
||||
static constexpr size_t RESUME_OFFER_MAC_OFFSET = RESUME_OFFER_NONCE_OFFSET + RESUME_NONCE_SIZE;
|
||||
|
||||
// ServerHello trailing extension: version | server_nonce | confirm_mac
|
||||
static constexpr size_t RESUME_ACCEPT_SIZE = 1 + RESUME_NONCE_SIZE + RESUME_MAC_SIZE; // 33
|
||||
|
||||
struct ResumeTicket {
|
||||
uint8_t session_id[RESUME_SESSION_ID_SIZE];
|
||||
uint8_t secret[RESUME_SECRET_SIZE];
|
||||
};
|
||||
// Sent on the wire as one blob: session_id || secret
|
||||
static_assert(sizeof(ResumeTicket) == RESUME_SESSION_ID_SIZE + RESUME_SECRET_SIZE, "ticket must be packed");
|
||||
|
||||
/// Fixed-slot RAM cache of single-use resume tickets. Lost on reboot by
|
||||
/// design: clients fall back to a full handshake.
|
||||
class ResumeTicketCache {
|
||||
public:
|
||||
/// Generate a fresh ticket into out and store it, evicting the oldest
|
||||
/// slot. Returns false (and stores nothing) if the RNG fails.
|
||||
bool issue(ResumeTicket &out);
|
||||
/// Accept a resume offer: verify and consume the ticket (single use; a
|
||||
/// forged MAC never burns one), build both transport ciphers, and write
|
||||
/// the ServerHello accept extension into out_ext. Returns the extension
|
||||
/// length, or 0 (nothing allocated) on any miss, failure, or when
|
||||
/// out_capacity is too small. Secrets are wiped internally.
|
||||
size_t try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue, size_t prologue_len,
|
||||
uint8_t *out_ext, size_t out_capacity, NoiseCipherState *&send_cipher,
|
||||
NoiseCipherState *&recv_cipher);
|
||||
/// Forget every ticket (PSK change).
|
||||
void clear();
|
||||
|
||||
// Round robin; more clients than slots thrash and fall back to full handshakes
|
||||
static constexpr uint8_t SLOTS = 2;
|
||||
static_assert(SLOTS <= 8, "used_mask_ is uint8_t");
|
||||
|
||||
protected:
|
||||
ResumeTicket slots_[SLOTS];
|
||||
uint8_t used_mask_{0};
|
||||
uint8_t next_{0};
|
||||
};
|
||||
|
||||
/// HKDF labels, PROGMEM on ESP8266.
|
||||
extern const char RESUME_LABEL_OFFER[6];
|
||||
extern const char RESUME_LABEL_CONFIRM[8];
|
||||
extern const char RESUME_LABEL_KEYS[5];
|
||||
|
||||
// Largest KDF input: "keys" || client_nonce || server_nonce || SHA256(prologue)
|
||||
static constexpr size_t RESUME_KDF_MAX_DATA =
|
||||
sizeof(RESUME_LABEL_KEYS) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32;
|
||||
|
||||
/// Noise-construction HKDF-SHA256 keyed with the ticket secret over
|
||||
/// label || a || b [|| SHA256(hash_in)], at most RESUME_KDF_MAX_DATA. out2 == nullptr means MAC only.
|
||||
bool resume_kdf(const uint8_t *secret, const char *label, size_t label_len, const uint8_t *a, size_t a_len,
|
||||
const uint8_t *b, size_t b_len, const uint8_t *hash_in, size_t hash_in_len, uint8_t *out1,
|
||||
size_t out1_len, uint8_t *out2);
|
||||
|
||||
/// offer_mac for the ClientHello resume offer (what a client computes and
|
||||
/// try_accept checks).
|
||||
inline bool resume_compute_offer_mac(const uint8_t *secret, const uint8_t *session_id, const uint8_t *client_nonce,
|
||||
uint8_t *out_mac) {
|
||||
static_assert(sizeof(RESUME_LABEL_OFFER) - 1 + RESUME_SESSION_ID_SIZE + RESUME_NONCE_SIZE <= RESUME_KDF_MAX_DATA,
|
||||
"KDF buffer");
|
||||
return resume_kdf(secret, RESUME_LABEL_OFFER, sizeof(RESUME_LABEL_OFFER) - 1, session_id, RESUME_SESSION_ID_SIZE,
|
||||
client_nonce, RESUME_NONCE_SIZE, nullptr, 0, out_mac, RESUME_MAC_SIZE, nullptr);
|
||||
}
|
||||
|
||||
/// confirm_mac for the ServerHello extension.
|
||||
inline bool resume_compute_confirm_mac(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce,
|
||||
uint8_t *out_mac) {
|
||||
static_assert(sizeof(RESUME_LABEL_CONFIRM) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE <= RESUME_KDF_MAX_DATA,
|
||||
"KDF buffer");
|
||||
return resume_kdf(secret, RESUME_LABEL_CONFIRM, sizeof(RESUME_LABEL_CONFIRM) - 1, client_nonce, RESUME_NONCE_SIZE,
|
||||
server_nonce, RESUME_NONCE_SIZE, nullptr, 0, out_mac, RESUME_MAC_SIZE, nullptr);
|
||||
}
|
||||
|
||||
/// Derive the transport keys. k_c2d encrypts client-to-device traffic,
|
||||
/// k_d2c device-to-client.
|
||||
inline bool resume_derive_keys(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce,
|
||||
const uint8_t *prologue, size_t prologue_len, uint8_t *k_c2d, uint8_t *k_d2c) {
|
||||
static_assert(sizeof(RESUME_LABEL_KEYS) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32 <= RESUME_KDF_MAX_DATA,
|
||||
"KDF buffer");
|
||||
return resume_kdf(secret, RESUME_LABEL_KEYS, sizeof(RESUME_LABEL_KEYS) - 1, client_nonce, RESUME_NONCE_SIZE,
|
||||
server_nonce, RESUME_NONCE_SIZE, prologue, prologue_len, k_c2d, 32, k_d2c);
|
||||
}
|
||||
|
||||
/// Build a ChaChaPoly cipher state keyed with key (32 bytes); nullptr on
|
||||
/// failure. Nonce counter starts at 0, exactly like a post-split cipher.
|
||||
NoiseCipherState *resume_make_cipher(const uint8_t *key);
|
||||
|
||||
} // namespace esphome::noise
|
||||
#endif // USE_NOISE
|
||||
@@ -28,6 +28,7 @@ void DeviceFirmwareUpdate::setup() {
|
||||
this->reset_pin_->digital_write(true);
|
||||
} else {
|
||||
NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET;
|
||||
arch_feed_wdt();
|
||||
App.reboot();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
|
||||
ESP_LOGV(TAG, "Stopping");
|
||||
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
|
||||
}
|
||||
if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) {
|
||||
this->task_.deallocate();
|
||||
// Retries on a subsequent loop if the task is still running on the other core
|
||||
if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) {
|
||||
ESP_LOGD(TAG, "Stopped");
|
||||
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
|
||||
}
|
||||
@@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic
|
||||
bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
|
||||
} else {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (temp_ring_buffer) {
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
// Only write to the ring buffer if the reference is valid
|
||||
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
||||
} else {
|
||||
@@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const {
|
||||
bool has_ring_buffer_data = false;
|
||||
if (this->requires_resampling_()) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (temp_ring_buffer) {
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
has_ring_buffer_data = (temp_ring_buffer->available() > 0);
|
||||
}
|
||||
}
|
||||
@@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
|
||||
this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_));
|
||||
|
||||
if (!temp_ring_buffer) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
err = ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
this_resampler->ring_buffer_ = temp_ring_buffer;
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -55,8 +55,7 @@ namespace esphome::rp2 {
|
||||
|
||||
static const char *const TAG = "rp2.crash";
|
||||
|
||||
// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear().
|
||||
// The valid field is explicitly cleared in crash_handler_read_and_clear() instead.
|
||||
// Filled from the watchdog scratch registers on the first read.
|
||||
static struct CrashData {
|
||||
bool valid;
|
||||
uint32_t pc;
|
||||
@@ -64,11 +63,24 @@ static struct CrashData {
|
||||
uint32_t sp;
|
||||
uint32_t backtrace[MAX_BACKTRACE];
|
||||
uint8_t backtrace_count;
|
||||
} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
} s_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
bool crash_handler_has_data() { return s_crash_data.valid; }
|
||||
// Logger::pre_setup() logs the record before App.pre_setup() reaches
|
||||
// arch_init(), so the first caller reads it and later calls are no-ops.
|
||||
// The read clears the scratch registers, so it must not run twice, and
|
||||
// arch_init() keeps its call so the read precedes watchdog_enable(), which
|
||||
// overwrites scratch[4].
|
||||
static bool s_crash_data_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
bool crash_handler_has_data() {
|
||||
crash_handler_read_and_clear();
|
||||
return s_crash_data.valid;
|
||||
}
|
||||
|
||||
void crash_handler_read_and_clear() {
|
||||
if (s_crash_data_read)
|
||||
return;
|
||||
s_crash_data_read = true;
|
||||
s_crash_data.valid = false;
|
||||
uint32_t magic = watchdog_hw->scratch[0];
|
||||
if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) {
|
||||
@@ -97,7 +109,7 @@ void crash_handler_read_and_clear() {
|
||||
// the device crashes again during boot, and allowing the CLI's process_stacktrace
|
||||
// to match and decode each address individually.
|
||||
void crash_handler_log() {
|
||||
if (!s_crash_data.valid)
|
||||
if (!crash_handler_has_data())
|
||||
return;
|
||||
|
||||
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
namespace esphome::rp2 {
|
||||
|
||||
/// Read crash data from watchdog scratch registers and clear them.
|
||||
/// Only the first call reads; later calls are no-ops.
|
||||
void crash_handler_read_and_clear();
|
||||
|
||||
/// Log crash data if a crash was detected on previous boot.
|
||||
void crash_handler_log();
|
||||
|
||||
/// Returns true if crash data was found this boot.
|
||||
/// Returns true if crash data was found this boot, reading it first if needed.
|
||||
bool crash_handler_has_data();
|
||||
|
||||
} // namespace esphome::rp2
|
||||
|
||||
@@ -30,6 +30,7 @@ CONF_SENDSPIN_ID = "sendspin_id"
|
||||
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
||||
CONF_FIXED_DELAY = "fixed_delay"
|
||||
CONF_DECODE_MEMORY = "decode_memory"
|
||||
CONF_CODECS = "codecs"
|
||||
|
||||
# Matches ARTWORK_MAX_SLOTS in sendspin-cpp.
|
||||
MAX_ARTWORK_SLOTS = 4
|
||||
@@ -44,6 +45,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
|
||||
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
|
||||
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
|
||||
|
||||
CODEC_FLAC = "flac"
|
||||
CODEC_OPUS = "opus"
|
||||
CODEC_PCM = "pcm"
|
||||
|
||||
CODECS = {
|
||||
CODEC_FLAC: CODEC_FORMAT_FLAC,
|
||||
CODEC_OPUS: CODEC_FORMAT_OPUS,
|
||||
CODEC_PCM: CODEC_FORMAT_PCM,
|
||||
}
|
||||
|
||||
# Opus only supports 48 kHz audio, so it is left out of the default list at other rates.
|
||||
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
|
||||
OPUS_SAMPLE_RATE = 48000
|
||||
|
||||
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
|
||||
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
|
||||
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
|
||||
@@ -286,16 +301,13 @@ async def to_code(config: ConfigType) -> None:
|
||||
if data.player_support:
|
||||
cg.add_define("USE_SENDSPIN_PLAYER", True)
|
||||
|
||||
# Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate
|
||||
# (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server
|
||||
# Configures the player role. Each configured codec is advertised for 16 bits per sample
|
||||
# mono and stereo at the configured sample rate. The order is a preference order, both for
|
||||
# the codecs themselves and for stereo over mono.
|
||||
player_cfg = data.player_config
|
||||
sample_rate = player_cfg[CONF_SAMPLE_RATE]
|
||||
|
||||
# OPUS only supports 48 kHz audio
|
||||
codecs = [CODEC_FORMAT_FLAC]
|
||||
if sample_rate == 48000:
|
||||
codecs.append(CODEC_FORMAT_OPUS)
|
||||
codecs.append(CODEC_FORMAT_PCM)
|
||||
codecs = [CODECS[codec] for codec in player_cfg[CONF_CODECS]]
|
||||
|
||||
def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer:
|
||||
return cg.StructInitializer(
|
||||
|
||||
@@ -13,11 +13,16 @@ from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CODEC_OPUS,
|
||||
CODECS,
|
||||
CONF_CODECS,
|
||||
CONF_DECODE_MEMORY,
|
||||
CONF_FIXED_DELAY,
|
||||
CONF_INITIAL_STATIC_DELAY,
|
||||
CONF_SENDSPIN_ID,
|
||||
DEFAULT_CODECS,
|
||||
MEMORY_LOCATIONS,
|
||||
OPUS_SAMPLE_RATE,
|
||||
SendspinHub,
|
||||
register_player_config,
|
||||
request_controller_support,
|
||||
@@ -49,10 +54,32 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_codecs(config: ConfigType) -> ConfigType:
|
||||
"""Validate the codec preference list, filling in the default when it is not set."""
|
||||
sample_rate = config[CONF_SAMPLE_RATE]
|
||||
if (codecs := config.get(CONF_CODECS)) is None:
|
||||
config[CONF_CODECS] = [
|
||||
codec
|
||||
for codec in DEFAULT_CODECS
|
||||
if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE
|
||||
]
|
||||
return config
|
||||
|
||||
if len(set(codecs)) != len(codecs):
|
||||
raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS])
|
||||
if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE:
|
||||
raise cv.Invalid(
|
||||
f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}",
|
||||
path=[CONF_CODECS],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _register(config: ConfigType) -> ConfigType:
|
||||
request_controller_support()
|
||||
register_player_config(
|
||||
{
|
||||
CONF_CODECS: config[CONF_CODECS],
|
||||
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
|
||||
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
|
||||
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
|
||||
@@ -85,9 +112,13 @@ CONFIG_SCHEMA = cv.All(
|
||||
min=16000, max=96000
|
||||
),
|
||||
cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True),
|
||||
cv.Optional(CONF_CODECS): cv.All(
|
||||
cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1)
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
_resolve_codecs,
|
||||
_register,
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user