mirror of
https://github.com/esphome/esphome.git
synced 2026-09-27 06:50:22 +00:00
Merge remote-tracking branch 'origin/dev' into neutral-ble-client
# Conflicts: # esphome/components/ble_client/automation.h
This commit is contained in:
@@ -9,6 +9,29 @@ body:
|
||||
If you have a feature request or enhancement, please [request them here instead][fr].
|
||||
|
||||
[fr]: https://github.com/orgs/esphome/discussions
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Use of AI in bug reports
|
||||
|
||||
AI tools are good at carrying out well-defined tasks, but they are not good at troubleshooting.
|
||||
Please do NOT paste an AI-generated wall of text into the issue template - if the AI hasn't solved
|
||||
your problem, its wild guesses are not likely to help.
|
||||
|
||||
Please DO include your own words and observations, compile/boot logs, and
|
||||
especially a minimal reproducible example of your YAML configuration that demonstrates the problem.
|
||||
|
||||
It is however quite acceptable to use AI to translate your *own* report,
|
||||
if you aren't a competent English speaker.
|
||||
|
||||
If you really think it will be useful to include an AI's analysis, preferably wrap it in a `<details>` block which will be collapsed by default.
|
||||
|
||||
If you are using AI to help solve a problem, rather than asking it to speculate about what the problem is,
|
||||
it can be more useful to ask it to create a step-by-step troubleshooting procedure.
|
||||
AI is also useful for generating boilerplate code, such as a minimal reproducible example of your YAML
|
||||
configuration that demonstrates the problem.
|
||||
|
||||
Used properly, AI can be a useful tool to help you solve your problem, but don't let it get in the way.
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -244,11 +244,20 @@ 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:
|
||||
# Keep in sync with requirements_test.txt.
|
||||
prek-version: "0.4.11"
|
||||
prek-version: ${{ steps.prek.outputs.version }}
|
||||
# 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
|
||||
@@ -374,8 +383,9 @@ jobs:
|
||||
- name: Install apt packages (cached)
|
||||
# ccache speeds up the host compiles. A cache hit never touches apt
|
||||
# (mirror outages cannot hang the job); the timeout bounds the cold
|
||||
# path. Packages and version must match seed-apt-cache exactly;
|
||||
# libsdl2-dev is unused here and carried only for cache-key parity.
|
||||
# path. Packages and version must match seed-apt-cache exactly.
|
||||
# libsdl2-dev is needed by the headless display tests, which capture
|
||||
# screenshots.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
@@ -438,6 +448,16 @@ jobs:
|
||||
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
|
||||
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal \
|
||||
--junitxml=junit-integration.xml "${test_files[@]}"
|
||||
- name: Upload test artifacts
|
||||
# Tests that compare rendered output write the image they actually got here, so a
|
||||
# failure can be looked at without reproducing the whole build locally.
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: integration-test-artifacts-${{ matrix.bucket.name }}
|
||||
path: test_artifacts/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Upload junit timings
|
||||
# Consumed by sync-integration-durations.yml through
|
||||
# script/update_integration_test_durations.py; only full matrix dev
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
|
||||
If you are the author of this PR, please leave a comment if you want
|
||||
to keep it open. Also, please rebase your PR onto the latest dev
|
||||
to keep it open. Also, please merge the latest dev branch into your
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Thank you for your contribution!
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# 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
|
||||
@@ -137,6 +137,8 @@ config/
|
||||
!tests/component_tests/**/config/
|
||||
tests/build/
|
||||
tests/.esphome/
|
||||
# Output kept by failing tests for inspection; uploaded by CI
|
||||
test_artifacts/
|
||||
/.temp-clang-tidy.cpp
|
||||
/.temp/
|
||||
.pio/
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
# 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
|
||||
@@ -11,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.3
|
||||
rev: v0.16.6
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
@@ -42,7 +41,7 @@ repos:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
- repo: https://github.com/adrienverge/yamllint.git
|
||||
rev: v1.37.1
|
||||
rev: v1.38.0
|
||||
hooks:
|
||||
- id: yamllint
|
||||
exclude: ^(\.clang-format|\.clang-tidy)$
|
||||
|
||||
@@ -553,6 +553,7 @@ 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.
|
||||
@@ -628,6 +629,9 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
_request_listener_slot()
|
||||
cg.add(hub.register_listener(var))
|
||||
```
|
||||
When several instances each own a list declared at the same size (one per hub of a
|
||||
`MULTI_CONF` component), pass the owning object as the key, `_request_listener_slot(str(hub))`;
|
||||
the define is then the largest count any one key requested instead of the total.
|
||||
```cpp
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
void register_listener(MyComponentListener *listener);
|
||||
@@ -839,7 +843,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:
|
||||
|
||||
@@ -100,6 +100,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
|
||||
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
|
||||
esphome/components/bp1658cj/* @Cossid
|
||||
esphome/components/bp5758d/* @Cossid
|
||||
esphome/components/bridge/* @kbx81
|
||||
esphome/components/bthome_mithermometer/* @nagyrobi
|
||||
esphome/components/button/* @esphome/core
|
||||
esphome/components/bytebuffer/* @clydebarrow
|
||||
@@ -111,6 +112,8 @@ esphome/components/captive_portal/* @esphome/core
|
||||
esphome/components/cc1101/* @gabest11 @lygris
|
||||
esphome/components/ccs811/* @habbie
|
||||
esphome/components/cd74hc4067/* @asoehlke
|
||||
esphome/components/cdc_acm_uart/* @kbx81
|
||||
esphome/components/cdc_acm_uart/bridge/* @kbx81
|
||||
esphome/components/ch422g/* @clydebarrow @jesterret
|
||||
esphome/components/ch423/* @dwmw2
|
||||
esphome/components/chsc6x/* @kkosik20
|
||||
@@ -496,6 +499,7 @@ esphome/components/sm2335/* @Cossid
|
||||
esphome/components/sml/* @alengwenus
|
||||
esphome/components/smt100/* @piechade
|
||||
esphome/components/sn74hc165/* @jesserockz
|
||||
esphome/components/snapshot/* @clydebarrow
|
||||
esphome/components/socket/* @esphome/core
|
||||
esphome/components/sonoff_d1/* @anatoly-savchenkov
|
||||
esphome/components/sound_level/* @kahrendt
|
||||
|
||||
@@ -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.9.0-dev
|
||||
PROJECT_NUMBER = 2026.10.0-dev
|
||||
|
||||
# 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
|
||||
|
||||
+36
-19
@@ -125,30 +125,47 @@ design is optimal or that it will not change.
|
||||
## OTA update encryption
|
||||
|
||||
The `esphome` OTA platform optionally encrypts updates with the same Noise
|
||||
`NNpsk0` pattern the native API uses; one key protects the device. With an
|
||||
`encryption:` block configured the guarantees are: the firmware image is
|
||||
confidential in transit, the uploader is authenticated by the pre-shared key,
|
||||
and the plaintext negotiation preceding the handshake is bound into the
|
||||
handshake prologue, so stripping or tampering with it fails the first MAC.
|
||||
Both ends fail closed with no override: a device built with a key refuses
|
||||
`NNpsk0` pattern the native API uses; one key protects the device. A device
|
||||
whose `api:` block has an encryption key, static in the YAML or provisioned at
|
||||
runtime, compiles in the transport and offers it on every OTA connection once
|
||||
it holds a key, so an uploader presenting that key gets the guarantees below
|
||||
even without an `ota: encryption:` block; only that block makes the device
|
||||
require encryption. The guarantees are: the firmware image is confidential in
|
||||
transit, the uploader is authenticated by the pre-shared key, and the plaintext
|
||||
negotiation preceding the handshake is bound into the handshake prologue, so
|
||||
stripping or tampering with it fails the first MAC. With `ota: encryption:`
|
||||
configured both ends fail closed with no override: the device refuses
|
||||
plaintext uploads, and the CLI refuses to send plaintext when a key is
|
||||
configured.
|
||||
configured. Without that block the CLI tries a static api key when the device
|
||||
offers and, until 2027.3.0, falls back to plaintext with a warning when the
|
||||
offer is missing or the handshake fails; a runtime provisioned key never
|
||||
reaches the CLI, so those uploads stay plaintext.
|
||||
|
||||
Defeating any of that without the key is in scope: a keyed device accepting a
|
||||
plaintext or downgraded upload, getting past the MAC, or recovering image
|
||||
contents from captured traffic.
|
||||
Defeating any of that without the key is in scope: a device that requires
|
||||
encryption accepting a plaintext or downgraded upload, getting past the MAC,
|
||||
or recovering image contents from captured traffic.
|
||||
|
||||
The following are **not** vulnerabilities, by design:
|
||||
|
||||
- Plaintext OTA on a device with no `encryption:` block. That is the
|
||||
documented default, authenticated (if at all) by the OTA password.
|
||||
- The enablement window: turning encryption on takes one last upload of the
|
||||
encryption-enabled firmware over the existing plaintext channel, with the
|
||||
pre-existing plaintext exposure.
|
||||
- The web OTA `/update` endpoint alongside encryption. The `web_server`
|
||||
component keeps it always reachable, and `captive_portal:` auto-loads it
|
||||
for the fallback AP window; validation warns about both combinations, and
|
||||
the operator keeps the recovery path.
|
||||
- Plaintext OTA on a device with no `ota: encryption:` block, including one
|
||||
that offers encryption because it has an api key. That is the documented
|
||||
default, authenticated (if at all) by the OTA password. An uploader that
|
||||
takes the offer skips the password; the key authenticates it. With a
|
||||
runtime provisioned key and no `provisioning:` window, whoever provisions
|
||||
the key gains that upload path too; validation warns about the pair.
|
||||
- The CLI plaintext fallback until 2027.3.0: without `ota: encryption:` an
|
||||
active attacker who strips the offer or breaks the handshake can make a
|
||||
keyed CLI upload plaintext, with the pre-existing plaintext exposure. A
|
||||
device that requires encryption still refuses that upload.
|
||||
- The enablement window: firmware built with a static api key already offers
|
||||
encryption, so turning on `ota: encryption:` is itself an encrypted upload.
|
||||
Older firmware needs one last plaintext upload of an offering build, with
|
||||
the pre-existing plaintext exposure.
|
||||
- The web OTA `/update` endpoint alongside encryption. With the `web_server`
|
||||
or `prometheus` component the shared listener is always up, so the endpoint
|
||||
stays reachable and validation warns about that combination;
|
||||
`captive_portal:` alone brings the listener up only for the fallback AP
|
||||
window, which is the intended recovery path, so that is not warned about.
|
||||
- CLI retry behavior on transport or MAC failures; every attempt renegotiates
|
||||
a fresh handshake with fresh ephemerals, so retrying does not weaken
|
||||
authentication.
|
||||
|
||||
+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.13.1
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.9
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+13
-1
@@ -1335,12 +1335,14 @@ def _upload_via_native_api(
|
||||
break
|
||||
|
||||
from esphome import espota2
|
||||
from esphome.components.noise import static_encryption_key
|
||||
|
||||
remote_port = int(ota_conf[CONF_PORT])
|
||||
password = ota_conf.get(CONF_PASSWORD)
|
||||
# Fail closed: an encryption block whose key did not resolve must never
|
||||
# fall back to a plaintext upload
|
||||
noise_psk = None
|
||||
plaintext_fallback = False
|
||||
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||
noise_psk = encryption_conf.get(CONF_KEY)
|
||||
if not noise_psk:
|
||||
@@ -1351,6 +1353,10 @@ def _upload_via_native_api(
|
||||
# Ensure the key is a string, as required by the underlying OTA implementation.
|
||||
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
|
||||
noise_psk = str(noise_psk)
|
||||
elif api_key := static_encryption_key(config.get(CONF_API) or {}):
|
||||
# Remove before 2027.3.0: the api key is tried, falling back to plaintext
|
||||
noise_psk = str(api_key)
|
||||
plaintext_fallback = True
|
||||
|
||||
def check_partition_access(option_string: str) -> None:
|
||||
if not ota_conf.get("allow_partition_access"):
|
||||
@@ -1382,7 +1388,13 @@ def _upload_via_native_api(
|
||||
_validate_bootloader_binary(binary)
|
||||
|
||||
return espota2.run_ota(
|
||||
network_devices, remote_port, password, binary, ota_type, noise_psk
|
||||
network_devices,
|
||||
remote_port,
|
||||
password,
|
||||
binary,
|
||||
ota_type,
|
||||
noise_psk,
|
||||
plaintext_fallback=plaintext_fallback,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,9 +23,7 @@ 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__)
|
||||
|
||||
@@ -44,8 +44,7 @@ def get_arduino8266_tools_path() -> Path:
|
||||
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
|
||||
|
||||
|
||||
# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the
|
||||
# encoder below cannot name 3.0.0/3.0.1 either (see its docstring)
|
||||
# 3.1.1 rather than 3.1.0: the registry has no packages for 3.0.0, 3.0.1 or 3.1.0
|
||||
MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
|
||||
|
||||
|
||||
@@ -53,20 +52,16 @@ def framework_package_version(ver: Version) -> str:
|
||||
"""Map an Arduino core version to its registry package version (3.1.2 ->
|
||||
3.30102.0; the leading 3 is the package major).
|
||||
|
||||
Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor
|
||||
at MIN_FRAMEWORK_VERSION.
|
||||
Exact registry names for 3.x cores; callers floor at MIN_FRAMEWORK_VERSION.
|
||||
"""
|
||||
if ver.major > 3:
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} is not supported yet; "
|
||||
"the newest known core series is 3.x"
|
||||
)
|
||||
if ver <= Version(2, 6, 2):
|
||||
# Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same
|
||||
# boundary as _format_framework_arduino_version's era guard)
|
||||
if ver.major < 3:
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} uses an older package encoding than this "
|
||||
"helper implements (newer than 2.6.2)"
|
||||
f"Arduino core {ver} is not supported; ESPHome requires core 3.x"
|
||||
)
|
||||
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ void Anova::dump_config() { LOG_CLIMATE("", "Anova BLE Cooker", this); }
|
||||
|
||||
void Anova::setup() {
|
||||
this->codec_ = make_unique<AnovaCodec>();
|
||||
this->current_request_ = 0;
|
||||
this->poll_step_ = PollStep::IDLE;
|
||||
}
|
||||
|
||||
void Anova::loop() {
|
||||
@@ -22,6 +22,15 @@ void Anova::loop() {
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void Anova::write_request_(AnovaPacket *pkt) {
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
|
||||
void Anova::control(const ClimateCall &call) {
|
||||
auto mode_val = call.get_mode();
|
||||
if (mode_val.has_value()) {
|
||||
@@ -38,22 +47,11 @@ void Anova::control(const ClimateCall &call) {
|
||||
ESP_LOGW(TAG, "Unsupported mode: %d", mode);
|
||||
return;
|
||||
}
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
this->write_request_(pkt);
|
||||
}
|
||||
auto target_temp = call.get_target_temperature();
|
||||
if (target_temp.has_value()) {
|
||||
auto *pkt = this->codec_->get_set_target_temp_request(*target_temp);
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
this->write_request_(this->codec_->get_set_target_temp_request(*target_temp));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +60,7 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
|
||||
case ESP_GATTC_DISCONNECT_EVT: {
|
||||
this->current_temperature = NAN;
|
||||
this->target_temperature = NAN;
|
||||
this->poll_step_ = PollStep::IDLE;
|
||||
this->publish_state();
|
||||
break;
|
||||
}
|
||||
@@ -83,8 +82,8 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
|
||||
}
|
||||
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
this->current_request_ = 0;
|
||||
this->update();
|
||||
this->poll_step_ = PollStep::IDLE;
|
||||
this->update(); // begin the first poll cycle immediately
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_NOTIFY_EVT: {
|
||||
@@ -101,33 +100,30 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
|
||||
this->mode = this->codec_->running_ ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_OFF;
|
||||
}
|
||||
if (this->codec_->has_unit()) {
|
||||
this->fahrenheit_ = (this->codec_->unit_ == 'f');
|
||||
ESP_LOGD(TAG, "Anova units is %s", this->fahrenheit_ ? "fahrenheit" : "celsius");
|
||||
this->current_request_++;
|
||||
ESP_LOGD(TAG, "Anova units is %s", (this->codec_->unit_ == 'f') ? "fahrenheit" : "celsius");
|
||||
}
|
||||
this->publish_state();
|
||||
|
||||
if (this->current_request_ > 1) {
|
||||
AnovaPacket *pkt = nullptr;
|
||||
switch (this->current_request_++) {
|
||||
case 2:
|
||||
pkt = this->codec_->get_read_target_temp_request();
|
||||
break;
|
||||
case 3:
|
||||
pkt = this->codec_->get_read_current_temp_request();
|
||||
break;
|
||||
default:
|
||||
this->current_request_ = 1;
|
||||
break;
|
||||
}
|
||||
if (pkt != nullptr) {
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
// Advance the poll cycle to its next request based on the reply we got.
|
||||
switch (this->poll_step_) {
|
||||
case PollStep::SET_UNIT:
|
||||
this->poll_step_ = PollStep::STATUS;
|
||||
this->write_request_(this->codec_->get_read_device_status_request());
|
||||
break;
|
||||
case PollStep::STATUS:
|
||||
this->poll_step_ = PollStep::TARGET;
|
||||
this->write_request_(this->codec_->get_read_target_temp_request());
|
||||
break;
|
||||
case PollStep::TARGET:
|
||||
this->poll_step_ = PollStep::CURRENT;
|
||||
this->write_request_(this->codec_->get_read_current_temp_request());
|
||||
break;
|
||||
case PollStep::CURRENT:
|
||||
this->poll_step_ = PollStep::IDLE; // full cycle complete
|
||||
break;
|
||||
default:
|
||||
// A reply to an ad-hoc control() write, outside a managed cycle.
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -136,27 +132,26 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
|
||||
}
|
||||
}
|
||||
|
||||
void Anova::set_unit_of_measurement(const char *unit) { this->fahrenheit_ = !strncmp(unit, "f", 1); }
|
||||
void Anova::set_unit_of_measurement(const char *unit) { this->want_fahrenheit_ = !strncmp(unit, "f", 1); }
|
||||
|
||||
void Anova::update() {
|
||||
if (this->node_state != espbt::ClientState::ESTABLISHED)
|
||||
return;
|
||||
|
||||
if (this->current_request_ < 2) {
|
||||
AnovaPacket *pkt;
|
||||
if (this->current_request_ == 0) {
|
||||
pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c');
|
||||
} else {
|
||||
pkt = this->codec_->get_read_device_status_request();
|
||||
}
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
this->current_request_++;
|
||||
if (this->poll_step_ != PollStep::IDLE) {
|
||||
// The previous cycle never finished within a full polling interval -- a
|
||||
// reply was missed or a write failed. Restart the cycle rather than stall;
|
||||
// the polling interval itself acts as the timeout. A late reply from the
|
||||
// abandoned cycle is harmless: state decoding happens on every notify
|
||||
// regardless of step, and each notify sends at most one follow-up request.
|
||||
ESP_LOGW(TAG, "[%s] Poll cycle incomplete (step %u); restarting cycle", this->parent_->address_str(),
|
||||
static_cast<uint8_t>(this->poll_step_));
|
||||
}
|
||||
// Re-assert the configured unit at the start of every poll cycle, then fall
|
||||
// through the status/temperature reads via the notification handler. Always
|
||||
// command the configured unit (want_fahrenheit_) -- never the last value the
|
||||
// device reported, or a drift to 'c' would lock itself in.
|
||||
this->poll_step_ = PollStep::SET_UNIT;
|
||||
this->write_request_(this->codec_->get_set_unit_request(this->want_fahrenheit_ ? 'f' : 'c'));
|
||||
}
|
||||
|
||||
} // namespace esphome::anova
|
||||
|
||||
@@ -37,11 +37,20 @@ class Anova final : public climate::Climate, public esphome::ble_client::BLEClie
|
||||
void set_unit_of_measurement(const char *unit);
|
||||
|
||||
protected:
|
||||
// A poll cycle re-asserts the configured unit, then reads device state.
|
||||
// Re-asserting every cycle prevents the cooker from silently reverting to
|
||||
// its default (Celsius); previously the unit was only set once on
|
||||
// connection, so a drift persisted (and corrupted the F/C interpretation of
|
||||
// subsequent readings) until the BLE link was re-established.
|
||||
enum class PollStep : uint8_t { SET_UNIT, STATUS, TARGET, CURRENT, IDLE };
|
||||
|
||||
void write_request_(AnovaPacket *pkt);
|
||||
|
||||
std::unique_ptr<AnovaCodec> codec_;
|
||||
void control(const climate::ClimateCall &call) override;
|
||||
uint16_t char_handle_;
|
||||
uint8_t current_request_;
|
||||
bool fahrenheit_;
|
||||
bool want_fahrenheit_{true}; // configured target unit; never overwritten by device replies
|
||||
PollStep poll_step_{PollStep::IDLE};
|
||||
};
|
||||
|
||||
} // namespace esphome::anova
|
||||
|
||||
@@ -14,6 +14,7 @@ from esphome.components.noise import ( # noqa: F401
|
||||
ENCRYPTION_SCHEMA,
|
||||
decode_encryption_key,
|
||||
encryption_schema,
|
||||
new_psk_progmem,
|
||||
validate_encryption_key,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_defines, get_logger_level
|
||||
@@ -349,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
|
||||
@@ -589,8 +589,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
|
||||
if key := encryption_config.get(CONF_KEY):
|
||||
decoded = decode_encryption_key(key)
|
||||
cg.add(var.set_noise_psk(list(decoded)))
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
|
||||
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
||||
else:
|
||||
# No key provided, but encryption desired
|
||||
|
||||
@@ -77,6 +77,7 @@ service APIConnection {
|
||||
rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {}
|
||||
rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {}
|
||||
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
|
||||
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -2726,7 +2727,8 @@ enum SerialProxyParity {
|
||||
SERIAL_PROXY_PARITY_ODD = 2;
|
||||
}
|
||||
|
||||
// Configure UART parameters for a serial proxy instance
|
||||
// Configure UART parameters for a serial proxy instance. Only the subscribed client may
|
||||
// configure the port; others are refused with PORT_IN_USE (since API 1.17).
|
||||
message SerialProxyConfigureRequest {
|
||||
option (id) = 138;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2752,7 +2754,8 @@ message SerialProxyDataReceived {
|
||||
bytes data = 2; // Raw data received from the serial device
|
||||
}
|
||||
|
||||
// Write data to a serial device
|
||||
// Write data to a serial device. Only the subscribed client may write; writes from
|
||||
// others are ignored (since API 1.17).
|
||||
message SerialProxyWriteRequest {
|
||||
option (id) = 140;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2763,7 +2766,8 @@ message SerialProxyWriteRequest {
|
||||
bytes data = 2; // Raw data to write to the serial device
|
||||
}
|
||||
|
||||
// Set modem control pin states (RTS and DTR)
|
||||
// Set modem control pin states (RTS and DTR). Only the subscribed client may set them;
|
||||
// others are refused with PORT_IN_USE (since API 1.17).
|
||||
message SerialProxySetModemPinsRequest {
|
||||
option (id) = 141;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2802,6 +2806,7 @@ enum SerialProxyRequestType {
|
||||
// error the device answers with INVALID_ARGUMENT.
|
||||
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17)
|
||||
}
|
||||
|
||||
enum SerialProxyStatus {
|
||||
@@ -2814,7 +2819,8 @@ enum SerialProxyStatus {
|
||||
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
|
||||
}
|
||||
|
||||
// Generic request message for simple serial proxy operations
|
||||
// Generic request message for simple serial proxy operations. FLUSH requires an active
|
||||
// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17).
|
||||
message SerialProxyRequest {
|
||||
option (id) = 144;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2838,6 +2844,29 @@ message SerialProxyRequestResponse {
|
||||
string error_message = 4; // Additional detail on failure (optional)
|
||||
}
|
||||
|
||||
// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL
|
||||
// activates the port's protocol-aware tap (if one is configured), letting it observe
|
||||
// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap
|
||||
// speaks is a property of the device configuration, discoverable from the tap
|
||||
// component's own API surface. A client that is about to flash firmware selects RAW
|
||||
// first, which definitively disables that injection.
|
||||
enum SerialProxyMode {
|
||||
SERIAL_PROXY_MODE_RAW = 0;
|
||||
SERIAL_PROXY_MODE_PROTOCOL = 1;
|
||||
}
|
||||
|
||||
// Only the subscribed client may change the mode; any other caller -- including one that
|
||||
// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED
|
||||
// when the port has no protocol-aware tap configured.
|
||||
message SerialProxySetModeRequest {
|
||||
option (id) = 152;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
|
||||
uint32 instance = 1;
|
||||
SerialProxyMode mode = 2;
|
||||
}
|
||||
|
||||
// ==================== BLUETOOTH CONNECTION PARAMS ====================
|
||||
message BluetoothSetConnectionParamsRequest {
|
||||
option (id) = 145;
|
||||
|
||||
@@ -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");
|
||||
@@ -1661,6 +1664,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
break;
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE:
|
||||
// Response-only discriminators; never valid in a request
|
||||
ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
|
||||
status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
|
||||
@@ -1673,6 +1677,19 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
send_serial_proxy_ack(this, msg.instance, msg.type, status);
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE,
|
||||
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
|
||||
return;
|
||||
}
|
||||
serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode);
|
||||
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE,
|
||||
serial_proxy_result_to_status(result));
|
||||
}
|
||||
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
|
||||
@@ -1799,7 +1816,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
|
||||
HelloResponse resp;
|
||||
resp.api_version_major = 1;
|
||||
resp.api_version_minor = 16;
|
||||
resp.api_version_minor = 17;
|
||||
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
|
||||
resp.server_info = ESPHOME_VERSION_REF;
|
||||
resp.name = StringRef(App.get_name());
|
||||
@@ -2161,7 +2178,10 @@ void APIConnection::on_homeassistant_action_response(const HomeassistantActionRe
|
||||
bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg) {
|
||||
NoiseEncryptionSetKeyResponse resp;
|
||||
resp.success = false;
|
||||
|
||||
#ifdef USE_API_NOISE_PSK_FROM_YAML
|
||||
// A yaml key cannot be changed at runtime, so no decode or save path is built
|
||||
ESP_LOGW(TAG, "Key set in YAML");
|
||||
#else
|
||||
#ifdef USE_PROVISIONING
|
||||
// Refuse to set a key once the provisioning window has closed (defense in depth;
|
||||
// such connections are already rejected at hello).
|
||||
@@ -2196,6 +2216,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif // USE_API_NOISE_PSK_FROM_YAML
|
||||
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
@@ -244,6 +244,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
|
||||
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
|
||||
void on_serial_proxy_request(const SerialProxyRequest &msg);
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg);
|
||||
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
|
||||
#endif
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin
|
||||
return APIError::OK;
|
||||
|
||||
// Queue unsent data into overflow buffer
|
||||
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent))) {
|
||||
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, sent)) {
|
||||
HELPER_LOG("Overflow buffer full or out of memory, dropping connection");
|
||||
this->state_ = State::FAILED;
|
||||
return APIError::SOCKET_WRITE_FAILED;
|
||||
|
||||
@@ -219,7 +219,10 @@ class APIFrameHelper {
|
||||
if (this->rx_buf_len_ == 0) {
|
||||
this->rx_buf_.release();
|
||||
}
|
||||
this->release_overflow_buffer();
|
||||
}
|
||||
// Free the send backlog storage once it has drained
|
||||
void release_overflow_buffer() { this->overflow_buf_.release(); }
|
||||
|
||||
protected:
|
||||
// Drain backlogged overflow data to the socket and handle errors.
|
||||
|
||||
@@ -67,15 +67,15 @@ APIError APINoiseFrameHelper::init() {
|
||||
}
|
||||
|
||||
// init prologue
|
||||
size_t old_size = prologue_.size();
|
||||
if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] {
|
||||
uint8_t *dst = prologue_.append(PROLOGUE_INIT_LEN);
|
||||
if (dst == nullptr) [[unlikely]] {
|
||||
state_ = State::FAILED;
|
||||
return APIError::OUT_OF_MEMORY;
|
||||
}
|
||||
#ifdef USE_ESP8266
|
||||
memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
|
||||
memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
|
||||
#else
|
||||
std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
|
||||
std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
|
||||
#endif
|
||||
|
||||
state_ = State::CLIENT_HELLO;
|
||||
@@ -272,17 +272,17 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
return handle_handshake_frame_error_(aerr);
|
||||
}
|
||||
// ignore contents, may be used in future for flags
|
||||
// Resize for: existing prologue + 2 size bytes + frame data
|
||||
size_t old_size = this->prologue_.size();
|
||||
// Append 2 size bytes + frame data to the prologue
|
||||
size_t rx_size = this->rx_buf_.size();
|
||||
if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] {
|
||||
uint8_t *dst = this->prologue_.append(2 + rx_size);
|
||||
if (dst == nullptr) [[unlikely]] {
|
||||
state_ = State::FAILED;
|
||||
return APIError::OUT_OF_MEMORY;
|
||||
}
|
||||
this->prologue_[old_size] = (uint8_t) (rx_size >> 8);
|
||||
this->prologue_[old_size + 1] = (uint8_t) rx_size;
|
||||
dst[0] = (uint8_t) (rx_size >> 8);
|
||||
dst[1] = (uint8_t) rx_size;
|
||||
if (rx_size > 0) {
|
||||
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size);
|
||||
std::memcpy(dst + 2, this->rx_buf_.data(), rx_size);
|
||||
}
|
||||
|
||||
state_ = State::SERVER_HELLO;
|
||||
@@ -548,7 +548,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
||||
* @return 0 on success, -1 on error (check errno)
|
||||
*/
|
||||
APIError APINoiseFrameHelper::init_handshake_() {
|
||||
int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size());
|
||||
int err = this->handshake_.init(this->ctx_, prologue_.data(), prologue_.size());
|
||||
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4253,6 +4253,19 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_length(1, this->error_message.size());
|
||||
return size;
|
||||
}
|
||||
bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
this->instance = value;
|
||||
break;
|
||||
case 2:
|
||||
this->mode = static_cast<enums::SerialProxyMode>(value);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
|
||||
@@ -356,6 +356,7 @@ enum SerialProxyRequestType : uint32_t {
|
||||
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2,
|
||||
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3,
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4,
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5,
|
||||
};
|
||||
enum SerialProxyStatus : uint32_t {
|
||||
SERIAL_PROXY_STATUS_OK = 0,
|
||||
@@ -366,6 +367,10 @@ enum SerialProxyStatus : uint32_t {
|
||||
SERIAL_PROXY_STATUS_PORT_IN_USE = 5,
|
||||
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6,
|
||||
};
|
||||
enum SerialProxyMode : uint32_t {
|
||||
SERIAL_PROXY_MODE_RAW = 0,
|
||||
SERIAL_PROXY_MODE_PROTOCOL = 1,
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace enums
|
||||
@@ -3403,6 +3408,22 @@ class SerialProxyRequestResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
class SerialProxySetModeRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 152;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 6;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); }
|
||||
#endif
|
||||
uint32_t instance{0};
|
||||
enums::SerialProxyMode mode{};
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
|
||||
|
||||
@@ -854,6 +854,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyRequestType>(enums
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE");
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS");
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
@@ -878,6 +880,16 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::SerialProxyMode value) {
|
||||
switch (value) {
|
||||
case enums::SERIAL_PROXY_MODE_RAW:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW");
|
||||
case enums::SERIAL_PROXY_MODE_PROTOCOL:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
const char *HelloRequest::dump_to(DumpBuffer &out) const {
|
||||
@@ -2805,6 +2817,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("error_message"), this->error_message);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
|
||||
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
|
||||
|
||||
@@ -712,6 +712,17 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
this->on_device_capabilities_request();
|
||||
break;
|
||||
}
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
case SerialProxySetModeRequest::MESSAGE_TYPE: {
|
||||
SerialProxySetModeRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg);
|
||||
#endif
|
||||
this->on_serial_proxy_set_mode_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -235,6 +235,9 @@ class APIServerConnectionBase {
|
||||
void on_serial_proxy_request(const SerialProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
|
||||
#endif
|
||||
|
||||
@@ -41,13 +41,13 @@ void APIServer::setup() {
|
||||
ControllerRegistry::register_controller(this);
|
||||
|
||||
#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);
|
||||
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
// Only load saved PSK if not set from YAML
|
||||
if (this->load_and_apply_noise_psk_()) {
|
||||
// A cleared record loads fine but holds no key
|
||||
if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) {
|
||||
ESP_LOGD(TAG, "Loaded saved Noise PSK");
|
||||
}
|
||||
#endif
|
||||
@@ -550,6 +550,7 @@ const std::vector<APIServer::HomeAssistantStateSubscription> &APIServer::get_sta
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg,
|
||||
const LogString *fail_log_msg, bool make_active) {
|
||||
if (!this->noise_pref_.save(&new_psk)) {
|
||||
@@ -583,22 +584,19 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
||||
}
|
||||
|
||||
bool APIServer::load_and_apply_noise_psk_() {
|
||||
SavedNoisePsk saved{};
|
||||
if (!this->noise_pref_.load(&saved))
|
||||
// Load into a temp so a failed read cannot disturb the key in use
|
||||
SavedNoisePsk loaded{};
|
||||
if (!this->noise_pref_.load(&loaded))
|
||||
return false;
|
||||
this->set_noise_psk(saved.psk);
|
||||
this->saved_psk_ = loaded;
|
||||
// An unprovisioned device stores the reserved all-zeros key, which is no key
|
||||
const bool has_key = !noise::NoiseContext::is_all_zeros(this->saved_psk_.psk);
|
||||
this->noise_ctx_.set_psk(has_key ? this->saved_psk_.psk.data() : nullptr);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
|
||||
#ifdef USE_API_NOISE_PSK_FROM_YAML
|
||||
// When PSK is set from YAML, this function should never be called
|
||||
// but if it is, reject the change
|
||||
ESP_LOGW(TAG, "Key set in YAML");
|
||||
return false;
|
||||
#else
|
||||
auto &old_psk = this->noise_ctx_.get_psk();
|
||||
if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) {
|
||||
if (this->saved_psk_.psk == psk) {
|
||||
ESP_LOGW(TAG, "New PSK matches old");
|
||||
return true;
|
||||
}
|
||||
@@ -614,15 +612,8 @@ bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
|
||||
}
|
||||
#endif
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
bool APIServer::clear_noise_psk(bool make_active) {
|
||||
#ifdef USE_API_NOISE_PSK_FROM_YAML
|
||||
// When PSK is set from YAML, this function should never be called
|
||||
// but if it is, reject the change
|
||||
ESP_LOGW(TAG, "Key set in YAML");
|
||||
return false;
|
||||
#else
|
||||
SavedNoisePsk empty_psk{};
|
||||
bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
|
||||
make_active);
|
||||
@@ -634,8 +625,8 @@ bool APIServer::clear_noise_psk(bool make_active) {
|
||||
}
|
||||
#endif
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
#endif // USE_API_NOISE_PSK_FROM_YAML
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
|
||||
@@ -76,9 +76,14 @@ class APIServer final : public Component,
|
||||
APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; }
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
// Runtime key changes exist for the provisioning path only (not lambdas);
|
||||
// with a yaml key they compile out
|
||||
bool save_noise_psk(noise::psk_t psk, bool make_active = true);
|
||||
bool clear_noise_psk(bool make_active = true);
|
||||
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
|
||||
#endif
|
||||
/// 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); }
|
||||
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
|
||||
#endif // USE_API_NOISE
|
||||
|
||||
@@ -275,10 +280,12 @@ class APIServer final : public Component,
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
|
||||
bool make_active);
|
||||
// Load saved PSK from preferences and apply it. Returns true on success.
|
||||
bool load_and_apply_noise_psk_();
|
||||
#endif // USE_API_NOISE_PSK_FROM_YAML
|
||||
#endif // USE_API_NOISE
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
// Helper methods to reduce code duplication
|
||||
@@ -358,6 +365,9 @@ class APIServer final : public Component,
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
noise::NoiseContext noise_ctx_;
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key
|
||||
#endif
|
||||
ESPPreferenceObject noise_pref_;
|
||||
#endif // USE_API_NOISE
|
||||
};
|
||||
|
||||
@@ -9,6 +9,10 @@ namespace esphome::atm90e32 {
|
||||
|
||||
static const char *const TAG = "atm90e32";
|
||||
|
||||
static const LogString *offset_calibration_name(bool power_offsets) {
|
||||
return power_offsets ? LOG_STR("Power offset") : LOG_STR("Offset");
|
||||
}
|
||||
|
||||
static uint32_t pref_hash(const char *prefix, const char *name_space) {
|
||||
auto hash = fnv1_hash(prefix);
|
||||
return fnv1_hash_extend(hash, name_space);
|
||||
@@ -203,13 +207,12 @@ void ATM90E32Component::setup() {
|
||||
|
||||
// Initialize flash storage for power offset calibrations
|
||||
uint32_t po_hash = pref_hash("_power_offset_calibration_", cs);
|
||||
this->power_offset_pref_ = global_preferences->make_preference<PowerOffsetCalibration[3]>(po_hash, true);
|
||||
this->power_offset_pref_ = global_preferences->make_preference<OffsetCalibration[3]>(po_hash, true);
|
||||
bool migrated_power_offset = false;
|
||||
if (has_distinct_legacy_namespace) {
|
||||
uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs);
|
||||
auto legacy_power_offset_pref =
|
||||
global_preferences->make_preference<PowerOffsetCalibration[3]>(legacy_po_hash, true);
|
||||
PowerOffsetCalibration power_offset_data[3]{};
|
||||
auto legacy_power_offset_pref = global_preferences->make_preference<OffsetCalibration[3]>(legacy_po_hash, true);
|
||||
OffsetCalibration power_offset_data[3]{};
|
||||
int migration_status =
|
||||
migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data);
|
||||
migrated_power_offset = migration_status > 0;
|
||||
@@ -224,20 +227,20 @@ void ATM90E32Component::setup() {
|
||||
global_preferences->sync();
|
||||
}
|
||||
|
||||
this->restore_offset_calibrations_();
|
||||
this->restore_power_offset_calibrations_();
|
||||
this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
|
||||
this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.",
|
||||
cs);
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
this->write16_(this->voltage_offset_registers[phase],
|
||||
static_cast<uint16_t>(this->offset_phase_[phase].voltage_offset_));
|
||||
static_cast<uint16_t>(this->offset_phase_[phase].first_offset));
|
||||
this->write16_(this->current_offset_registers[phase],
|
||||
static_cast<uint16_t>(this->offset_phase_[phase].current_offset_));
|
||||
static_cast<uint16_t>(this->offset_phase_[phase].second_offset));
|
||||
this->write16_(this->power_offset_registers[phase],
|
||||
static_cast<uint16_t>(this->power_offset_phase_[phase].active_power_offset));
|
||||
static_cast<uint16_t>(this->power_offset_phase_[phase].first_offset));
|
||||
this->write16_(this->reactive_power_offset_registers[phase],
|
||||
static_cast<uint16_t>(this->power_offset_phase_[phase].reactive_power_offset));
|
||||
static_cast<uint16_t>(this->power_offset_phase_[phase].second_offset));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,8 +320,8 @@ void ATM90E32Component::log_calibration_status_() {
|
||||
cs);
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase,
|
||||
this->config_offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].voltage_offset_,
|
||||
this->config_offset_phase_[phase].current_offset_, this->offset_phase_[phase].current_offset_);
|
||||
this->config_offset_phase_[phase].first_offset, this->offset_phase_[phase].first_offset,
|
||||
this->config_offset_phase_[phase].second_offset, this->offset_phase_[phase].second_offset);
|
||||
}
|
||||
ESP_LOGW(TAG,
|
||||
"[CALIBRATION][%s] ===============================================================================", cs);
|
||||
@@ -335,10 +338,8 @@ void ATM90E32Component::log_calibration_status_() {
|
||||
cs);
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase,
|
||||
this->config_power_offset_phase_[phase].active_power_offset,
|
||||
this->power_offset_phase_[phase].active_power_offset,
|
||||
this->config_power_offset_phase_[phase].reactive_power_offset,
|
||||
this->power_offset_phase_[phase].reactive_power_offset);
|
||||
this->config_power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].first_offset,
|
||||
this->config_power_offset_phase_[phase].second_offset, this->power_offset_phase_[phase].second_offset);
|
||||
}
|
||||
ESP_LOGW(TAG,
|
||||
"[CALIBRATION][%s] ===============================================================================", cs);
|
||||
@@ -372,7 +373,7 @@ void ATM90E32Component::log_calibration_status_() {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
|
||||
this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_);
|
||||
this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\\n", cs);
|
||||
}
|
||||
@@ -385,8 +386,7 @@ void ATM90E32Component::log_calibration_status_() {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
|
||||
this->power_offset_phase_[phase].active_power_offset,
|
||||
this->power_offset_phase_[phase].reactive_power_offset);
|
||||
this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
|
||||
}
|
||||
@@ -756,36 +756,68 @@ void ATM90E32Component::save_gain_calibration_to_memory_() {
|
||||
}
|
||||
}
|
||||
|
||||
void ATM90E32Component::save_offset_calibration_to_memory_() {
|
||||
void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored,
|
||||
bool previous_using_saved, OffsetCalibrationType type) {
|
||||
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
|
||||
const char *cs = this->get_calibration_id_();
|
||||
bool success = this->offset_pref_.save(&this->offset_phase_);
|
||||
global_preferences->sync();
|
||||
if (success) {
|
||||
this->using_saved_calibrations_ = true;
|
||||
this->restored_offset_calibration_ = true;
|
||||
for (bool &phase : this->offset_calibration_mismatch_)
|
||||
phase = false;
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] Offset calibration saved to memory.", cs);
|
||||
} else {
|
||||
this->using_saved_calibrations_ = false;
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save offset calibration to memory!", cs);
|
||||
}
|
||||
}
|
||||
const LogString *name = offset_calibration_name(power_offsets);
|
||||
OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_;
|
||||
ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_;
|
||||
bool *has_stored =
|
||||
power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_;
|
||||
bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_;
|
||||
bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_;
|
||||
|
||||
void ATM90E32Component::save_power_offset_calibration_to_memory_() {
|
||||
const char *cs = this->get_calibration_id_();
|
||||
bool success = this->power_offset_pref_.save(&this->power_offset_phase_);
|
||||
global_preferences->sync();
|
||||
if (success) {
|
||||
this->using_saved_calibrations_ = true;
|
||||
this->restored_power_offset_calibration_ = true;
|
||||
for (bool &phase : this->power_offset_calibration_mismatch_)
|
||||
phase = false;
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] Power offset calibration saved to memory.", cs);
|
||||
} else {
|
||||
this->using_saved_calibrations_ = false;
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save power offset calibration to memory!", cs);
|
||||
const bool writes_verified = this->verify_offset_writes_(type);
|
||||
bool saved = false;
|
||||
bool synced = false;
|
||||
if (writes_verified) {
|
||||
saved = preference->save(offsets);
|
||||
synced = global_preferences->sync();
|
||||
}
|
||||
|
||||
if (writes_verified && saved && synced) {
|
||||
this->using_saved_calibrations_ = true;
|
||||
*has_stored = true;
|
||||
*restored = true;
|
||||
for (uint8_t phase = 0; phase < 3; phase++)
|
||||
mismatches[phase] = false;
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration saved to memory. %s calibration completed and verified.", cs,
|
||||
LOG_STR_ARG(name), LOG_STR_ARG(name));
|
||||
return;
|
||||
}
|
||||
|
||||
if (writes_verified) {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save %s calibration to memory!", cs, LOG_STR_ARG(name));
|
||||
}
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
this->write_offsets_to_registers_(phase, previous[phase].first_offset, previous[phase].second_offset, type);
|
||||
}
|
||||
const bool rollback_verified = this->verify_offset_writes_(type);
|
||||
|
||||
bool rollback_persisted = false;
|
||||
if (writes_verified) {
|
||||
OffsetCalibration rollback[3]{};
|
||||
prepare_offset_rollback(previous, previous_restored, rollback);
|
||||
const bool rollback_saved = preference->save(&rollback);
|
||||
const bool rollback_synced = global_preferences->sync();
|
||||
rollback_persisted = rollback_saved && rollback_synced;
|
||||
if (!rollback_saved || !rollback_synced) {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to persist restored %s calibration values!", cs, LOG_STR_ARG(name));
|
||||
}
|
||||
}
|
||||
|
||||
*restored = previous_restored;
|
||||
if (rollback_persisted)
|
||||
*has_stored = previous_restored;
|
||||
this->using_saved_calibrations_ = previous_using_saved;
|
||||
if (!rollback_verified) {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; rollback readback verification failed.", cs,
|
||||
LOG_STR_ARG(name));
|
||||
return;
|
||||
}
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; previous values restored.", cs, LOG_STR_ARG(name));
|
||||
}
|
||||
|
||||
void ATM90E32Component::run_offset_calibrations() {
|
||||
@@ -803,11 +835,16 @@ void ATM90E32Component::run_offset_calibrations() {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs);
|
||||
|
||||
OffsetCalibration previous_offsets[3] = {this->offset_phase_[0], this->offset_phase_[1], this->offset_phase_[2]};
|
||||
const bool previous_restored = this->restored_offset_calibration_;
|
||||
const bool previous_using_saved = this->using_saved_calibrations_;
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
int16_t voltage_offset = calibrate_offset(phase, true);
|
||||
int16_t current_offset = calibrate_offset(phase, false);
|
||||
|
||||
this->write_offsets_to_registers_(phase, voltage_offset, current_offset);
|
||||
this->write_offsets_to_registers_(phase, voltage_offset, current_offset,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
|
||||
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset,
|
||||
current_offset);
|
||||
@@ -815,7 +852,8 @@ void ATM90E32Component::run_offset_calibrations() {
|
||||
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ==================================================================\n", cs);
|
||||
|
||||
this->save_offset_calibration_to_memory_();
|
||||
this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
|
||||
}
|
||||
|
||||
void ATM90E32Component::run_power_offset_calibrations() {
|
||||
@@ -834,18 +872,25 @@ void ATM90E32Component::run_power_offset_calibrations() {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
|
||||
|
||||
OffsetCalibration previous_offsets[3] = {this->power_offset_phase_[0], this->power_offset_phase_[1],
|
||||
this->power_offset_phase_[2]};
|
||||
const bool previous_restored = this->restored_power_offset_calibration_;
|
||||
const bool previous_using_saved = this->using_saved_calibrations_;
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
int16_t active_offset = calibrate_power_offset(phase, false);
|
||||
int16_t reactive_offset = calibrate_power_offset(phase, true);
|
||||
|
||||
this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset);
|
||||
this->write_offsets_to_registers_(phase, active_offset, reactive_offset,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
|
||||
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset,
|
||||
reactive_offset);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
|
||||
|
||||
this->save_power_offset_calibration_to_memory_();
|
||||
this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
|
||||
}
|
||||
|
||||
void ATM90E32Component::write_gains_to_registers_() {
|
||||
@@ -859,35 +904,26 @@ void ATM90E32Component::write_gains_to_registers_() {
|
||||
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000);
|
||||
}
|
||||
|
||||
void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset) {
|
||||
// Save to runtime
|
||||
this->offset_phase_[phase].voltage_offset_ = voltage_offset;
|
||||
this->phase_[phase].voltage_offset_ = voltage_offset;
|
||||
void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset,
|
||||
OffsetCalibrationType type) {
|
||||
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
|
||||
OffsetCalibration &offsets = power_offsets ? this->power_offset_phase_[phase] : this->offset_phase_[phase];
|
||||
offsets.first_offset = first_offset;
|
||||
offsets.second_offset = second_offset;
|
||||
if (power_offsets) {
|
||||
this->phase_[phase].active_power_offset_ = first_offset;
|
||||
this->phase_[phase].reactive_power_offset_ = second_offset;
|
||||
} else {
|
||||
this->phase_[phase].voltage_offset_ = first_offset;
|
||||
this->phase_[phase].current_offset_ = second_offset;
|
||||
}
|
||||
|
||||
// Save to flash-storable struct
|
||||
this->offset_phase_[phase].current_offset_ = current_offset;
|
||||
this->phase_[phase].current_offset_ = current_offset;
|
||||
|
||||
// Write to registers
|
||||
const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers;
|
||||
const uint16_t *second_registers =
|
||||
power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers;
|
||||
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA);
|
||||
this->write16_(voltage_offset_registers[phase], static_cast<uint16_t>(voltage_offset));
|
||||
this->write16_(current_offset_registers[phase], static_cast<uint16_t>(current_offset));
|
||||
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000);
|
||||
}
|
||||
|
||||
void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset) {
|
||||
// Save to runtime
|
||||
this->phase_[phase].active_power_offset_ = p_offset;
|
||||
this->phase_[phase].reactive_power_offset_ = q_offset;
|
||||
|
||||
// Save to flash-storable struct
|
||||
this->power_offset_phase_[phase].active_power_offset = p_offset;
|
||||
this->power_offset_phase_[phase].reactive_power_offset = q_offset;
|
||||
|
||||
// Write to registers
|
||||
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA);
|
||||
this->write16_(this->power_offset_registers[phase], static_cast<uint16_t>(p_offset));
|
||||
this->write16_(this->reactive_power_offset_registers[phase], static_cast<uint16_t>(q_offset));
|
||||
this->write16_(first_registers[phase], static_cast<uint16_t>(first_offset));
|
||||
this->write16_(second_registers[phase], static_cast<uint16_t>(second_offset));
|
||||
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000);
|
||||
}
|
||||
|
||||
@@ -947,89 +983,78 @@ void ATM90E32Component::restore_gain_calibrations_() {
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs);
|
||||
}
|
||||
|
||||
void ATM90E32Component::restore_offset_calibrations_() {
|
||||
void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) {
|
||||
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
|
||||
const char *cs = this->get_calibration_id_();
|
||||
const LogString *name = power_offsets ? LOG_STR("power offset") : LOG_STR("offset");
|
||||
OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_;
|
||||
OffsetCalibration(*config_offsets)[3] =
|
||||
power_offsets ? &this->config_power_offset_phase_ : &this->config_offset_phase_;
|
||||
ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_;
|
||||
bool *has_stored =
|
||||
power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_;
|
||||
bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_;
|
||||
bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_;
|
||||
const bool *has_first = power_offsets ? this->has_config_active_power_offset_ : this->has_config_voltage_offset_;
|
||||
const bool *has_second = power_offsets ? this->has_config_reactive_power_offset_ : this->has_config_current_offset_;
|
||||
|
||||
for (uint8_t i = 0; i < 3; ++i)
|
||||
this->config_offset_phase_[i] = this->offset_phase_[i];
|
||||
|
||||
bool have_data = this->offset_pref_.load(&this->offset_phase_);
|
||||
(*config_offsets)[i] = (*offsets)[i];
|
||||
|
||||
const bool have_data = preference->load(offsets);
|
||||
bool all_zero = true;
|
||||
if (have_data) {
|
||||
for (auto &phase : this->offset_phase_) {
|
||||
if (phase.voltage_offset_ != 0 || phase.current_offset_ != 0) {
|
||||
for (const auto &phase : *offsets) {
|
||||
if (phase.first_offset != 0 || phase.second_offset != 0) {
|
||||
all_zero = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (have_data && !all_zero) {
|
||||
this->restored_offset_calibration_ = true;
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
auto &offset = this->offset_phase_[phase];
|
||||
bool mismatch = false;
|
||||
if (this->has_config_voltage_offset_[phase] &&
|
||||
offset.voltage_offset_ != this->config_offset_phase_[phase].voltage_offset_)
|
||||
mismatch = true;
|
||||
if (this->has_config_current_offset_[phase] &&
|
||||
offset.current_offset_ != this->config_offset_phase_[phase].current_offset_)
|
||||
mismatch = true;
|
||||
if (mismatch)
|
||||
this->offset_calibration_mismatch_[phase] = true;
|
||||
*has_stored = have_data && !all_zero;
|
||||
*restored = false;
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
mismatches[phase] = false;
|
||||
if (*has_stored) {
|
||||
mismatches[phase] =
|
||||
(has_first[phase] && (*offsets)[phase].first_offset != (*config_offsets)[phase].first_offset) ||
|
||||
(has_second[phase] && (*offsets)[phase].second_offset != (*config_offsets)[phase].second_offset);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if (!*has_stored) {
|
||||
for (uint8_t phase = 0; phase < 3; phase++)
|
||||
this->offset_phase_[phase] = this->config_offset_phase_[phase];
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored offset calibrations found. Using default values.", cs);
|
||||
(*offsets)[phase] = (*config_offsets)[phase];
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored %s calibrations found. Using default values.", cs, LOG_STR_ARG(name));
|
||||
}
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
write_offsets_to_registers_(phase, this->offset_phase_[phase].voltage_offset_,
|
||||
this->offset_phase_[phase].current_offset_);
|
||||
this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type);
|
||||
}
|
||||
}
|
||||
|
||||
void ATM90E32Component::restore_power_offset_calibrations_() {
|
||||
const char *cs = this->get_calibration_id_();
|
||||
for (uint8_t i = 0; i < 3; ++i)
|
||||
this->config_power_offset_phase_[i] = this->power_offset_phase_[i];
|
||||
|
||||
bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_);
|
||||
|
||||
bool all_zero = true;
|
||||
if (have_data) {
|
||||
for (auto &phase : this->power_offset_phase_) {
|
||||
if (phase.active_power_offset != 0 || phase.reactive_power_offset != 0) {
|
||||
all_zero = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const bool initial_values_verified = this->verify_offset_writes_(type);
|
||||
if (initial_values_verified) {
|
||||
const auto state = resolve_offset_restore_state(*has_stored, true, false);
|
||||
*restored = state.restored;
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration values verified.", cs, LOG_STR_ARG(name));
|
||||
return;
|
||||
}
|
||||
|
||||
if (have_data && !all_zero) {
|
||||
this->restored_power_offset_calibration_ = true;
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
auto &offset = this->power_offset_phase_[phase];
|
||||
bool mismatch = false;
|
||||
if (this->has_config_active_power_offset_[phase] &&
|
||||
offset.active_power_offset != this->config_power_offset_phase_[phase].active_power_offset)
|
||||
mismatch = true;
|
||||
if (this->has_config_reactive_power_offset_[phase] &&
|
||||
offset.reactive_power_offset != this->config_power_offset_phase_[phase].reactive_power_offset)
|
||||
mismatch = true;
|
||||
if (mismatch)
|
||||
this->power_offset_calibration_mismatch_[phase] = true;
|
||||
}
|
||||
this->using_saved_calibrations_ = false;
|
||||
for (uint8_t phase = 0; phase < 3; phase++)
|
||||
mismatches[phase] = false;
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
(*offsets)[phase] = (*config_offsets)[phase];
|
||||
this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type);
|
||||
}
|
||||
const auto state = resolve_offset_restore_state(*has_stored, false, this->verify_offset_writes_(type));
|
||||
*restored = state.restored;
|
||||
if (state.values_verified) {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore failed verification; config values verified.", cs,
|
||||
LOG_STR_ARG(name));
|
||||
} else {
|
||||
for (uint8_t phase = 0; phase < 3; ++phase)
|
||||
this->power_offset_phase_[phase] = this->config_power_offset_phase_[phase];
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored power offsets found. Using default values.", cs);
|
||||
}
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
write_power_offsets_to_registers_(phase, this->power_offset_phase_[phase].active_power_offset,
|
||||
this->power_offset_phase_[phase].reactive_power_offset);
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore and config fallback both failed verification.", cs,
|
||||
LOG_STR_ARG(name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1084,14 +1109,14 @@ void ATM90E32Component::clear_gain_calibrations() {
|
||||
|
||||
void ATM90E32Component::clear_offset_calibrations() {
|
||||
const char *cs = this->get_calibration_id_();
|
||||
if (!this->restored_offset_calibration_) {
|
||||
if (!this->has_stored_offset_calibration_) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
|
||||
this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_);
|
||||
this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs);
|
||||
return;
|
||||
@@ -1104,10 +1129,11 @@ void ATM90E32Component::clear_offset_calibrations() {
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
int16_t voltage_offset =
|
||||
this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].voltage_offset_ : 0;
|
||||
this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].first_offset : 0;
|
||||
int16_t current_offset =
|
||||
this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].current_offset_ : 0;
|
||||
this->write_offsets_to_registers_(phase, voltage_offset, current_offset);
|
||||
this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].second_offset : 0;
|
||||
this->write_offsets_to_registers_(phase, voltage_offset, current_offset,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset,
|
||||
current_offset);
|
||||
}
|
||||
@@ -1117,6 +1143,7 @@ void ATM90E32Component::clear_offset_calibrations() {
|
||||
this->offset_pref_.save(&zero_offsets); // Clear stored values in flash
|
||||
global_preferences->sync();
|
||||
|
||||
this->has_stored_offset_calibration_ = false;
|
||||
this->restored_offset_calibration_ = false;
|
||||
for (bool &phase : this->offset_calibration_mismatch_)
|
||||
phase = false;
|
||||
@@ -1126,15 +1153,14 @@ void ATM90E32Component::clear_offset_calibrations() {
|
||||
|
||||
void ATM90E32Component::clear_power_offset_calibrations() {
|
||||
const char *cs = this->get_calibration_id_();
|
||||
if (!this->restored_power_offset_calibration_) {
|
||||
if (!this->has_stored_power_offset_calibration_) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
|
||||
this->power_offset_phase_[phase].active_power_offset,
|
||||
this->power_offset_phase_[phase].reactive_power_offset);
|
||||
this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
|
||||
return;
|
||||
@@ -1147,20 +1173,21 @@ void ATM90E32Component::clear_power_offset_calibrations() {
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
int16_t active_offset =
|
||||
this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].active_power_offset : 0;
|
||||
int16_t reactive_offset = this->has_config_reactive_power_offset_[phase]
|
||||
? this->config_power_offset_phase_[phase].reactive_power_offset
|
||||
: 0;
|
||||
this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset);
|
||||
this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].first_offset : 0;
|
||||
int16_t reactive_offset =
|
||||
this->has_config_reactive_power_offset_[phase] ? this->config_power_offset_phase_[phase].second_offset : 0;
|
||||
this->write_offsets_to_registers_(phase, active_offset, reactive_offset,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset,
|
||||
reactive_offset);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
|
||||
|
||||
PowerOffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}};
|
||||
OffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}};
|
||||
this->power_offset_pref_.save(&zero_power_offsets);
|
||||
global_preferences->sync();
|
||||
|
||||
this->has_stored_power_offset_calibration_ = false;
|
||||
this->restored_power_offset_calibration_ = false;
|
||||
for (bool &phase : this->power_offset_calibration_mismatch_)
|
||||
phase = false;
|
||||
@@ -1215,6 +1242,31 @@ bool ATM90E32Component::verify_gain_writes_() {
|
||||
return success; // Return true if all writes were successful, false otherwise
|
||||
}
|
||||
|
||||
bool ATM90E32Component::verify_offset_writes_(OffsetCalibrationType type) {
|
||||
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
|
||||
const char *cs = this->get_calibration_id_();
|
||||
const LogString *name = offset_calibration_name(power_offsets);
|
||||
const LogString *first_name = power_offsets ? LOG_STR("active") : LOG_STR("voltage");
|
||||
const LogString *second_name = power_offsets ? LOG_STR("reactive") : LOG_STR("current");
|
||||
const OffsetCalibration *offsets = power_offsets ? this->power_offset_phase_ : this->offset_phase_;
|
||||
const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers;
|
||||
const uint16_t *second_registers =
|
||||
power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers;
|
||||
bool success = true;
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
const uint16_t first = this->read16_(first_registers[phase]);
|
||||
const uint16_t second = this->read16_(second_registers[phase]);
|
||||
if (!offset_register_value_matches(first, offsets[phase].first_offset) ||
|
||||
!offset_register_value_matches(second, offsets[phase].second_offset)) {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] %s readback failed for Phase %s: %s %d/%d, %s %d/%d.", cs, LOG_STR_ARG(name),
|
||||
phase_labels[phase], LOG_STR_ARG(first_name), static_cast<int16_t>(first), offsets[phase].first_offset,
|
||||
LOG_STR_ARG(second_name), static_cast<int16_t>(second), offsets[phase].second_offset);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
void ATM90E32Component::check_phase_status() {
|
||||
uint16_t state0 = this->read16_(ATM90E32_REGISTER_EMMSTATE0);
|
||||
|
||||
@@ -13,6 +13,40 @@
|
||||
|
||||
namespace esphome::atm90e32 {
|
||||
|
||||
inline bool offset_register_value_matches(uint16_t actual, int16_t expected) {
|
||||
return actual == static_cast<uint16_t>(expected);
|
||||
}
|
||||
|
||||
struct OffsetCalibration {
|
||||
int16_t first_offset{0};
|
||||
int16_t second_offset{0};
|
||||
};
|
||||
|
||||
static_assert(sizeof(OffsetCalibration[3]) == 12, "Offset calibration preference layout must remain compatible");
|
||||
|
||||
enum class OffsetCalibrationType : uint8_t {
|
||||
OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT,
|
||||
OFFSET_CALIBRATION_TYPE_POWER,
|
||||
};
|
||||
|
||||
struct OffsetRestoreState {
|
||||
bool restored;
|
||||
bool values_verified;
|
||||
};
|
||||
|
||||
inline OffsetRestoreState resolve_offset_restore_state(bool has_stored_values, bool initial_values_verified,
|
||||
bool fallback_values_verified) {
|
||||
if (initial_values_verified)
|
||||
return {has_stored_values, true};
|
||||
return {false, fallback_values_verified};
|
||||
}
|
||||
|
||||
inline void prepare_offset_rollback(const OffsetCalibration (&previous)[3], bool had_stored_values,
|
||||
OffsetCalibration (&rollback)[3]) {
|
||||
for (uint8_t phase = 0; phase < 3; phase++)
|
||||
rollback[phase] = had_stored_values ? previous[phase] : OffsetCalibration{};
|
||||
}
|
||||
|
||||
class ATM90E32Component final : public PollingComponent,
|
||||
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH,
|
||||
spi::CLOCK_PHASE_TRAILING, spi::DATA_RATE_1MHZ> {
|
||||
@@ -71,19 +105,19 @@ class ATM90E32Component final : public PollingComponent,
|
||||
this->has_config_current_gain_[phase] = true;
|
||||
}
|
||||
void set_voltage_offset(uint8_t phase, int16_t offset) {
|
||||
this->offset_phase_[phase].voltage_offset_ = offset;
|
||||
this->offset_phase_[phase].first_offset = offset;
|
||||
this->has_config_voltage_offset_[phase] = true;
|
||||
}
|
||||
void set_current_offset(uint8_t phase, int16_t offset) {
|
||||
this->offset_phase_[phase].current_offset_ = offset;
|
||||
this->offset_phase_[phase].second_offset = offset;
|
||||
this->has_config_current_offset_[phase] = true;
|
||||
}
|
||||
void set_active_power_offset(uint8_t phase, int16_t offset) {
|
||||
this->power_offset_phase_[phase].active_power_offset = offset;
|
||||
this->power_offset_phase_[phase].first_offset = offset;
|
||||
this->has_config_active_power_offset_[phase] = true;
|
||||
}
|
||||
void set_reactive_power_offset(uint8_t phase, int16_t offset) {
|
||||
this->power_offset_phase_[phase].reactive_power_offset = offset;
|
||||
this->power_offset_phase_[phase].second_offset = offset;
|
||||
this->has_config_reactive_power_offset_[phase] = true;
|
||||
}
|
||||
void set_freq_sensor(sensor::Sensor *freq_sensor) { freq_sensor_ = freq_sensor; }
|
||||
@@ -171,16 +205,16 @@ class ATM90E32Component final : public PollingComponent,
|
||||
float get_chip_temperature_();
|
||||
bool get_publish_interval_flag_() { return publish_interval_flag_; };
|
||||
void set_publish_interval_flag_(bool flag) { publish_interval_flag_ = flag; };
|
||||
void restore_offset_calibrations_();
|
||||
void restore_power_offset_calibrations_();
|
||||
void restore_offset_calibrations_(OffsetCalibrationType type);
|
||||
void restore_gain_calibrations_();
|
||||
void save_offset_calibration_to_memory_();
|
||||
void save_gain_calibration_to_memory_();
|
||||
void save_power_offset_calibration_to_memory_();
|
||||
void write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset);
|
||||
void write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset);
|
||||
void finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored,
|
||||
bool previous_using_saved, OffsetCalibrationType type);
|
||||
void write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset,
|
||||
OffsetCalibrationType type);
|
||||
void write_gains_to_registers_();
|
||||
bool verify_gain_writes_();
|
||||
bool verify_offset_writes_(OffsetCalibrationType type);
|
||||
bool validate_spi_read_(uint16_t expected, const char *context = nullptr);
|
||||
void log_calibration_status_();
|
||||
const char *get_calibration_id_();
|
||||
@@ -219,19 +253,10 @@ class ATM90E32Component final : public PollingComponent,
|
||||
uint32_t cumulative_reverse_active_energy_{0};
|
||||
} phase_[3];
|
||||
|
||||
struct OffsetCalibration {
|
||||
int16_t voltage_offset_{0};
|
||||
int16_t current_offset_{0};
|
||||
} offset_phase_[3];
|
||||
|
||||
OffsetCalibration offset_phase_[3];
|
||||
OffsetCalibration config_offset_phase_[3];
|
||||
|
||||
struct PowerOffsetCalibration {
|
||||
int16_t active_power_offset{0};
|
||||
int16_t reactive_power_offset{0};
|
||||
} power_offset_phase_[3];
|
||||
|
||||
PowerOffsetCalibration config_power_offset_phase_[3];
|
||||
OffsetCalibration power_offset_phase_[3];
|
||||
OffsetCalibration config_power_offset_phase_[3];
|
||||
|
||||
struct GainCalibration {
|
||||
uint16_t voltage_gain{1};
|
||||
@@ -265,6 +290,8 @@ class ATM90E32Component final : public PollingComponent,
|
||||
bool enable_offset_calibration_{false};
|
||||
bool enable_gain_calibration_{false};
|
||||
const char *instance_id_{nullptr};
|
||||
bool has_stored_offset_calibration_{false};
|
||||
bool has_stored_power_offset_calibration_{false};
|
||||
bool restored_offset_calibration_{false};
|
||||
bool restored_power_offset_calibration_{false};
|
||||
bool restored_gain_calibration_{false};
|
||||
|
||||
@@ -339,10 +339,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
# HTTPS streams verify the server against the root certificate bundle
|
||||
require_certificate_bundle()
|
||||
|
||||
add_idf_component(
|
||||
name="esphome/esp-audio-libs",
|
||||
ref="3.2.1",
|
||||
)
|
||||
add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0")
|
||||
|
||||
data = _get_data()
|
||||
|
||||
|
||||
@@ -313,9 +313,10 @@ FileDecoderState AudioDecoder::decode_mp3_() {
|
||||
this->output_transfer_buffer_->increase_buffer_length(
|
||||
this->audio_stream_info_.value().frames_to_bytes(samples_decoded));
|
||||
}
|
||||
} else if (result == micro_mp3::MP3_STREAM_INFO_READY) {
|
||||
// First successful header parse: capture stream info and resize the output buffer to fit one full frame.
|
||||
// microMP3 always outputs 16-bit PCM.
|
||||
} else if (result == micro_mp3::MP3_STREAM_INFO_READY || result == micro_mp3::MP3_STREAM_INFO_CHANGED) {
|
||||
// Header parsed: capture stream info and resize the output buffer to fit one full frame.
|
||||
// microMP3 always outputs 16-bit PCM. MP3_STREAM_INFO_CHANGED is handled identically: despite its
|
||||
// negative value it is documented as recoverable, so it must not reach the catch-all below.
|
||||
this->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate());
|
||||
this->free_buffer_required_ =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -9,6 +9,23 @@
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
// Base for nodes that never read the parent's services.
|
||||
// The parent releases its services only once every node reports Established, so a node that never
|
||||
// reports it keeps that memory allocated for the life of the connection.
|
||||
class BLEClientServicelessNode : public BLEClientNode {
|
||||
public:
|
||||
// Final so that Established is always reported on SEARCH_CMPL, before the derived node sees the event.
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) final {
|
||||
if (event == ESP_GATTC_SEARCH_CMPL_EVT)
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
this->on_gattc_event(event, gattc_if, param);
|
||||
}
|
||||
|
||||
protected:
|
||||
// Derived nodes handle GATT events here rather than by overriding the handler above.
|
||||
virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {}
|
||||
};
|
||||
|
||||
// implement on_connect automation.
|
||||
class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode {
|
||||
public:
|
||||
@@ -48,7 +65,7 @@ class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode
|
||||
}
|
||||
};
|
||||
|
||||
class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode {
|
||||
class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode {
|
||||
public:
|
||||
explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
void loop() override {}
|
||||
@@ -58,7 +75,7 @@ class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientN
|
||||
}
|
||||
};
|
||||
|
||||
class BLEClientPasskeyNotificationTrigger final : public Trigger<uint32_t>, public BLEClientNode {
|
||||
class BLEClientPasskeyNotificationTrigger final : public Trigger<uint32_t>, public BLEClientServicelessNode {
|
||||
public:
|
||||
explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
void loop() override {}
|
||||
@@ -69,7 +86,7 @@ class BLEClientPasskeyNotificationTrigger final : public Trigger<uint32_t>, publ
|
||||
}
|
||||
};
|
||||
|
||||
class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>, public BLEClientNode {
|
||||
class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>, public BLEClientServicelessNode {
|
||||
public:
|
||||
explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
void loop() override {}
|
||||
@@ -164,19 +181,17 @@ template<typename... Ts> class BLEClientRemoveBondAction final : public Action<T
|
||||
BLEClient *parent_{nullptr};
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...>, public BLEClientNode {
|
||||
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...>, public BLEClientServicelessNode {
|
||||
public:
|
||||
BLEClientConnectAction(BLEClient *ble_client) {
|
||||
ble_client->register_ble_node(this);
|
||||
ble_client_ = ble_client;
|
||||
}
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override {
|
||||
void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override {
|
||||
if (this->num_running_ == 0)
|
||||
return;
|
||||
switch (event) {
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT:
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); });
|
||||
break;
|
||||
// if the connection is closed, terminate the automation chain.
|
||||
@@ -213,14 +228,13 @@ template<typename... Ts> class BLEClientConnectAction final : public Action<Ts..
|
||||
std::tuple<Ts...> var_{};
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...>, public BLEClientNode {
|
||||
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...>, public BLEClientServicelessNode {
|
||||
public:
|
||||
BLEClientDisconnectAction(BLEClient *ble_client) {
|
||||
ble_client->register_ble_node(this);
|
||||
ble_client_ = ble_client;
|
||||
}
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override {
|
||||
void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override {
|
||||
if (this->num_running_ == 0)
|
||||
return;
|
||||
switch (event) {
|
||||
|
||||
@@ -46,15 +46,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();
|
||||
@@ -66,7 +58,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
|
||||
@@ -79,8 +71,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();
|
||||
@@ -88,6 +80,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()) {
|
||||
@@ -98,6 +106,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) {
|
||||
|
||||
@@ -59,6 +59,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; }
|
||||
|
||||
@@ -403,6 +403,17 @@ async def _to_code_ble_hub(config: ConfigType) -> None:
|
||||
await _connections_to_code(var, config)
|
||||
|
||||
|
||||
def enable_advertisement_filter() -> None:
|
||||
"""Compile the advertisement filter hook into bluetooth_proxy.
|
||||
|
||||
Called by external filtering components from to_code(). The define behind
|
||||
this is an implementation detail; do not emit it directly.
|
||||
|
||||
Public API for external components. Do not remove.
|
||||
"""
|
||||
cg.add_define("USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER")
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if CORE.is_esp32:
|
||||
await _to_code_esp32(config)
|
||||
|
||||
@@ -94,6 +94,15 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
|
||||
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
|
||||
return;
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
|
||||
// Ask the filter before the packet is queued, so a dropped advertisement never
|
||||
// reaches the batch or the network.
|
||||
if (this->advertisement_filter_.is_set() && !this->advertisement_filter_.should_forward(raw)) {
|
||||
ESP_LOGVV(TAG, "Filtered packet from %012" PRIX64, raw.address);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
auto &adv = this->response_.advertisements[this->response_.advertisements_len];
|
||||
adv.address = raw.address;
|
||||
adv.rssi = raw.rssi;
|
||||
@@ -184,6 +193,9 @@ void BluetoothProxy::dump_config() {
|
||||
" Adapter MAC: %s",
|
||||
scan_mode, mac_out);
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
|
||||
ESP_LOGCONFIG(TAG, " Advertisement filter: %s", YESNO(this->advertisement_filter_.is_set()));
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
@@ -97,6 +97,29 @@ static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x000011223344556
|
||||
static_assert(PendingReply{}.empty());
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
|
||||
/// Predicate slot letting an external component drop advertisements before they
|
||||
/// are queued for the API. Same shape as
|
||||
/// ble_device_base::RawAdvertisementCallback. Runs on the advertisement hot
|
||||
/// path, so it must be cheap and must not block.
|
||||
///
|
||||
/// Usage:
|
||||
/// proxy->set_advertisement_filter({this, [](void *self, const ble_device_base::RawAdvertisement &adv) {
|
||||
/// return static_cast<MyFilter *>(self)->should_forward(adv);
|
||||
/// }});
|
||||
///
|
||||
/// Returning false drops the advertisement. Not called at all while the API is
|
||||
/// disconnected, which matters to a stateful filter. Compiled in only when an
|
||||
/// external component calls bluetooth_proxy.enable_advertisement_filter().
|
||||
struct AdvertisementFilter {
|
||||
void *instance{nullptr};
|
||||
bool (*fn)(void *instance, const ble_device_base::RawAdvertisement &adv){nullptr};
|
||||
/// A default-constructed slot is "no filter"; the proxy guards on this.
|
||||
bool is_set() const { return this->fn != nullptr; }
|
||||
bool should_forward(const ble_device_base::RawAdvertisement &adv) const { return this->fn(this->instance, adv); }
|
||||
};
|
||||
#endif // USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
|
||||
|
||||
class BluetoothProxy final : public Component {
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// Allow the connection to update connections_free_response_
|
||||
@@ -162,6 +185,11 @@ class BluetoothProxy final : public Component {
|
||||
void set_active(bool active) { this->active_ = active; }
|
||||
bool has_active() { return this->active_; }
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
|
||||
/// One subscriber; a later call replaces an earlier one.
|
||||
void set_advertisement_filter(AdvertisementFilter filter) { this->advertisement_filter_ = filter; }
|
||||
#endif
|
||||
|
||||
uint32_t get_legacy_version() const {
|
||||
if (!this->active_) {
|
||||
return LEGACY_PASSIVE_ONLY_VERSION;
|
||||
@@ -330,6 +358,10 @@ class BluetoothProxy final : public Component {
|
||||
// start on an even word, closing two alignment holes.
|
||||
uint32_t last_advertisement_flush_time_{0};
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
|
||||
AdvertisementFilter advertisement_filter_{};
|
||||
#endif
|
||||
|
||||
// BLE advertisement batching
|
||||
api::BluetoothLERawAdvertisementsResponse response_;
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
DOMAIN = "bridge"
|
||||
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
@@ -0,0 +1,114 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, uart, usb_cdc_acm
|
||||
from esphome.components.bridge import DOMAIN as BRIDGE_DOMAIN
|
||||
from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
DEPENDENCIES = ["tinyusb", "uart", "usb_cdc_acm"]
|
||||
|
||||
CONF_DTR_PIN = "dtr_pin"
|
||||
CONF_RTS_PIN = "rts_pin"
|
||||
CONF_USB_CDC_ACM_ID = "usb_cdc_acm_id"
|
||||
|
||||
cdc_acm_uart_ns = cg.esphome_ns.namespace("cdc_acm_uart")
|
||||
CDCACMUARTBridge = cdc_acm_uart_ns.class_("CDCACMUARTBridge", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(CDCACMUARTBridge),
|
||||
cv.Required(CONF_UART_ID): cv.use_id(uart.IDFUARTComponent),
|
||||
cv.Required(CONF_USB_CDC_ACM_ID): cv.use_id(usb_cdc_acm.USBCDCACMInstance),
|
||||
cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema,
|
||||
cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
# Narrower than usb_cdc_acm's variant list on purpose: S31/H4 untested on
|
||||
# hardware; extend once verified.
|
||||
esp32.only_on_variant(
|
||||
supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _subtree_references_uart(node: object, uart_id: str) -> bool:
|
||||
"""Return True if any dict in the subtree has a uart_id entry naming this bus."""
|
||||
if isinstance(node, dict):
|
||||
return any(
|
||||
(key == CONF_UART_ID and str(value) == uart_id)
|
||||
or _subtree_references_uart(value, uart_id)
|
||||
for key, value in node.items()
|
||||
)
|
||||
if isinstance(node, list):
|
||||
return any(_subtree_references_uart(item, uart_id) for item in node)
|
||||
return False
|
||||
|
||||
|
||||
def _reject_debug(uart_conf: ConfigType) -> ConfigType:
|
||||
# The worker tasks use the IDF driver directly, so the uart debugger never sees
|
||||
# bridge traffic and its dummy_receiver would drain RX bytes on the main loop.
|
||||
if CONF_DEBUG in uart_conf:
|
||||
raise cv.Invalid(
|
||||
"A bridged UART cannot use 'debug'; the bridge bypasses the UART "
|
||||
"component's read/write path.",
|
||||
[CONF_DEBUG],
|
||||
)
|
||||
return uart_conf
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
full_config = fv.full_config.get()
|
||||
# Bridges of any platform must own their interfaces exclusively; shared ring
|
||||
# buffers and overwritten callbacks would corrupt both streams silently. The
|
||||
# seen-set is keyed on the bridge domain so future platforms share it.
|
||||
# Other components bind either interface through the same uart_id key (the CDC
|
||||
# instance is itself a uart::UARTComponent) and would race the worker tasks.
|
||||
# Bare `id:` references (a uart.write action) cannot be distinguished; not caught.
|
||||
data = full_config.data.setdefault(BRIDGE_DOMAIN, {})
|
||||
for conf_key, label in (
|
||||
(CONF_UART_ID, "UART"),
|
||||
(CONF_USB_CDC_ACM_ID, "USB CDC-ACM interface"),
|
||||
):
|
||||
owned_id = str(config[conf_key])
|
||||
used = data.setdefault(conf_key, set())
|
||||
if owned_id in used:
|
||||
raise cv.Invalid(
|
||||
f"The {label} '{owned_id}' is already bridged by another 'bridge' "
|
||||
f"instance; each bridge requires its own {label}.",
|
||||
[conf_key],
|
||||
)
|
||||
used.add(owned_id)
|
||||
for domain, domain_conf in full_config.items():
|
||||
if domain == BRIDGE_DOMAIN:
|
||||
continue
|
||||
if _subtree_references_uart(domain_conf, owned_id):
|
||||
raise cv.Invalid(
|
||||
f"The {label} '{owned_id}' is also used by '{domain}'; a bridge "
|
||||
f"requires exclusive use of its {label}.",
|
||||
[conf_key],
|
||||
)
|
||||
|
||||
fv.id_declaration_match_schema(_reject_debug)(config[CONF_UART_ID])
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
uart_component = await cg.get_variable(config[CONF_UART_ID])
|
||||
usb_cdc = await cg.get_variable(config[CONF_USB_CDC_ACM_ID])
|
||||
var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if dtr_pin_config := config.get(CONF_DTR_PIN):
|
||||
dtr_pin = await cg.gpio_pin_expression(dtr_pin_config)
|
||||
cg.add(var.set_dtr_pin(dtr_pin))
|
||||
if rts_pin_config := config.get(CONF_RTS_PIN):
|
||||
rts_pin = await cg.gpio_pin_expression(rts_pin_config)
|
||||
cg.add(var.set_rts_pin(rts_pin))
|
||||
@@ -0,0 +1,468 @@
|
||||
#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
|
||||
#include "cdc_acm_uart_bridge.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/ringbuf.h"
|
||||
#include "driver/uart.h"
|
||||
#include "soc/soc_caps.h"
|
||||
|
||||
namespace esphome::cdc_acm_uart {
|
||||
|
||||
static const char *const TAG = "cdc_acm_uart";
|
||||
|
||||
static constexpr size_t UART_TASK_STACK_SIZE = 4096;
|
||||
static constexpr size_t RINGBUF_RETRY_CHUNK_SIZE = 64;
|
||||
static constexpr uint32_t LOG_THROTTLE_MS = 1000;
|
||||
static constexpr uint32_t UART_RELOAD_SETTLE_MS = 20;
|
||||
// Above the default priority but below the USB/Wi-Fi system tasks.
|
||||
static constexpr UBaseType_t TASK_PRIORITY = 4;
|
||||
|
||||
static bool should_log_now(uint32_t *last_ms, uint32_t interval_ms) {
|
||||
uint32_t now = millis();
|
||||
if ((now - *last_ms) >= interval_ms) {
|
||||
*last_ms = now;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool ringbuf_send_with_retry(RingbufHandle_t ringbuf, const uint8_t *data, size_t len, uint32_t *log_ms) {
|
||||
if (len == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (xRingbufferSend(ringbuf, data, len, pdMS_TO_TICKS(1)) == pdTRUE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t offset = 0;
|
||||
while (offset < len) {
|
||||
size_t chunk = std::min(RINGBUF_RETRY_CHUNK_SIZE, len - offset);
|
||||
if (xRingbufferSend(ringbuf, data + offset, chunk, pdMS_TO_TICKS(1)) != pdTRUE) {
|
||||
if (should_log_now(log_ms, LOG_THROTTLE_MS)) {
|
||||
ESP_LOGW(TAG, "USB TX buffer full; some data is lost");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
offset += chunk;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::setup() {
|
||||
// Line state starts deasserted (no host yet); active-low DTR#/RTS# wiring is
|
||||
// handled by configuring the pins inverted, so deasserted idles HIGH.
|
||||
if (this->dtr_pin_ != nullptr) {
|
||||
this->dtr_pin_->setup();
|
||||
this->dtr_pin_->digital_write(false);
|
||||
}
|
||||
|
||||
if (this->rts_pin_ != nullptr) {
|
||||
this->rts_pin_->setup();
|
||||
this->rts_pin_->digital_write(false);
|
||||
}
|
||||
|
||||
// A failed UART never assigned its port number, so the worker tasks would run
|
||||
// against an indeterminate port.
|
||||
if (this->uart_parent_->is_failed()) {
|
||||
ESP_LOGE(TAG, "UART parent failed; aborting");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
this->configured_baud_rate_ = this->uart_parent_->get_baud_rate();
|
||||
this->configured_parity_ = this->uart_parent_->get_parity();
|
||||
this->configured_stop_bits_ = this->uart_parent_->get_stop_bits();
|
||||
this->configured_data_bits_ = this->uart_parent_->get_data_bits();
|
||||
|
||||
// usb_cdc_acm sets up first (priority IO > HARDWARE). Any interface failing marks
|
||||
// the hub failed, and a failed hub no longer runs loop(), so line coding and line
|
||||
// state events would never reach this bridge even if its own interface is healthy.
|
||||
if (this->usb_cdc_parent_->get_parent()->is_failed()) {
|
||||
ESP_LOGE(TAG, "USB CDC ACM failed; aborting");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-instance task names (keyed on the CDC interface number) keep task dumps
|
||||
// unambiguous with multiple bridges.
|
||||
char tx_task_name[] = "cdc_uart_tx_0";
|
||||
char rx_task_name[] = "cdc_uart_rx_0";
|
||||
const char itf_char = format_hex_char(this->usb_cdc_parent_->get_itf());
|
||||
tx_task_name[sizeof(tx_task_name) - 2] = itf_char;
|
||||
rx_task_name[sizeof(rx_task_name) - 2] = itf_char;
|
||||
|
||||
xTaskCreate(uart_tx_task_fn, tx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_tx_task_handle_);
|
||||
if (this->uart_tx_task_handle_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create UART TX task");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
xTaskCreate(uart_rx_task_fn, rx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_rx_task_handle_);
|
||||
if (this->uart_rx_task_handle_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create UART RX task");
|
||||
vTaskDelete(this->uart_tx_task_handle_);
|
||||
this->uart_tx_task_handle_ = nullptr;
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only register callbacks once both tasks exist, so a failed setup never drives
|
||||
// DTR/RTS from a dead bridge.
|
||||
this->usb_cdc_parent_->set_line_state_callback([this](bool dtr, bool rts) { this->set_line_state(dtr, rts); });
|
||||
this->usb_cdc_parent_->set_line_coding_callback([this](uint32_t, uint8_t, uint8_t, uint8_t) {
|
||||
this->host_coding_seen_ = true;
|
||||
// Another component owns the UART's framing while paused; resume() re-syncs.
|
||||
if (this->paused_ == 0) {
|
||||
this->set_line_coding();
|
||||
}
|
||||
});
|
||||
|
||||
// Release the workers only now: until here a failed setup may still delete the TX
|
||||
// task, which is safe only while it is parked and owns nothing in the driver.
|
||||
xTaskNotifyGive(this->uart_tx_task_handle_);
|
||||
xTaskNotifyGive(this->uart_rx_task_handle_);
|
||||
|
||||
// loop() only services line-coding reloads; stay off the main loop until one is
|
||||
// scheduled.
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"CDC-ACM UART Bridge:\n"
|
||||
" UART Bus: %u\n"
|
||||
" USB CDC Interface: %u",
|
||||
this->uart_parent_->get_hw_serial_number(), this->usb_cdc_parent_->get_itf());
|
||||
LOG_PIN(" DTR Pin: ", this->dtr_pin_);
|
||||
LOG_PIN(" RTS Pin: ", this->rts_pin_);
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::on_shutdown() {
|
||||
// The UART (BUS) shuts down after this component (HARDWARE) and deletes its driver,
|
||||
// freeing the ring buffer and mutexes the worker tasks block on. Suspending the
|
||||
// tasks unlinks them from those objects first.
|
||||
if (this->uart_rx_task_handle_ != nullptr) {
|
||||
vTaskSuspend(this->uart_rx_task_handle_);
|
||||
}
|
||||
if (this->uart_tx_task_handle_ != nullptr) {
|
||||
vTaskSuspend(this->uart_tx_task_handle_);
|
||||
}
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::loop() {
|
||||
switch (this->state_) {
|
||||
case MainState::MAIN_STATE_RELOAD_PENDING:
|
||||
if ((App.get_loop_component_start_time() - this->reload_requested_at_) < UART_RELOAD_SETTLE_MS) {
|
||||
return;
|
||||
}
|
||||
// Deliberately not gated on tx_idle_(): a host that re-codes the line mid-stream
|
||||
// wants the new framing now, and its own in-flight bytes are its concern.
|
||||
// apply_settings_live() rewrites the framing registers without reinstalling the
|
||||
// driver, so the worker tasks blocked inside it are undisturbed.
|
||||
this->uart_parent_->apply_settings_live();
|
||||
this->state_ = MainState::MAIN_STATE_RUNNING;
|
||||
break;
|
||||
case MainState::MAIN_STATE_PAUSING:
|
||||
case MainState::MAIN_STATE_RESUMING:
|
||||
// Let a host write that was in flight drain, FIFO included, before a reload
|
||||
// flushes the FIFOs and truncates it.
|
||||
if (!this->tx_idle_()) {
|
||||
return;
|
||||
}
|
||||
if (this->state_ == MainState::MAIN_STATE_PAUSING) {
|
||||
this->restore_configured_framing_();
|
||||
this->state_ = MainState::MAIN_STATE_PAUSED;
|
||||
} else {
|
||||
this->finish_resume_();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::set_line_coding() {
|
||||
if (!this->sync_host_framing_()) {
|
||||
return;
|
||||
}
|
||||
// Coalesce rapid line-coding updates from the host.
|
||||
this->reload_requested_at_ = App.get_loop_component_start_time();
|
||||
this->state_ = MainState::MAIN_STATE_RELOAD_PENDING;
|
||||
// Main-loop context (via USBCDCACMInstance::process_events_).
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
bool CDCACMUARTBridge::sync_host_framing_() {
|
||||
// usb_cdc_acm has already translated the wire coding onto the CDC instance (main
|
||||
// loop); mirror it here so the framing translation has a single source of truth.
|
||||
bool changed = false;
|
||||
|
||||
// Reject 0 (the CDC B0/hang-up encoding; older IDF revisions divide by the rate)
|
||||
// and rates above the SoC ceiling. Anything in between is the driver's call,
|
||||
// matching what a YAML-configured UART accepts.
|
||||
const uint32_t baud = this->usb_cdc_parent_->get_baud_rate();
|
||||
if (baud == 0 || baud > SOC_UART_BITRATE_MAX) {
|
||||
ESP_LOGW(TAG, "Ignoring unsupported baud rate %" PRIu32 " from host; keeping %" PRIu32, baud,
|
||||
this->uart_parent_->get_baud_rate());
|
||||
} else if (this->uart_parent_->get_baud_rate() != baud) {
|
||||
this->uart_parent_->set_baud_rate(baud);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const uint8_t stop_bits = this->usb_cdc_parent_->get_stop_bits();
|
||||
if (this->uart_parent_->get_stop_bits() != stop_bits) {
|
||||
this->uart_parent_->set_stop_bits(stop_bits);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const auto parity = this->usb_cdc_parent_->get_parity();
|
||||
if (this->uart_parent_->get_parity() != parity) {
|
||||
this->uart_parent_->set_parity(parity);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// USB CDC permits data-bit counts the UART cannot represent (up to 16).
|
||||
const uint8_t data_bits = this->usb_cdc_parent_->get_data_bits();
|
||||
if (data_bits < 5 || data_bits > 8) {
|
||||
ESP_LOGW(TAG, "Ignoring unsupported data bits %u from host; keeping %u", data_bits,
|
||||
this->uart_parent_->get_data_bits());
|
||||
} else if (this->uart_parent_->get_data_bits() != data_bits) {
|
||||
this->uart_parent_->set_data_bits(data_bits);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
ESP_LOGV(TAG, "Line coding: baud=%" PRIu32 ", data_bits=%u, stop_bits=%u, parity=%u",
|
||||
this->uart_parent_->get_baud_rate(), this->uart_parent_->get_data_bits(),
|
||||
this->uart_parent_->get_stop_bits(), static_cast<uint8_t>(this->uart_parent_->get_parity()));
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::pause() {
|
||||
if (this->state_ == MainState::MAIN_STATE_PAUSING || this->state_ == MainState::MAIN_STATE_PAUSED) {
|
||||
return;
|
||||
}
|
||||
this->paused_ = 1;
|
||||
// A null RX task means setup() has not completed (or failed): nothing to stop, and
|
||||
// the framing snapshot does not exist yet. Should setup() run later, the RX task
|
||||
// starts parked.
|
||||
if (this->uart_rx_task_handle_ == nullptr) {
|
||||
this->state_ = MainState::MAIN_STATE_PAUSED;
|
||||
return;
|
||||
}
|
||||
// Drops a coalesced host reload or a pending resume; loop() restores the framing
|
||||
// once any host write in flight has drained.
|
||||
this->state_ = MainState::MAIN_STATE_PAUSING;
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::resume() {
|
||||
if (this->state_ != MainState::MAIN_STATE_PAUSING && this->state_ != MainState::MAIN_STATE_PAUSED) {
|
||||
return;
|
||||
}
|
||||
if (this->uart_rx_task_handle_ == nullptr) {
|
||||
this->paused_ = 0;
|
||||
this->state_ = MainState::MAIN_STATE_RUNNING;
|
||||
return;
|
||||
}
|
||||
// A restore still waiting on the TX side is moot: the host's framing is kept.
|
||||
if (!this->tx_idle_()) {
|
||||
this->state_ = MainState::MAIN_STATE_RESUMING;
|
||||
this->enable_loop();
|
||||
return;
|
||||
}
|
||||
this->finish_resume_();
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::finish_resume_() {
|
||||
// Take the bus back at a known framing before either task runs again: the host's
|
||||
// if it ever sent one, else the YAML framing (the other owner may have changed it).
|
||||
if (this->host_coding_seen_) {
|
||||
this->sync_host_framing_();
|
||||
this->uart_parent_->apply_settings_live();
|
||||
} else {
|
||||
this->restore_configured_framing_();
|
||||
}
|
||||
this->paused_ = 0;
|
||||
this->state_ = MainState::MAIN_STATE_RUNNING;
|
||||
this->drive_line_state_();
|
||||
xTaskNotifyGive(this->uart_rx_task_handle_);
|
||||
}
|
||||
|
||||
bool CDCACMUARTBridge::tx_idle_() {
|
||||
const auto uart_num = static_cast<uart_port_t>(this->uart_parent_->get_hw_serial_number());
|
||||
return this->tx_busy_ == 0 && uart_wait_tx_done(uart_num, 0) == ESP_OK;
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::restore_configured_framing_() {
|
||||
// Always applied: the cached settings can lead the hardware by a pending reload,
|
||||
// so they are no proof of what is live.
|
||||
this->uart_parent_->set_baud_rate(this->configured_baud_rate_);
|
||||
this->uart_parent_->set_parity(this->configured_parity_);
|
||||
this->uart_parent_->set_stop_bits(this->configured_stop_bits_);
|
||||
this->uart_parent_->set_data_bits(this->configured_data_bits_);
|
||||
this->uart_parent_->apply_settings_live();
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::set_line_state(bool dtr, bool rts) {
|
||||
ESP_LOGV(TAG, "Line state: DTR=%d, RTS=%d", dtr, rts);
|
||||
this->host_dtr_ = dtr;
|
||||
this->host_rts_ = rts;
|
||||
// Frozen while paused: a host opening the port must not reset a peer that another
|
||||
// component is talking to.
|
||||
if (this->paused_ == 0) {
|
||||
this->drive_line_state_();
|
||||
}
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::drive_line_state_() {
|
||||
if (this->dtr_pin_ != nullptr) {
|
||||
this->dtr_pin_->digital_write(this->host_dtr_);
|
||||
}
|
||||
if (this->rts_pin_ != nullptr) {
|
||||
this->rts_pin_->digital_write(this->host_rts_);
|
||||
}
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::uart_rx_task_fn(void *arg) {
|
||||
auto *bridge = static_cast<CDCACMUARTBridge *>(arg);
|
||||
bridge->uart_rx_task_();
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::uart_tx_task_fn(void *arg) {
|
||||
auto *bridge = static_cast<CDCACMUARTBridge *>(arg);
|
||||
bridge->uart_tx_task_();
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::uart_rx_task_() {
|
||||
TaskHandle_t usb_tx_handle = this->usb_cdc_parent_->get_tx_task_handle();
|
||||
RingbufHandle_t usb_tx_ringbuf = this->usb_cdc_parent_->get_tx_ringbuf();
|
||||
uart_port_t uart_num = static_cast<uart_port_t>(this->uart_parent_->get_hw_serial_number());
|
||||
// Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs.
|
||||
uint32_t tx_full_log_ms = millis() - LOG_THROTTLE_MS;
|
||||
uint32_t err_log_ms = millis() - LOG_THROTTLE_MS;
|
||||
|
||||
uint8_t *data = this->uart_rx_buffer_.data();
|
||||
const size_t buf_size = this->uart_rx_buffer_.size();
|
||||
|
||||
// Released by setup() once both tasks exist.
|
||||
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
|
||||
|
||||
while (true) {
|
||||
if (this->paused_ != 0) {
|
||||
// Parked until resume() notifies; nothing is read, so the other owner sees
|
||||
// every byte.
|
||||
this->rx_parked_ = 1;
|
||||
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
|
||||
this->rx_parked_ = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block until at least one byte is available from UART.
|
||||
int total_rx_size = uart_read_bytes(uart_num, data, 1, pdMS_TO_TICKS(UART_RX_WAIT_MS));
|
||||
if (total_rx_size < 0) {
|
||||
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
|
||||
ESP_LOGE(TAG, "UART read failed: %d", total_rx_size);
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
continue;
|
||||
}
|
||||
if (total_rx_size == 0) {
|
||||
continue;
|
||||
}
|
||||
// pause() landed during the read: don't forward a byte to a host that is gone.
|
||||
if (this->paused_ != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drain the currently buffered burst without waiting.
|
||||
while (true) {
|
||||
int rx_data_size = uart_read_bytes(uart_num, data + total_rx_size, buf_size - total_rx_size, 0);
|
||||
if (rx_data_size < 0) {
|
||||
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
|
||||
ESP_LOGE(TAG, "UART read failed: %d", rx_data_size);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (rx_data_size == 0) {
|
||||
break;
|
||||
}
|
||||
ESP_LOGV(TAG, "UART RX: %d bytes", rx_data_size);
|
||||
total_rx_size += rx_data_size;
|
||||
if (total_rx_size >= (int) buf_size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ringbuf_send_with_retry(usb_tx_ringbuf, data, total_rx_size, &tx_full_log_ms);
|
||||
|
||||
ESP_LOGV(TAG, "UART RX: waking up USB TX task");
|
||||
xTaskNotifyGive(usb_tx_handle);
|
||||
}
|
||||
}
|
||||
|
||||
void CDCACMUARTBridge::uart_tx_task_() {
|
||||
RingbufHandle_t usb_rx_ringbuf = this->usb_cdc_parent_->get_rx_ringbuf();
|
||||
uart_port_t uart_num = static_cast<uart_port_t>(this->uart_parent_->get_hw_serial_number());
|
||||
uint8_t *data_to_uart = this->uart_tx_buffer_.data();
|
||||
const size_t buf_size = this->uart_tx_buffer_.size();
|
||||
size_t rx_size;
|
||||
// Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs.
|
||||
uint32_t err_log_ms = millis() - LOG_THROTTLE_MS;
|
||||
uint32_t drop_log_ms = millis() - LOG_THROTTLE_MS;
|
||||
|
||||
// Released by setup() once both tasks exist.
|
||||
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
|
||||
|
||||
while (true) {
|
||||
ESP_LOGV(TAG, "Waiting for data to send to UART");
|
||||
esp_err_t ret = usb_cdc_acm::ringbuf_read_bytes(usb_rx_ringbuf, data_to_uart, buf_size, &rx_size, portMAX_DELAY);
|
||||
|
||||
if (ret != ESP_OK) {
|
||||
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
|
||||
ESP_LOGE(TAG, "USB RX RingBuf read failed");
|
||||
}
|
||||
// Yield: this task runs above the main loop, so a persistent failure must not
|
||||
// become a tight loop.
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Another component owns the UART; host bytes must not interleave with its traffic.
|
||||
// tx_busy_ goes up before the check so is_paused() cannot miss a write in flight.
|
||||
this->tx_busy_ = 1;
|
||||
if (this->paused_ != 0) {
|
||||
this->tx_busy_ = 0;
|
||||
if (should_log_now(&drop_log_ms, LOG_THROTTLE_MS)) {
|
||||
ESP_LOGW(TAG, "Paused; dropping %zu bytes from host", rx_size);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Sending %zu bytes to UART", rx_size);
|
||||
// Signed: uart_write_bytes() returns -1 on error.
|
||||
int xfer_size = uart_write_bytes(uart_num, data_to_uart, rx_size);
|
||||
this->tx_busy_ = 0;
|
||||
|
||||
if (xfer_size < 0) {
|
||||
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
|
||||
ESP_LOGE(TAG, "UART write failed: %d", xfer_size);
|
||||
}
|
||||
} else if (static_cast<size_t>(xfer_size) != rx_size) {
|
||||
ESP_LOGW(TAG, "UART write incomplete (%d/%zu bytes)", xfer_size, rx_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::cdc_acm_uart
|
||||
#endif
|
||||
@@ -0,0 +1,117 @@
|
||||
#pragma once
|
||||
#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
|
||||
#include "esphome/components/uart/uart_component_esp_idf.h"
|
||||
#include "esphome/components/usb_cdc_acm/usb_cdc_acm.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include "sdkconfig.h"
|
||||
|
||||
namespace esphome::cdc_acm_uart {
|
||||
|
||||
class CDCACMUARTBridge final : public Component {
|
||||
public:
|
||||
// Upper bound on the RX task's blocking read, so pause() takes effect without
|
||||
// aborting the read. Arriving bytes still unblock it immediately.
|
||||
static constexpr uint32_t UART_RX_WAIT_MS = 250;
|
||||
|
||||
CDCACMUARTBridge(uart::IDFUARTComponent *uart_parent, usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent)
|
||||
: uart_parent_(uart_parent), usb_cdc_parent_(usb_cdc_parent) {}
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
void on_shutdown() override;
|
||||
float get_setup_priority() const override { return setup_priority::HARDWARE; }
|
||||
|
||||
void set_dtr_pin(GPIOPin *dtr_pin) { this->dtr_pin_ = dtr_pin; }
|
||||
void set_rts_pin(GPIOPin *rts_pin) { this->rts_pin_ = rts_pin; }
|
||||
|
||||
void set_line_coding();
|
||||
void set_line_state(bool dtr, bool rts);
|
||||
|
||||
/**
|
||||
* Stop forwarding in both directions and hand the UART back to its configured
|
||||
* framing, so another component may use the bus. Main-loop only. The RX task parks
|
||||
* within UART_RX_WAIT_MS (a byte it was already reading is discarded). A host write
|
||||
* already in flight is allowed to drain first, which at low baud rates can take
|
||||
* seconds; the framing is restored only after that, so poll is_paused() rather than
|
||||
* waiting a fixed interval. Host bytes not yet written to the UART are discarded.
|
||||
* The DTR/RTS outputs hold their state while paused and follow the host again on
|
||||
* resume().
|
||||
*/
|
||||
void pause();
|
||||
/**
|
||||
* Re-apply the host's line coding and line state, then resume forwarding. Main-loop
|
||||
* only. Deferred until any host write still draining has finished, so the reload
|
||||
* never truncates it.
|
||||
*/
|
||||
void resume();
|
||||
/// True once both worker tasks are off the bus and the configured framing is restored.
|
||||
/// With no RX task (setup() failed or has not run) there is nothing to wait for.
|
||||
bool is_paused() const {
|
||||
return this->state_ == MainState::MAIN_STATE_PAUSED &&
|
||||
(this->uart_rx_task_handle_ == nullptr || this->rx_parked_ != 0);
|
||||
}
|
||||
|
||||
protected:
|
||||
static void uart_rx_task_fn(void *arg);
|
||||
static void uart_tx_task_fn(void *arg);
|
||||
void uart_rx_task_();
|
||||
void uart_tx_task_();
|
||||
void restore_configured_framing_();
|
||||
// True when the TX task has no write in flight and the UART TX FIFO has drained.
|
||||
bool tx_idle_();
|
||||
void finish_resume_();
|
||||
void drive_line_state_();
|
||||
// Copy the host's line coding onto the UART settings; true if anything changed.
|
||||
bool sync_host_framing_();
|
||||
|
||||
TaskHandle_t uart_rx_task_handle_{nullptr};
|
||||
TaskHandle_t uart_tx_task_handle_{nullptr};
|
||||
|
||||
GPIOPin *dtr_pin_{nullptr};
|
||||
GPIOPin *rts_pin_{nullptr};
|
||||
|
||||
uint32_t reload_requested_at_{0};
|
||||
|
||||
// Worker staging, each sized to the CDC ring buffer it feeds or drains.
|
||||
std::array<uint8_t, CONFIG_TINYUSB_CDC_TX_BUFSIZE> uart_rx_buffer_{};
|
||||
std::array<uint8_t, CONFIG_TINYUSB_CDC_RX_BUFSIZE> uart_tx_buffer_{};
|
||||
|
||||
uart::IDFUARTComponent *uart_parent_;
|
||||
usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent_;
|
||||
|
||||
// YAML framing, captured at setup; the host's line coding overwrites the UART's
|
||||
// settings, so pause() needs the original to restore.
|
||||
uint32_t configured_baud_rate_{0};
|
||||
uart::UARTParityOptions configured_parity_{uart::UART_CONFIG_PARITY_NONE};
|
||||
uint8_t configured_stop_bits_{0};
|
||||
uint8_t configured_data_bits_{0};
|
||||
|
||||
// Written on the main loop, read by both worker tasks. uint8_t rather than bool:
|
||||
// GCC on Xtensa emits an out-of-line call for atomic<bool>.
|
||||
std::atomic<uint8_t> paused_{0};
|
||||
// Raised by the RX task while parked and by the TX task around each UART write, so
|
||||
// the pause hand-off knows when the bus is actually free.
|
||||
std::atomic<uint8_t> rx_parked_{0};
|
||||
std::atomic<uint8_t> tx_busy_{0};
|
||||
// Main-loop state; paused_ mirrors it for the worker tasks.
|
||||
enum class MainState : uint8_t {
|
||||
MAIN_STATE_RUNNING,
|
||||
MAIN_STATE_RELOAD_PENDING, // host line coding debounced, forwarding continues
|
||||
MAIN_STATE_PAUSING, // waiting for TX idle to restore the configured framing
|
||||
MAIN_STATE_PAUSED,
|
||||
MAIN_STATE_RESUMING, // resume() requested while a host write still drains
|
||||
};
|
||||
MainState state_{MainState::MAIN_STATE_RUNNING};
|
||||
// Host line state, recorded even while paused so resume() can re-drive the pins.
|
||||
bool host_dtr_{false};
|
||||
bool host_rts_{false};
|
||||
// True once the host has sent any line coding; resume() then re-syncs to it.
|
||||
bool host_coding_seen_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::cdc_acm_uart
|
||||
#endif
|
||||
@@ -125,6 +125,19 @@ CLIMATE_SWING_MODES = {
|
||||
|
||||
validate_climate_swing_mode = cv.enum(CLIMATE_SWING_MODES, upper=True)
|
||||
|
||||
ClimateAction = climate_ns.enum("ClimateAction")
|
||||
CLIMATE_ACTIONS = {
|
||||
"OFF": ClimateAction.CLIMATE_ACTION_OFF,
|
||||
"COOLING": ClimateAction.CLIMATE_ACTION_COOLING,
|
||||
"HEATING": ClimateAction.CLIMATE_ACTION_HEATING,
|
||||
"IDLE": ClimateAction.CLIMATE_ACTION_IDLE,
|
||||
"DRYING": ClimateAction.CLIMATE_ACTION_DRYING,
|
||||
"FAN": ClimateAction.CLIMATE_ACTION_FAN,
|
||||
"DEFROSTING": ClimateAction.CLIMATE_ACTION_DEFROSTING,
|
||||
}
|
||||
|
||||
validate_climate_action = cv.enum(CLIMATE_ACTIONS, upper=True)
|
||||
|
||||
CONF_MIN_HUMIDITY = "min_humidity"
|
||||
CONF_MAX_HUMIDITY = "max_humidity"
|
||||
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||
|
||||
@@ -368,8 +368,8 @@ optional<ClimateDeviceRestoreState> Climate::restore_state_() {
|
||||
}
|
||||
|
||||
void Climate::save_state_(const ClimateTraits &traits) {
|
||||
#if (defined(USE_ESP32) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \
|
||||
!defined(CLANG_TIDY)
|
||||
#if (defined(USE_ESP32) || defined(USE_ESP8266)) && !defined(CLANG_TIDY)
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wclass-memaccess"
|
||||
#define TEMP_IGNORE_MEMACCESS
|
||||
#endif
|
||||
|
||||
@@ -100,7 +100,6 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma
|
||||
void CM1106Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "CM1106:");
|
||||
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
|
||||
this->check_uart_settings(9600);
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ CONFIG_SCHEMA = (
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"cm1106",
|
||||
baud_rate=9600,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
"""Code generation entry point."""
|
||||
|
||||
@@ -30,6 +30,7 @@ CONF_KEYS = "keys"
|
||||
CONF_LABEL = "label"
|
||||
CONF_LIBRETINY = "libretiny"
|
||||
CONF_LOOP = "loop"
|
||||
CONF_MANUFACTURER = "manufacturer"
|
||||
CONF_NOX_INDEX = "nox_index"
|
||||
CONF_ON_PACKET = "on_packet"
|
||||
CONF_ON_RECEIVE = "on_receive"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir
|
||||
from esphome.components import climate_ir, remote_base
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
@@ -12,4 +12,5 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(CoolixClimate)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
remote_base.request_protocol("coolix") # used from C++
|
||||
await climate_ir.new_climate_ir(config)
|
||||
|
||||
@@ -58,7 +58,6 @@ void CSE7761Component::dump_config() {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
}
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
this->check_uart_settings(38400, 1, uart::UART_CONFIG_PARITY_EVEN, 8);
|
||||
}
|
||||
|
||||
void CSE7761Component::update() {
|
||||
|
||||
@@ -68,7 +68,13 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"cse7761", baud_rate=38400, require_rx=True, require_tx=True
|
||||
"cse7761",
|
||||
baud_rate=38400,
|
||||
require_rx=True,
|
||||
require_tx=True,
|
||||
data_bits=8,
|
||||
parity="EVEN",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -255,7 +255,6 @@ void CSE7766Component::dump_config() {
|
||||
LOG_SENSOR(" ", "Apparent Power", this->apparent_power_sensor_);
|
||||
LOG_SENSOR(" ", "Reactive Power", this->reactive_power_sensor_);
|
||||
LOG_SENSOR(" ", "Power Factor", this->power_factor_sensor_);
|
||||
this->check_uart_settings(4800, 1, uart::UART_CONFIG_PARITY_EVEN);
|
||||
}
|
||||
|
||||
} // namespace esphome::cse7766
|
||||
|
||||
@@ -84,7 +84,12 @@ CONFIG_SCHEMA = (
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"cse7766", baud_rate=4800, parity="EVEN", require_rx=True
|
||||
"cse7766",
|
||||
baud_rate=4800,
|
||||
require_rx=True,
|
||||
data_bits=8,
|
||||
parity="EVEN",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace esphome::dallas_temp {
|
||||
static const char *const TAG = "dallas.temp.sensor";
|
||||
|
||||
static const uint8_t DALLAS_MODEL_DS18S20 = 0x10;
|
||||
static const uint8_t DALLAS_MODEL_DS18B20 = 0x28;
|
||||
static const uint8_t DALLAS_COMMAND_START_CONVERSION = 0x44;
|
||||
static const uint8_t DALLAS_COMMAND_READ_SCRATCH_PAD = 0xBE;
|
||||
static const uint8_t DALLAS_COMMAND_WRITE_SCRATCH_PAD = 0x4E;
|
||||
@@ -154,7 +155,14 @@ float DallasTemperatureSensor::get_temp_c_() {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// undocumented test for powerup measurement of 85
|
||||
// https://github.com/cpetrich/counterfeit_DS18B20#solution-to-the-85-c-problem
|
||||
if ((this->address_ & 0xff) == DALLAS_MODEL_DS18B20) {
|
||||
if ((temp == 85 * 16) && (this->scratch_pad_[6] == 0xc)) {
|
||||
ESP_LOGD(TAG, "dropping reading caused by sensor reset");
|
||||
return NAN;
|
||||
}
|
||||
}
|
||||
return temp / 16.0f;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,14 @@ CONFIG_SCHEMA = (
|
||||
.extend(cv.polling_component_schema("30s"))
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"daly_bms",
|
||||
baud_rate=9600,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -22,10 +22,7 @@ static const uint8_t DALY_REQUEST_TEMPERATURE = 0x96;
|
||||
|
||||
void DalyBmsComponent::setup() { this->next_request_ = 1; }
|
||||
|
||||
void DalyBmsComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Daly BMS:");
|
||||
this->check_uart_settings(9600);
|
||||
}
|
||||
void DalyBmsComponent::dump_config() { ESP_LOGCONFIG(TAG, "Daly BMS:"); }
|
||||
|
||||
void DalyBmsComponent::update() {
|
||||
this->trigger_next_ = true;
|
||||
|
||||
@@ -22,9 +22,9 @@ void DebugComponent::dump_config() {
|
||||
LOG_SENSOR(" ", "Free space on heap", this->free_sensor_);
|
||||
LOG_SENSOR(" ", "Largest free heap block", this->block_sensor_);
|
||||
LOG_SENSOR(" ", "CPU frequency", this->cpu_frequency_sensor_);
|
||||
#if defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)
|
||||
#ifdef USE_ESP8266
|
||||
LOG_SENSOR(" ", "Heap fragmentation", this->fragmentation_sensor_);
|
||||
#endif // defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)
|
||||
#endif // USE_ESP8266
|
||||
#endif // USE_SENSOR
|
||||
|
||||
char device_info_buffer[DEVICE_INFO_BUFFER_SIZE];
|
||||
|
||||
@@ -35,7 +35,7 @@ class DebugComponent final : public PollingComponent {
|
||||
#ifdef USE_SENSOR
|
||||
void set_free_sensor(sensor::Sensor *free_sensor) { free_sensor_ = free_sensor; }
|
||||
void set_block_sensor(sensor::Sensor *block_sensor) { block_sensor_ = block_sensor; }
|
||||
#if (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)) || defined(USE_ESP32)
|
||||
#if defined(USE_ESP8266) || defined(USE_ESP32)
|
||||
void set_fragmentation_sensor(sensor::Sensor *fragmentation_sensor) { fragmentation_sensor_ = fragmentation_sensor; }
|
||||
#endif
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
@@ -61,7 +61,7 @@ class DebugComponent final : public PollingComponent {
|
||||
|
||||
sensor::Sensor *free_sensor_{nullptr};
|
||||
sensor::Sensor *block_sensor_{nullptr};
|
||||
#if (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)) || defined(USE_ESP32)
|
||||
#if defined(USE_ESP8266) || defined(USE_ESP32)
|
||||
sensor::Sensor *fragmentation_sensor_{nullptr};
|
||||
#endif
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
|
||||
@@ -66,11 +66,15 @@ const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFE
|
||||
|
||||
unsigned reason = esp_reset_reason();
|
||||
if (reason < sizeof(RESET_REASONS) / sizeof(RESET_REASONS[0])) {
|
||||
if (reason == ESP_RST_SW) {
|
||||
if (reason == ESP_RST_SW || reason == ESP_RST_WDT) {
|
||||
// On some ESP32-S3 configurations (e.g. SPIRAM with fetch-instructions/rodata),
|
||||
// esp_restart() intermittently produces RTCWDT_RTC_RST (ESP_RST_WDT) instead of
|
||||
// ESP_RST_SW. Check the stored reboot source for both reset reasons so a software
|
||||
// reboot that ends up as WDT still reports the correct source.
|
||||
auto pref = global_preferences->make_preference(REBOOT_MAX_LEN,
|
||||
fnv1_hash_extend(fnv1_hash(REBOOT_KEY), App.get_name().c_str()));
|
||||
char reboot_source[REBOOT_MAX_LEN]{};
|
||||
if (pref.load(&reboot_source)) {
|
||||
if (pref.load(&reboot_source) && reboot_source[0] != '\0') {
|
||||
reboot_source[REBOOT_MAX_LEN - 1] = '\0';
|
||||
snprintf(buf, size, "Reboot request from %s", reboot_source);
|
||||
} else {
|
||||
|
||||
@@ -159,12 +159,10 @@ void DebugComponent::update_platform_() {
|
||||
// NOLINTNEXTLINE(readability-static-accessed-through-instance)
|
||||
this->block_sensor_->publish_state(ESP.getMaxFreeBlockSize());
|
||||
}
|
||||
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)
|
||||
if (this->fragmentation_sensor_ != nullptr) {
|
||||
// NOLINTNEXTLINE(readability-static-accessed-through-instance)
|
||||
this->fragmentation_sensor_->publish_state(ESP.getHeapFragmentation());
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -23,11 +23,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import ( # noqa: F401 pylint: disable=unused-import
|
||||
CONF_DEBUG_ID,
|
||||
FILTER_SOURCE_FILES,
|
||||
DebugComponent,
|
||||
)
|
||||
from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import
|
||||
|
||||
DEPENDENCIES = ["debug"]
|
||||
|
||||
@@ -52,12 +48,9 @@ CONFIG_SCHEMA = {
|
||||
),
|
||||
cv.Optional(CONF_FRAGMENTATION): cv.All(
|
||||
cv.Any(
|
||||
cv.All(
|
||||
cv.only_on_esp8266,
|
||||
cv.require_framework_version(esp8266_arduino=cv.Version(2, 5, 2)),
|
||||
),
|
||||
cv.only_on_esp8266,
|
||||
cv.only_on_esp32,
|
||||
msg="This feature is only available on ESP8266 (Arduino 2.5.2+) and ESP32",
|
||||
msg="This feature is only available on ESP8266 and ESP32",
|
||||
),
|
||||
sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
|
||||
@@ -9,11 +9,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import ( # noqa: F401 pylint: disable=unused-import
|
||||
CONF_DEBUG_ID,
|
||||
FILTER_SOURCE_FILES,
|
||||
DebugComponent,
|
||||
)
|
||||
from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import
|
||||
|
||||
DEPENDENCIES = ["debug"]
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ bool DeepSleepComponent::prepare_to_sleep_() {
|
||||
this->status_set_warning();
|
||||
ESP_LOGV(TAG, "Waiting for pin to switch state to enter deep sleep...");
|
||||
}
|
||||
this->next_enter_deep_sleep_ = true;
|
||||
this->defer_sleep_();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ void DeepSleepComponent::setup() {
|
||||
|
||||
void DeepSleepComponent::schedule_sleep_() {
|
||||
this->next_enter_deep_sleep_ = false;
|
||||
this->disable_loop();
|
||||
const optional<uint32_t> run_duration = get_run_duration_();
|
||||
if (run_duration.has_value()) {
|
||||
ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration);
|
||||
@@ -45,7 +46,7 @@ void DeepSleepComponent::loop() {
|
||||
|
||||
void DeepSleepComponent::begin_sleep(bool manual) {
|
||||
if (this->prevent_ && !manual) {
|
||||
this->next_enter_deep_sleep_ = true;
|
||||
this->defer_sleep_();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,11 @@ class DeepSleepComponent final : public Component {
|
||||
void schedule_sleep_();
|
||||
bool should_teardown_();
|
||||
|
||||
void defer_sleep_() {
|
||||
this->next_enter_deep_sleep_ = true;
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
#ifdef USE_BK72XX
|
||||
bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const;
|
||||
bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); }
|
||||
|
||||
@@ -100,7 +100,7 @@ bool DeepSleepComponent::prepare_to_sleep_() {
|
||||
this->status_set_warning();
|
||||
ESP_LOGW(TAG, "Waiting for wakeup pin state change");
|
||||
}
|
||||
this->next_enter_deep_sleep_ = true;
|
||||
this->defer_sleep_();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -60,7 +60,12 @@ CONFIG_SCHEMA = cv.All(
|
||||
).extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"dfplayer", baud_rate=9600, require_tx=True
|
||||
"dfplayer",
|
||||
baud_rate=9600,
|
||||
require_tx=True,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -277,9 +277,6 @@ void DFPlayer::loop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
void DFPlayer::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "DFPlayer:");
|
||||
this->check_uart_settings(9600);
|
||||
}
|
||||
void DFPlayer::dump_config() { ESP_LOGCONFIG(TAG, "DFPlayer:"); }
|
||||
|
||||
} // namespace esphome::dfplayer
|
||||
|
||||
@@ -153,13 +153,14 @@ bool ES7210::configure_mic_gain_() {
|
||||
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv));
|
||||
|
||||
// Configure mic 3
|
||||
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00));
|
||||
// MIC3 uses the ADC3/4 and MIC3/4 clock domains (bits 2 and 4), not the MIC1/2 domains.
|
||||
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00));
|
||||
ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00));
|
||||
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x10, 0x10));
|
||||
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, regv));
|
||||
|
||||
// Configure mic 4
|
||||
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00));
|
||||
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00));
|
||||
ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00));
|
||||
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x10, 0x10));
|
||||
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, regv));
|
||||
|
||||
@@ -189,6 +189,13 @@ PSRAM_XIP_VARIANTS = {
|
||||
VARIANT_ESP32S31,
|
||||
}
|
||||
|
||||
# Variants whose ROM exports a full-format vsnprintf but no vasprintf
|
||||
# (esp32c6.rom.newlib-normal.ld). There, the newlib printf engine is only
|
||||
# linked because esp_http_client calls vasprintf; see vasprintf_stubs.cpp.
|
||||
# The other variants either export both (classic ESP32, nano-format only) or
|
||||
# neither, so the engine is already in the image and the wrap saves nothing.
|
||||
ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6}
|
||||
|
||||
# NVS encryption (HMAC peripheral scheme) is only available on variants that
|
||||
# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original
|
||||
# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral
|
||||
@@ -1732,6 +1739,8 @@ CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary"
|
||||
CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs"
|
||||
CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert"
|
||||
CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7"
|
||||
CONF_DISABLE_MBEDTLS_TLS_SERVER = "disable_mbedtls_tls_server"
|
||||
CONF_DISABLE_MBEDTLS_TLS_EXTRAS = "disable_mbedtls_tls_extras"
|
||||
CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram"
|
||||
CONF_DISABLE_FATFS = "disable_fatfs"
|
||||
CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram"
|
||||
@@ -1746,6 +1755,8 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required"
|
||||
KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required"
|
||||
KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required"
|
||||
KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
|
||||
KEY_MBEDTLS_TLS_SERVER_REQUIRED = "mbedtls_tls_server_required"
|
||||
KEY_MBEDTLS_TLS_EXTRAS_REQUIRED = "mbedtls_tls_extras_required"
|
||||
KEY_FATFS_REQUIRED = "fatfs_required"
|
||||
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
|
||||
KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required"
|
||||
@@ -1830,6 +1841,30 @@ def require_mbedtls_pkcs7() -> None:
|
||||
CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True
|
||||
|
||||
|
||||
def require_mbedtls_tls_server() -> None:
|
||||
"""Mark that the mbedTLS server-side TLS/DTLS handshake is required.
|
||||
|
||||
Call this from components that accept TLS connections (OpenThread's DTLS
|
||||
commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from
|
||||
being selected.
|
||||
"""
|
||||
CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True
|
||||
|
||||
|
||||
def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None:
|
||||
"""Mark TLS features disabled by ``disable_mbedtls_tls_extras`` as required.
|
||||
|
||||
``options`` names the entries of ``MBEDTLS_TLS_EXTRA_OPTIONS`` to keep;
|
||||
omit it to keep all of them. Call this from components that need AES-CCM,
|
||||
deterministic ECDSA signing, static RSA/ECDH key exchange, TLS
|
||||
renegotiation or session tickets, or that run a TLS client against
|
||||
servers ESPHome cannot vet (wpa_supplicant's EAP client). A user-supplied
|
||||
sdkconfig_options value is never overridden either.
|
||||
"""
|
||||
required = CORE.data[KEY_ESP32].setdefault(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set())
|
||||
required.update(MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options)
|
||||
|
||||
|
||||
def require_mbedtls_sha512() -> None:
|
||||
"""Mark that mbedTLS SHA-384/SHA-512 support is required by a component.
|
||||
|
||||
@@ -1987,6 +2022,8 @@ FRAMEWORK_SCHEMA = cv.Schema(
|
||||
cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_TLS_SERVER, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_TLS_EXTRAS, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean,
|
||||
@@ -2302,6 +2339,69 @@ async def _reconcile_certificate_bundle_sdkconfig() -> None:
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True)
|
||||
|
||||
|
||||
# TLS features an HTTPS/MQTT client talking to a modern server never
|
||||
# negotiates. Static RSA and static ECDH key exchange have no forward secrecy
|
||||
# and are gone in TLS 1.3, renegotiation is deprecated, esp-tls never enables
|
||||
# session tickets, AES-CCM ciphersuites are not offered by web servers, and
|
||||
# deterministic ECDSA only matters when signing with a private key. Together
|
||||
# they cost ~10 KB of flash whenever TLS is linked (http_request, mqtt).
|
||||
# wpa_supplicant's EAP client is a second TLS client that talks to RADIUS
|
||||
# servers ESPHome cannot vet, and a failed EAP handshake leaves the device
|
||||
# off the network, so the wifi component re-enables all of these when eap is
|
||||
# configured.
|
||||
# The EC public key parsing extras stay enabled: they decide whether a peer
|
||||
# certificate with a compressed point or explicit curve parameters parses,
|
||||
# which no component can know ahead of time.
|
||||
MBEDTLS_TLS_EXTRA_OPTIONS = (
|
||||
"CONFIG_MBEDTLS_KEY_EXCHANGE_RSA",
|
||||
"CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA",
|
||||
"CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_RSA",
|
||||
"CONFIG_MBEDTLS_SSL_RENEGOTIATION",
|
||||
"CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS",
|
||||
"CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS",
|
||||
"CONFIG_MBEDTLS_CCM_C",
|
||||
"CONFIG_MBEDTLS_ECDSA_DETERMINISTIC",
|
||||
)
|
||||
|
||||
# Members of the mbedTLS "TLS Protocol Role" Kconfig choice. Setting one
|
||||
# member is only valid when the user has not already chosen another.
|
||||
MBEDTLS_TLS_ROLE_OPTIONS = (
|
||||
"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT",
|
||||
"CONFIG_MBEDTLS_TLS_SERVER_ONLY",
|
||||
"CONFIG_MBEDTLS_TLS_CLIENT_ONLY",
|
||||
"CONFIG_MBEDTLS_TLS_DISABLED",
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _reconcile_mbedtls_tls_sdkconfig(
|
||||
disable_tls_server: bool, disable_tls_extras: bool
|
||||
) -> None:
|
||||
"""Trim mbedTLS to what a TLS client needs unless a component asked otherwise.
|
||||
|
||||
Runs at FINAL priority so every require_mbedtls_tls_server() and
|
||||
require_mbedtls_tls_extras() call has happened. Only the server-side
|
||||
handshake (~7 KB) is a separate option; nothing in ESPHome accepts TLS
|
||||
connections, but OpenThread's DTLS commissioner does. A user-supplied
|
||||
sdkconfig_options value always wins; for the TLS role choice, any member
|
||||
the user set leaves the whole choice alone so the pair cannot conflict.
|
||||
"""
|
||||
data = CORE.data[KEY_ESP32]
|
||||
sdkconfig = data[KEY_SDKCONFIG_OPTIONS]
|
||||
if (
|
||||
disable_tls_server
|
||||
and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False)
|
||||
and not any(option in sdkconfig for option in MBEDTLS_TLS_ROLE_OPTIONS)
|
||||
):
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_CLIENT_ONLY", True)
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", False)
|
||||
if disable_tls_extras:
|
||||
required = data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set())
|
||||
for option in MBEDTLS_TLS_EXTRA_OPTIONS:
|
||||
if option not in required:
|
||||
set_idf_sdkconfig_default(option, False)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _reconcile_network_sdkconfig() -> None:
|
||||
"""Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags.
|
||||
@@ -2566,6 +2666,17 @@ async def to_code(config):
|
||||
else:
|
||||
for symbol in ("vprintf", "printf", "fprintf", "vfprintf"):
|
||||
cg.add_build_flag(f"-Wl,--wrap={symbol}")
|
||||
# esp_http_client calls vasprintf, which on the ESP32-C6 is the only
|
||||
# reference to newlib's full printf engine (~20 KB: _svfprintf_r,
|
||||
# _dtoa_r and their helpers); every other caller resolves to the
|
||||
# ROM. See vasprintf_stubs.cpp. The --undefined flag is needed
|
||||
# because libsrc.a is scanned before the IDF libraries that
|
||||
# reference the symbol, so the stub would otherwise never be pulled
|
||||
# from the archive.
|
||||
if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS:
|
||||
cg.add_define("USE_ESP32_VASPRINTF_STUB")
|
||||
cg.add_build_flag("-Wl,--wrap=vasprintf")
|
||||
cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf")
|
||||
else:
|
||||
cg.add_build_flag("-DUSE_ARDUINO")
|
||||
cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO")
|
||||
@@ -2991,6 +3102,13 @@ async def to_code(config):
|
||||
# FINAL priority: runs after every require_certificate_bundle() call
|
||||
CORE.add_job(_reconcile_certificate_bundle_sdkconfig)
|
||||
|
||||
# FINAL priority: runs after every require_mbedtls_tls_*() call
|
||||
CORE.add_job(
|
||||
_reconcile_mbedtls_tls_sdkconfig,
|
||||
advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER],
|
||||
advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS],
|
||||
)
|
||||
|
||||
# FINAL: require_*() calls can come from to_code at or below this priority, so an
|
||||
# inline read would be iteration-order-dependent; reconcile once after every job ran.
|
||||
CORE.add_job(
|
||||
|
||||
@@ -3,18 +3,11 @@ 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 ( # noqa: F401 # pylint: disable=unused-import
|
||||
KEY_ESP32,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_VARIANT,
|
||||
)
|
||||
from esphome.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION, KEY_VARIANT # noqa: F401 # pylint: disable=unused-import
|
||||
|
||||
# Back compat for external components only; in-tree callers import it
|
||||
# from esphome.espidf directly.
|
||||
from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import
|
||||
variant_to_idf_target,
|
||||
)
|
||||
from esphome.espidf import variant_to_idf_target # noqa: F401 # pylint: disable=unused-import
|
||||
|
||||
KEY_BOARD = "board"
|
||||
KEY_SDKCONFIG_OPTIONS = "sdkconfig_options"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Linker wrap stub for vasprintf() on variants whose ROM exports a
|
||||
* full-format vsnprintf() but no vasprintf() (ESP32-C6, newlib only).
|
||||
*
|
||||
* On those chips every snprintf/vsnprintf call in the image resolves to
|
||||
* the ROM, so the newlib printf engine (_svfprintf_r, _dtoa_r and their
|
||||
* helpers, ~20 KB) is not linked at all until something references a
|
||||
* printf-family function the ROM lacks. esp_http_client does exactly that
|
||||
* through vasprintf() in its header and auth helpers, so adding
|
||||
* http_request to a build costs the whole engine on top of the HTTP and
|
||||
* TLS code itself.
|
||||
*
|
||||
* This stub reimplements vasprintf() on top of the ROM vsnprintf(), which
|
||||
* keeps the engine out of the image. It is only compiled in when codegen
|
||||
* defines USE_ESP32_VASPRINTF_STUB, which is gated on the variant's ROM
|
||||
* linker script and on the same newlib condition as printf_stubs.cpp.
|
||||
*/
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP_IDF) && defined(USE_ESP32_VASPRINTF_STUB)
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace esphome::esp32 {}
|
||||
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" {
|
||||
|
||||
int __wrap_vasprintf(char **strp, const char *fmt, va_list ap) {
|
||||
va_list ap_copy;
|
||||
va_copy(ap_copy, ap);
|
||||
int len = vsnprintf(nullptr, 0, fmt, ap_copy);
|
||||
va_end(ap_copy);
|
||||
if (len < 0) {
|
||||
return len;
|
||||
}
|
||||
// vasprintf's contract is a malloc'd buffer the caller releases with free()
|
||||
char *buf = static_cast<char *>(malloc(static_cast<size_t>(len) + 1)); // NOLINT(cppcoreguidelines-no-malloc)
|
||||
if (buf == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
vsnprintf(buf, static_cast<size_t>(len) + 1, fmt, ap);
|
||||
*strp = buf;
|
||||
return len;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
|
||||
#endif // USE_ESP_IDF && USE_ESP32_VASPRINTF_STUB
|
||||
@@ -83,38 +83,60 @@ 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
|
||||
void ESP32BLE::advertising_start() {
|
||||
this->advertising_init_();
|
||||
if (!this->is_active())
|
||||
this->advertising_ref_count_++;
|
||||
this->advertising_refresh();
|
||||
}
|
||||
|
||||
void ESP32BLE::advertising_stop() {
|
||||
if (this->advertising_ref_count_ == 0)
|
||||
return;
|
||||
this->advertising_->start();
|
||||
this->advertising_ref_count_--;
|
||||
this->advertising_refresh();
|
||||
}
|
||||
|
||||
void ESP32BLE::advertising_refresh() {
|
||||
if (this->advertising_ == nullptr || !this->is_active())
|
||||
return;
|
||||
// Advertise while any component still needs it, otherwise stop
|
||||
if (this->advertising_ref_count_ == 0) {
|
||||
this->advertising_->stop();
|
||||
} else {
|
||||
this->advertising_->start();
|
||||
}
|
||||
}
|
||||
|
||||
void ESP32BLE::advertising_set_service_data(const std::vector<uint8_t> &data) {
|
||||
this->advertising_init_();
|
||||
this->advertising_->set_service_data(data);
|
||||
this->advertising_start();
|
||||
this->advertising_refresh();
|
||||
}
|
||||
|
||||
void ESP32BLE::advertising_set_manufacturer_data(const std::vector<uint8_t> &data) {
|
||||
this->advertising_init_();
|
||||
this->advertising_->set_manufacturer_data(data);
|
||||
this->advertising_start();
|
||||
this->advertising_refresh();
|
||||
}
|
||||
|
||||
void ESP32BLE::advertising_set_service_data_and_name(std::span<const uint8_t> data, bool include_name) {
|
||||
@@ -136,7 +158,7 @@ void ESP32BLE::advertising_set_service_data_and_name(std::span<const uint8_t> da
|
||||
this->advertising_->set_service_data(data);
|
||||
}
|
||||
|
||||
this->advertising_start();
|
||||
this->advertising_refresh();
|
||||
}
|
||||
|
||||
void ESP32BLE::advertising_register_raw_advertisement_callback(std::function<void(bool)> &&callback) {
|
||||
@@ -147,13 +169,13 @@ void ESP32BLE::advertising_register_raw_advertisement_callback(std::function<voi
|
||||
void ESP32BLE::advertising_add_service_uuid(ESPBTUUID uuid) {
|
||||
this->advertising_init_();
|
||||
this->advertising_->add_service_uuid(uuid);
|
||||
this->advertising_start();
|
||||
this->advertising_refresh();
|
||||
}
|
||||
|
||||
void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) {
|
||||
this->advertising_init_();
|
||||
this->advertising_->remove_service_uuid(uuid);
|
||||
this->advertising_start();
|
||||
this->advertising_refresh();
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -563,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;
|
||||
@@ -575,6 +601,10 @@ void ESP32BLE::loop_handle_state_transition_not_active_() {
|
||||
}
|
||||
|
||||
this->state_ = BLE_COMPONENT_STATE_ACTIVE;
|
||||
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||
// Requests made before the stack was up (or before it was re-enabled) take effect now
|
||||
this->advertising_refresh();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -114,7 +114,17 @@ class ESP32BLE final : public Component {
|
||||
void set_name(const char *name) { this->name_ = name; }
|
||||
|
||||
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||
/** Request advertising on behalf of a component.
|
||||
*
|
||||
* Requests are reference counted: advertising runs until every component that called
|
||||
* advertising_start() has released it again with advertising_stop(). Each component must
|
||||
* pair its calls, so nothing advertises until something actually asks for it.
|
||||
*/
|
||||
void advertising_start();
|
||||
/// Release a request made with advertising_start(); advertising stops at the last release.
|
||||
void advertising_stop();
|
||||
/// Apply the current payload and request count: advertise while requested, otherwise stop.
|
||||
void advertising_refresh();
|
||||
void advertising_set_service_data(const std::vector<uint8_t> &data);
|
||||
void advertising_set_manufacturer_data(const std::vector<uint8_t> &data);
|
||||
void advertising_set_appearance(uint16_t appearance) { this->appearance_ = appearance; }
|
||||
@@ -166,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_();
|
||||
@@ -226,6 +245,9 @@ class ESP32BLE final : public Component {
|
||||
// 1-byte aligned members (grouped together to minimize padding)
|
||||
BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum)
|
||||
bool enable_on_boot_{}; // 1 byte
|
||||
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||
uint8_t advertising_ref_count_{0}; // 1 byte, number of components requesting advertising
|
||||
#endif
|
||||
|
||||
#ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS
|
||||
optional<esp_ble_auth_req_t> auth_req_mode_;
|
||||
|
||||
@@ -67,6 +67,8 @@ void ESP32BLEBeacon::setup() {
|
||||
this->on_advertise_();
|
||||
}
|
||||
});
|
||||
// A beacon always needs the device to advertise, and never releases the request
|
||||
global_ble->advertising_start();
|
||||
}
|
||||
|
||||
void ESP32BLEBeacon::on_advertise_() {
|
||||
|
||||
@@ -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_() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import encodings
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble
|
||||
from esphome.components.const import CONF_MANUFACTURER
|
||||
from esphome.components.esp32 import request_bluetooth
|
||||
from esphome.components.esp32_ble import BTLoggers, bt_uuid
|
||||
import esphome.config_validation as cv
|
||||
@@ -41,7 +42,6 @@ CONF_DESCRIPTORS = "descriptors"
|
||||
CONF_ENDIANNESS = "endianness"
|
||||
CONF_FIRMWARE_VERSION = "firmware_version"
|
||||
CONF_INDICATE = "indicate"
|
||||
CONF_MANUFACTURER = "manufacturer"
|
||||
CONF_MANUFACTURER_DATA = "manufacturer_data"
|
||||
CONF_MAX_CLIENTS = "max_clients"
|
||||
CONF_ON_WRITE = "on_write"
|
||||
@@ -596,6 +596,18 @@ async def to_code(config):
|
||||
cg.add(var.set_parent(parent))
|
||||
cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE]))
|
||||
cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS]))
|
||||
# Only advertise for the server itself when the configuration gives clients something to
|
||||
# find. A server that is auto-loaded purely to host a runtime service (esp32_improv) stays
|
||||
# silent until that service asks for advertising.
|
||||
cg.add(
|
||||
var.set_advertising_required(
|
||||
CONF_MANUFACTURER_DATA in config
|
||||
or any(
|
||||
not uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID)
|
||||
for service_config in config[CONF_SERVICES]
|
||||
)
|
||||
)
|
||||
)
|
||||
if CONF_MANUFACTURER_DATA in config:
|
||||
cg.add(var.set_manufacturer_data(config[CONF_MANUFACTURER_DATA]))
|
||||
for service_config in config[CONF_SERVICES]:
|
||||
|
||||
@@ -81,6 +81,7 @@ void BLEServer::loop() {
|
||||
if (this->device_information_service_->is_running()) {
|
||||
this->state_ = RUNNING;
|
||||
this->restart_advertising_();
|
||||
this->request_advertising_();
|
||||
ESP_LOGD(TAG, "BLE server setup successfully");
|
||||
} else if (this->device_information_service_->is_created()) {
|
||||
this->device_information_service_->start();
|
||||
@@ -98,6 +99,20 @@ void BLEServer::restart_advertising_() {
|
||||
}
|
||||
}
|
||||
|
||||
void BLEServer::request_advertising_() {
|
||||
if (!this->advertising_required_ || this->advertising_requested_)
|
||||
return;
|
||||
this->advertising_requested_ = true;
|
||||
this->parent_->advertising_start();
|
||||
}
|
||||
|
||||
void BLEServer::release_advertising_() {
|
||||
if (!this->advertising_requested_)
|
||||
return;
|
||||
this->advertising_requested_ = false;
|
||||
this->parent_->advertising_stop();
|
||||
}
|
||||
|
||||
BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t num_handles) {
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char uuid_buf[esp32_ble::UUID_STR_LEN];
|
||||
@@ -170,7 +185,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga
|
||||
this->add_client_(param->connect.conn_id);
|
||||
// Resume advertising so additional clients can discover and connect
|
||||
if (this->client_count_ < this->max_clients_) {
|
||||
this->parent_->advertising_start();
|
||||
this->parent_->advertising_refresh();
|
||||
}
|
||||
this->dispatch_callbacks_(CallbackType::ON_CONNECT, param->connect.conn_id);
|
||||
break;
|
||||
@@ -178,7 +193,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga
|
||||
case ESP_GATTS_DISCONNECT_EVT: {
|
||||
ESP_LOGD(TAG, "BLE Client disconnected");
|
||||
this->remove_client_(param->disconnect.conn_id);
|
||||
this->parent_->advertising_start();
|
||||
this->parent_->advertising_refresh();
|
||||
this->dispatch_callbacks_(CallbackType::ON_DISCONNECT, param->disconnect.conn_id);
|
||||
break;
|
||||
}
|
||||
@@ -226,6 +241,8 @@ void BLEServer::remove_client_(uint16_t conn_id) {
|
||||
}
|
||||
|
||||
void BLEServer::ble_before_disabled_event_handler() {
|
||||
// Advertising is re-requested once the server is running again after BLE is re-enabled
|
||||
this->release_advertising_();
|
||||
// Delete all clients
|
||||
this->client_count_ = 0;
|
||||
// Delete all services
|
||||
|
||||
@@ -38,6 +38,13 @@ class BLEServer final : public Component, public Parented<ESP32BLE> {
|
||||
this->restart_advertising_();
|
||||
}
|
||||
|
||||
/** Whether this server needs the device to advertise so clients can find and connect to it.
|
||||
*
|
||||
* False for a server that only hosts services created at runtime (e.g. esp32_improv), which
|
||||
* request advertising themselves for as long as they need it.
|
||||
*/
|
||||
void set_advertising_required(bool required) { this->advertising_required_ = required; }
|
||||
|
||||
void set_max_clients(uint8_t max_clients) { this->max_clients_ = max_clients; }
|
||||
uint8_t get_max_clients() const { return this->max_clients_; }
|
||||
|
||||
@@ -82,6 +89,8 @@ class BLEServer final : public Component, public Parented<ESP32BLE> {
|
||||
};
|
||||
|
||||
void restart_advertising_();
|
||||
void request_advertising_();
|
||||
void release_advertising_();
|
||||
|
||||
int8_t find_client_index_(uint16_t conn_id) const;
|
||||
void add_client_(uint16_t conn_id);
|
||||
@@ -93,6 +102,8 @@ class BLEServer final : public Component, public Parented<ESP32BLE> {
|
||||
std::vector<uint8_t> manufacturer_data_{};
|
||||
esp_gatt_if_t gatts_if_{0};
|
||||
bool registered_{false};
|
||||
bool advertising_required_{true};
|
||||
bool advertising_requested_{false};
|
||||
|
||||
uint16_t clients_[USE_ESP32_BLE_MAX_CONNECTIONS]{};
|
||||
uint8_t client_count_{0};
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -37,6 +37,25 @@ CONF_HANDSHAKE_PIN = "handshake_pin"
|
||||
CONF_SDIO_FREQUENCY = "sdio_frequency"
|
||||
CONF_SPI_MODE = "spi_mode"
|
||||
|
||||
# ESP-NOW-over-hosted shim (esp_now_hosted.cpp). esp-hosted proxies esp_wifi.h
|
||||
# but not esp_now.h (espressif/esp-hosted-mcu#19), and esp_wifi_remote injects
|
||||
# the esp_now.h header on the ESP32-P4 host with no implementation, leaving the
|
||||
# esp_now_* symbols undefined at link. On a P4 host, esp_now_hosted.cpp DEFINES
|
||||
# those symbols and forwards each call to the co-processor over esp-hosted's
|
||||
# CustomRpc "peer data transfer" channel, so ESPHome's `espnow` component links
|
||||
# and runs unchanged (proven on a Tab5, 2026-07-20). The .cpp is guarded to
|
||||
# CONFIG_IDF_TARGET_ESP32P4 so it compiles to nothing on hosts with a native
|
||||
# ESP-NOW stack. CustomRpc needs these two host-side Kconfig options. Host
|
||||
# registers 3 handlers (RESP, RECV, SEND); the coprocessor registers 1 (REQ);
|
||||
# we ask for 8 to leave room for other CustomRpc extensions alongside.
|
||||
#
|
||||
# The coprocessor must run the matching custom firmware (a parallel effort in
|
||||
# esphome/esp-hosted-firmware). esp_now_hosted_rpc.h here is the canonical copy
|
||||
# of the wire contract and MUST stay byte-identical to the copy that coprocessor
|
||||
# firmware uses — the packed structs are the on-wire layout, so any divergence
|
||||
# silently corrupts every ESP-NOW frame.
|
||||
_MAX_CUSTOM_MSG_HANDLERS = 8
|
||||
|
||||
# Shared fields for both transport modes
|
||||
BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
@@ -262,6 +281,23 @@ async def to_code(config: ConfigType) -> None:
|
||||
else:
|
||||
_configure_spi(config)
|
||||
|
||||
# ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see
|
||||
# the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not
|
||||
# gated on the `espnow` component being present: the shim is tiny and the
|
||||
# esp_now_* symbols/CustomRpc calls it defines require these Kconfig options
|
||||
# to link whenever esp_now_hosted.cpp compiles (which is on any P4 host), so
|
||||
# coupling the two keeps the build consistent. When `espnow` is absent the
|
||||
# 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-hosted's CustomRpc ("peer data transfer") path — off by default.
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True
|
||||
)
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS
|
||||
)
|
||||
|
||||
# Place the transport mempool in PSRAM. Required on memory-tight host
|
||||
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
|
||||
# mempool allocation fails at boot with `sdio_mempool_create` assert.
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
/*
|
||||
* esp_now_hosted — host-side shim implementing <esp_now.h> over esp-hosted
|
||||
* CustomRpc, so ESPHome's `espnow` component can run on a radio-less host
|
||||
* (e.g. the ESP32-P4) whose radio lives on an esp-hosted co-processor.
|
||||
*
|
||||
* A radio-less host has no native ESP-NOW. esp_wifi_remote INJECTS the full
|
||||
* esp_now.h header (types + declarations) but ships NO implementation, so every
|
||||
* esp_now_* symbol is an undefined reference at link time. This translation
|
||||
* unit provides those definitions; each forwards to the co-processor over
|
||||
* CustomRpc (see esphome/esp-hosted-firmware for the matching coprocessor
|
||||
* handlers). No esp-hosted or esp_wifi_remote source is patched, and there is no
|
||||
* duplicate-symbol clash because nothing else defines these symbols here.
|
||||
*
|
||||
* See esp_now_hosted_rpc.h for the wire protocol.
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
// Only build the shim on the radio-less host. On chips with a native ESP-NOW
|
||||
// stack (S3, C6, …) the real symbols exist and this file must stay empty to
|
||||
// avoid duplicate definitions.
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32P4)
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
#include "esp_idf_version.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
|
||||
#include <esp_now.h> // injected declarations we are now DEFINING
|
||||
#include <esp_wifi_types.h> // wifi_pkt_rx_ctrl_t, wifi_tx_info_t
|
||||
|
||||
// esp_hosted_misc.h (host) ships WITHOUT an extern "C" guard, so including it
|
||||
// from C++ would give its declarations C++ linkage and the real C symbols in
|
||||
// libesp_hosted would go unresolved at link. Wrap it. (Verified vs
|
||||
// esp_hosted 2.12.9.)
|
||||
extern "C" {
|
||||
#include "esp_hosted_misc.h" // esp_hosted_{send_custom_data,register_custom_callback}
|
||||
}
|
||||
|
||||
#include "esp_now_hosted_rpc.h"
|
||||
|
||||
namespace {
|
||||
|
||||
const char *const TAG = "esp_now_hosted";
|
||||
|
||||
// One outstanding request at a time. ESPHome drives esp_now_* from the main
|
||||
// loop; the matching response and the async RECV/SEND events all arrive on the
|
||||
// single esp-hosted RPC RX thread. Serializing requests keeps the shared
|
||||
// response slot race-free; a sequence number stops a late/stale response from
|
||||
// being mistaken for ours.
|
||||
SemaphoreHandle_t g_req_mutex = nullptr;
|
||||
SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands
|
||||
bool g_setup_done = false; // set only after setup fully succeeds
|
||||
uint8_t g_seq = 0;
|
||||
volatile uint8_t g_expect_seq = 0;
|
||||
volatile int32_t g_resp_status = 0;
|
||||
uint8_t g_resp_ret[16];
|
||||
volatile uint16_t g_resp_ret_len = 0;
|
||||
|
||||
// Written from the main loop (register/unregister/deinit), read from the
|
||||
// esp-hosted RX thread (on_recv/on_send). volatile for the same reason the
|
||||
// g_resp_* globals are: force the RX thread to observe an updated pointer
|
||||
// (e.g. a nulling by esp_now_deinit) rather than a cached one.
|
||||
volatile esp_now_recv_cb_t g_recv_cb = nullptr;
|
||||
volatile esp_now_send_cb_t g_send_cb = nullptr;
|
||||
|
||||
// Local mirror of the co-processor's peer table. ESPHome's espnow component
|
||||
// calls esp_now_is_peer_exist() on the main loop for every received frame
|
||||
// (twice) and every send; forwarding each as a blocking RPC round-trip stalls
|
||||
// the loop. The shim is the only path that mutates the co-processor peer table
|
||||
// (add/del/deinit all go through here), so this mirror is authoritative and
|
||||
// esp_now_is_peer_exist() can answer from it with no round-trip.
|
||||
//
|
||||
// esp_now_* are public C symbols: any component or user lambda may call them,
|
||||
// and although ESPHome's espnow touches peers only from the main loop today
|
||||
// (its RX/TX callbacks merely enqueue), the shim cannot rely on that. A short
|
||||
// spinlock keeps the mirror consistent from any task/core, matching native
|
||||
// esp_now_*'s own internal thread-safety. The critical sections are a bounded
|
||||
// (<=20-entry) scan, so they stay tiny. ESP_NOW_MAX_TOTAL_PEER_NUM is 20.
|
||||
constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20;
|
||||
uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6];
|
||||
size_t g_peer_count = 0;
|
||||
portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
// Caller must hold g_peer_lock.
|
||||
int peer_cache_find_locked(const uint8_t *mac) {
|
||||
for (size_t i = 0; i < g_peer_count; i++) {
|
||||
if (memcmp(g_peer_cache[i], mac, 6) == 0)
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool peer_cache_contains(const uint8_t *mac) {
|
||||
portENTER_CRITICAL(&g_peer_lock);
|
||||
const bool found = peer_cache_find_locked(mac) >= 0;
|
||||
portEXIT_CRITICAL(&g_peer_lock);
|
||||
return found;
|
||||
}
|
||||
|
||||
void peer_cache_add(const uint8_t *mac) {
|
||||
portENTER_CRITICAL(&g_peer_lock);
|
||||
if (peer_cache_find_locked(mac) < 0 && g_peer_count < ESP_NOW_HOSTED_MAX_PEERS)
|
||||
memcpy(g_peer_cache[g_peer_count++], mac, 6);
|
||||
portEXIT_CRITICAL(&g_peer_lock);
|
||||
}
|
||||
|
||||
void peer_cache_remove(const uint8_t *mac) {
|
||||
portENTER_CRITICAL(&g_peer_lock);
|
||||
const int idx = peer_cache_find_locked(mac);
|
||||
if (idx >= 0) {
|
||||
g_peer_count--;
|
||||
if (static_cast<size_t>(idx) != g_peer_count) // move the last entry into the gap
|
||||
memcpy(g_peer_cache[idx], g_peer_cache[g_peer_count], 6);
|
||||
}
|
||||
portEXIT_CRITICAL(&g_peer_lock);
|
||||
}
|
||||
|
||||
void peer_cache_clear() {
|
||||
portENTER_CRITICAL(&g_peer_lock);
|
||||
g_peer_count = 0;
|
||||
portEXIT_CRITICAL(&g_peer_lock);
|
||||
}
|
||||
|
||||
// ── CustomRpc event handlers (run on the esp-hosted RPC RX thread) ──────────
|
||||
// Keep them short and non-blocking. In particular they MUST NOT call back into
|
||||
// any esp_now_* shim function: that would try to take g_req_mutex / wait on the
|
||||
// RX thread that delivers the response, and deadlock.
|
||||
|
||||
void on_resp(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) {
|
||||
if (len < sizeof(esp_now_hosted_resp_t)) {
|
||||
ESP_LOGW(TAG, "RESP too short: %u bytes", static_cast<unsigned>(len));
|
||||
return;
|
||||
}
|
||||
const auto *r = reinterpret_cast<const esp_now_hosted_resp_t *>(data);
|
||||
if (r->seq != g_expect_seq) { // late response from a timed-out request (expected)
|
||||
ESP_LOGV(TAG, "dropping stale RESP seq %u (want %u)", r->seq, g_expect_seq);
|
||||
return;
|
||||
}
|
||||
g_resp_status = r->status;
|
||||
uint16_t rl = r->ret_len;
|
||||
if (rl > sizeof(g_resp_ret)) {
|
||||
// Larger than any real opcode return — a likely wire-format drift signal.
|
||||
ESP_LOGW(TAG, "RESP ret_len %u exceeds buffer, clamping (wire drift?)", rl);
|
||||
rl = sizeof(g_resp_ret);
|
||||
}
|
||||
if (len >= sizeof(esp_now_hosted_resp_t) + rl) {
|
||||
memcpy(g_resp_ret, r->ret, rl);
|
||||
} else {
|
||||
// Truncated frame: fail closed. Never hand the caller stale bytes left in
|
||||
// g_resp_ret by a previous response, and don't let request() report a
|
||||
// zeroed payload as success — override the status to an error.
|
||||
ESP_LOGW(TAG, "RESP truncated: claims %u ret bytes, frame too short", rl);
|
||||
rl = 0;
|
||||
g_resp_status = ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
g_resp_ret_len = rl;
|
||||
xSemaphoreGive(g_resp_sem);
|
||||
}
|
||||
|
||||
void on_recv(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) {
|
||||
// Read the volatile pointer once: esp_now_unregister_recv_cb()/deinit() (via
|
||||
// the espnow component's disable()) can null it on the main loop between the
|
||||
// guard and the call, which would otherwise turn the call into a null-deref.
|
||||
const esp_now_recv_cb_t cb = g_recv_cb;
|
||||
if (cb == nullptr)
|
||||
return;
|
||||
if (len < sizeof(esp_now_hosted_recv_evt_t)) {
|
||||
ESP_LOGW(TAG, "RECV too short: %u bytes", static_cast<unsigned>(len));
|
||||
return;
|
||||
}
|
||||
const auto *e = reinterpret_cast<const esp_now_hosted_recv_evt_t *>(data);
|
||||
if (len < sizeof(esp_now_hosted_recv_evt_t) + e->data_len) {
|
||||
ESP_LOGW(TAG, "RECV data_len %u exceeds frame", e->data_len);
|
||||
return;
|
||||
}
|
||||
|
||||
// ESPHome dereferences info->rx_ctrl->{rssi,timestamp}; give it a real one.
|
||||
wifi_pkt_rx_ctrl_t rx_ctrl;
|
||||
memset(&rx_ctrl, 0, sizeof(rx_ctrl));
|
||||
rx_ctrl.rssi = e->rssi;
|
||||
rx_ctrl.channel = e->channel;
|
||||
rx_ctrl.timestamp = static_cast<uint32_t>(esp_timer_get_time());
|
||||
|
||||
esp_now_recv_info_t info;
|
||||
info.src_addr = const_cast<uint8_t *>(e->src_addr);
|
||||
info.des_addr = const_cast<uint8_t *>(e->des_addr);
|
||||
info.rx_ctrl = &rx_ctrl;
|
||||
cb(&info, e->data, static_cast<int>(e->data_len));
|
||||
}
|
||||
|
||||
void on_send(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) {
|
||||
// Read the volatile pointer once (see on_recv): disable()/deinit() can null it
|
||||
// on the main loop concurrently with this RX-thread callback.
|
||||
const esp_now_send_cb_t cb = g_send_cb;
|
||||
if (cb == nullptr)
|
||||
return;
|
||||
if (len < sizeof(esp_now_hosted_send_evt_t)) {
|
||||
ESP_LOGW(TAG, "SEND evt too short: %u bytes", static_cast<unsigned>(len));
|
||||
return;
|
||||
}
|
||||
const auto *e = reinterpret_cast<const esp_now_hosted_send_evt_t *>(data);
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
|
||||
// IDF >= 5.5: esp_now_send_cb_t takes esp_now_send_info_t (== wifi_tx_info_t),
|
||||
// whose des_addr is a POINTER (not an inline array). Point it at the event's
|
||||
// MAC (valid for this callback) — do NOT memcpy into it (that writes NULL and
|
||||
// faults). ESPHome reads only info->des_addr.
|
||||
esp_now_send_info_t si;
|
||||
memset(&si, 0, sizeof(si));
|
||||
si.des_addr = const_cast<uint8_t *>(e->des_addr);
|
||||
cb(&si, static_cast<esp_now_send_status_t>(e->status));
|
||||
#else
|
||||
cb(e->des_addr, static_cast<esp_now_send_status_t>(e->status));
|
||||
#endif
|
||||
}
|
||||
|
||||
esp_err_t ensure_setup() {
|
||||
// Gate on g_setup_done, not on g_req_mutex: a failure part-way through (a
|
||||
// semaphore that did not allocate, a callback that did not register) must not
|
||||
// leave a later call thinking setup completed. Semaphore creation is guarded
|
||||
// so a retry after a partial failure does not leak the earlier handles.
|
||||
if (g_setup_done)
|
||||
return ESP_OK;
|
||||
if (g_req_mutex == nullptr)
|
||||
g_req_mutex = xSemaphoreCreateMutex();
|
||||
if (g_resp_sem == nullptr)
|
||||
g_resp_sem = xSemaphoreCreateBinary();
|
||||
if (g_req_mutex == nullptr || g_resp_sem == nullptr)
|
||||
return ESP_ERR_NO_MEM;
|
||||
esp_err_t err;
|
||||
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK)
|
||||
return err;
|
||||
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK)
|
||||
return err;
|
||||
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK)
|
||||
return err;
|
||||
g_setup_done = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// Send one request envelope. With wait=true (default) block until the matching
|
||||
// response (or timeout); with wait=false return as soon as the frame is handed
|
||||
// to the transport (fire-and-forget, used by esp_now_send).
|
||||
//
|
||||
// `tail` is an optional second chunk written straight after `payload`. Callers
|
||||
// with a fixed header plus a bulk body (esp_now_send) pass the two separately
|
||||
// so they never need a build buffer of their own: both chunks are laid into the
|
||||
// request buffer here, under g_req_mutex, which keeps concurrent callers from
|
||||
// racing and saves a full copy of the body on every transmit.
|
||||
esp_err_t request(uint8_t opcode, const void *payload, uint16_t plen, void *ret, uint16_t ret_cap, uint16_t *ret_len,
|
||||
bool wait = true, const void *tail = nullptr, uint16_t tail_len = 0) {
|
||||
esp_err_t err = ensure_setup();
|
||||
if (err != ESP_OK)
|
||||
return err;
|
||||
if (plen > ESP_NOW_HOSTED_MAX_PAYLOAD || tail_len > ESP_NOW_HOSTED_MAX_PAYLOAD - plen)
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
const uint16_t total_len = static_cast<uint16_t>(plen + tail_len);
|
||||
|
||||
if (xSemaphoreTake(g_req_mutex, portMAX_DELAY) != pdTRUE)
|
||||
return ESP_FAIL;
|
||||
|
||||
static uint8_t buf[sizeof(esp_now_hosted_req_t) + ESP_NOW_HOSTED_MAX_PAYLOAD]; // guarded by g_req_mutex
|
||||
auto *req = reinterpret_cast<esp_now_hosted_req_t *>(buf);
|
||||
req->opcode = opcode;
|
||||
req->seq = ++g_seq;
|
||||
req->payload_len = total_len;
|
||||
if (plen != 0)
|
||||
memcpy(req->payload, payload, plen);
|
||||
if (tail_len != 0)
|
||||
memcpy(req->payload + plen, tail, tail_len);
|
||||
g_expect_seq = req->seq;
|
||||
|
||||
xSemaphoreTake(g_resp_sem, 0); // drain any stale signal before sending
|
||||
err = esp_hosted_send_custom_data(ESP_NOW_HOSTED_MSG_REQ, buf, sizeof(esp_now_hosted_req_t) + total_len);
|
||||
if (err != ESP_OK) {
|
||||
xSemaphoreGive(g_req_mutex);
|
||||
return err;
|
||||
}
|
||||
if (!wait) {
|
||||
// Fire-and-forget (esp_now_send): the co-processor enqueues the frame and
|
||||
// reports the real TX result later via the async SEND event, exactly like
|
||||
// native esp_now_send. Returning here keeps the main loop off the ~100 ms+
|
||||
// RPC round-trip. The matching RESP is ignored (seq won't match the next
|
||||
// waited request, so on_resp drops it).
|
||||
xSemaphoreGive(g_req_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (xSemaphoreTake(g_resp_sem, pdMS_TO_TICKS(ESP_NOW_HOSTED_TIMEOUT_MS)) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "opcode %u timed out", opcode);
|
||||
xSemaphoreGive(g_req_mutex);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
const int32_t status = g_resp_status;
|
||||
if (ret != nullptr && ret_cap != 0) {
|
||||
uint16_t n = g_resp_ret_len < ret_cap ? g_resp_ret_len : ret_cap;
|
||||
memcpy(ret, const_cast<const uint8_t *>(g_resp_ret), n);
|
||||
if (ret_len != nullptr)
|
||||
*ret_len = n;
|
||||
}
|
||||
xSemaphoreGive(g_req_mutex);
|
||||
return static_cast<esp_err_t>(status);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ── The <esp_now.h> surface, defined for the radio-less host ────────────────
|
||||
extern "C" {
|
||||
|
||||
esp_err_t esp_now_init(void) { return request(ESP_NOW_HOSTED_OP_INIT, nullptr, 0, nullptr, 0, nullptr); }
|
||||
|
||||
esp_err_t esp_now_deinit(void) {
|
||||
g_recv_cb = nullptr;
|
||||
g_send_cb = nullptr;
|
||||
peer_cache_clear(); // the co-processor drops all peers on deinit
|
||||
return request(ESP_NOW_HOSTED_OP_DEINIT, nullptr, 0, nullptr, 0, nullptr);
|
||||
}
|
||||
|
||||
esp_err_t esp_now_get_version(uint32_t *version) {
|
||||
uint32_t v = 0;
|
||||
uint16_t rl = 0;
|
||||
esp_err_t err = request(ESP_NOW_HOSTED_OP_GET_VERSION, nullptr, 0, &v, sizeof(v), &rl);
|
||||
if (version != nullptr)
|
||||
*version = v;
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_register_recv_cb(esp_now_recv_cb_t cb) {
|
||||
// Only arm the callback once the CustomRpc handlers are actually registered,
|
||||
// so a failed setup leaves g_recv_cb null rather than falsely "registered".
|
||||
esp_err_t err = ensure_setup();
|
||||
if (err != ESP_OK)
|
||||
return err;
|
||||
g_recv_cb = cb;
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t esp_now_unregister_recv_cb(void) {
|
||||
g_recv_cb = nullptr;
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t esp_now_register_send_cb(esp_now_send_cb_t cb) {
|
||||
esp_err_t err = ensure_setup();
|
||||
if (err != ESP_OK)
|
||||
return err;
|
||||
g_send_cb = cb;
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t esp_now_unregister_send_cb(void) {
|
||||
g_send_cb = nullptr;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t add_or_mod_peer(uint8_t opcode, const esp_now_peer_info_t *peer, bool wait) {
|
||||
if (peer == nullptr)
|
||||
return ESP_ERR_ESPNOW_ARG;
|
||||
esp_now_hosted_peer_t p;
|
||||
memset(&p, 0, sizeof(p));
|
||||
memcpy(p.peer_addr, peer->peer_addr, 6);
|
||||
memcpy(p.lmk, peer->lmk, 16);
|
||||
p.channel = peer->channel;
|
||||
p.ifidx = static_cast<uint8_t>(peer->ifidx);
|
||||
p.encrypt = peer->encrypt ? 1 : 0;
|
||||
return request(opcode, &p, sizeof(p), nullptr, 0, nullptr, wait);
|
||||
}
|
||||
esp_err_t esp_now_add_peer(const esp_now_peer_info_t *peer) {
|
||||
// Fire-and-forget (wait=false): adding a peer is a blocking RPC round-trip,
|
||||
// and ESPHome's espnow calls it on the main loop when a device joins the mesh
|
||||
// — under co-processor load that stalls the UI (peer-churn stutter). Issue it
|
||||
// without waiting and mirror it locally. Safe against a following
|
||||
// esp_now_send to the same peer: both ride the same in-order CustomRpc
|
||||
// channel (mutex-serialized on the host) and the co-processor processes REQs
|
||||
// FIFO, so ADD_PEER is applied before the SEND. Trade-off: a co-processor-side
|
||||
// failure (e.g. peer table full) is no longer reported synchronously — the
|
||||
// same limitation as esp_now_send — but ESPHome only adds peers it validated.
|
||||
esp_err_t err = add_or_mod_peer(ESP_NOW_HOSTED_OP_ADD_PEER, peer, /*wait=*/false);
|
||||
if (err == ESP_OK)
|
||||
peer_cache_add(peer->peer_addr); // keep the local mirror in sync
|
||||
return err;
|
||||
}
|
||||
esp_err_t esp_now_mod_peer(const esp_now_peer_info_t *peer) {
|
||||
// mod_peer changes a peer's parameters, not its existence, so the cache is
|
||||
// unaffected. Kept synchronous — it is not on any hot path (espnow never
|
||||
// calls it), so the extra round-trip does not matter and the status is useful.
|
||||
return add_or_mod_peer(ESP_NOW_HOSTED_OP_MOD_PEER, peer, /*wait=*/true);
|
||||
}
|
||||
|
||||
esp_err_t esp_now_del_peer(const uint8_t *peer_addr) {
|
||||
if (peer_addr == nullptr)
|
||||
return ESP_ERR_ESPNOW_ARG;
|
||||
// Fire-and-forget for the same reason as add_peer (peer churn on the main
|
||||
// loop). Removal is order-independent, so this is strictly safe.
|
||||
esp_err_t err = request(ESP_NOW_HOSTED_OP_DEL_PEER, peer_addr, 6, nullptr, 0, nullptr, /*wait=*/false);
|
||||
if (err == ESP_OK)
|
||||
peer_cache_remove(peer_addr); // keep the local mirror in sync
|
||||
return err;
|
||||
}
|
||||
|
||||
bool esp_now_is_peer_exist(const uint8_t *peer_addr) {
|
||||
if (peer_addr == nullptr)
|
||||
return false;
|
||||
// Answered from the local mirror — no RPC round-trip. ESPHome's espnow calls
|
||||
// this on the main loop for every received frame and every send, so a
|
||||
// blocking round-trip here would stall rendering under mesh traffic.
|
||||
return peer_cache_contains(peer_addr);
|
||||
}
|
||||
|
||||
esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len) {
|
||||
if (len > ESP_NOW_HOSTED_MAX_FRAME)
|
||||
return ESP_ERR_ESPNOW_ARG;
|
||||
if (data == nullptr && len != 0) // native esp_now_send treats this as an arg error
|
||||
return ESP_ERR_ESPNOW_ARG;
|
||||
// Only the small fixed header is built here; the caller's frame goes over as
|
||||
// the request tail, so request() lays both into its own buffer under
|
||||
// g_req_mutex. esp_now_send is a public C symbol and may be called from any
|
||||
// task, and a shared build buffer here would let two callers corrupt each
|
||||
// other's frame. Passing the body through also drops a full-frame copy per
|
||||
// transmit, on the path this shim exists to keep quick.
|
||||
uint8_t hdr[sizeof(esp_now_hosted_send_req_t)];
|
||||
auto *s = reinterpret_cast<esp_now_hosted_send_req_t *>(hdr);
|
||||
s->has_addr = peer_addr != nullptr ? 1 : 0;
|
||||
if (peer_addr != nullptr)
|
||||
memcpy(s->peer_addr, peer_addr, 6);
|
||||
else
|
||||
memset(s->peer_addr, 0, 6);
|
||||
s->data_len = static_cast<uint16_t>(len);
|
||||
// Fire-and-forget (wait=false): native esp_now_send returns once the frame is
|
||||
// queued, with the real TX result delivered later through the send callback.
|
||||
// The co-processor mirrors that — it acks enqueue immediately and reports the
|
||||
// outcome via the async SEND event (on_send -> on_send_report). Waiting for
|
||||
// the RPC RESP here would block the main loop for the full round-trip on
|
||||
// every transmit.
|
||||
return request(ESP_NOW_HOSTED_OP_SEND, hdr, sizeof(hdr), nullptr, 0, nullptr, /*wait=*/false, data,
|
||||
static_cast<uint16_t>(len));
|
||||
}
|
||||
|
||||
esp_err_t esp_now_set_pmk(const uint8_t *pmk) {
|
||||
if (pmk == nullptr)
|
||||
return ESP_ERR_ESPNOW_ARG;
|
||||
return request(ESP_NOW_HOSTED_OP_SET_PMK, pmk, 16, nullptr, 0, nullptr);
|
||||
}
|
||||
|
||||
// Remainder of the <esp_now.h> surface. Not used by ESPHome's espnow component
|
||||
// today; provided so the whole header links and future callers get a defined
|
||||
// (if unimplemented) symbol rather than a link error. Wire them through
|
||||
// CustomRpc if a use case appears.
|
||||
esp_err_t esp_now_get_peer(const uint8_t * /*peer_addr*/, esp_now_peer_info_t * /*peer*/) {
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
esp_err_t esp_now_fetch_peer(bool /*from_head*/, esp_now_peer_info_t * /*peer*/) { return ESP_ERR_NOT_SUPPORTED; }
|
||||
esp_err_t esp_now_get_peer_num(esp_now_peer_num_t * /*num*/) { return ESP_ERR_NOT_SUPPORTED; }
|
||||
esp_err_t esp_now_set_wake_window(uint16_t /*window*/) {
|
||||
return ESP_ERR_NOT_SUPPORTED; // power-save wake window is not forwarded; don't claim success
|
||||
}
|
||||
esp_err_t esp_now_set_peer_rate_config(const uint8_t * /*peer_addr*/, esp_now_rate_config_t * /*cfg*/) {
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
esp_err_t esp_wifi_config_espnow_rate(wifi_interface_t /*ifx*/, wifi_phy_rate_t /*rate*/) {
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
#endif // CONFIG_IDF_TARGET_ESP32P4
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* esp_now_hosted — ESP-NOW-over-CustomRpc wire protocol.
|
||||
*
|
||||
* Shared, byte-for-byte-identical contract between:
|
||||
* - the host shim (esphome/components/esp32_hosted/esp_now_hosted.cpp)
|
||||
* - the coprocessor firmware (esphome/esp-hosted-firmware)
|
||||
*
|
||||
* It rides esp-hosted's CustomRpc channel (RPC ID 388, "peer data transfer",
|
||||
* available since esp-hosted v2.8.1), teaching the radio-less host <-> radio
|
||||
* co-processor link to carry esp_now.h, which esp-hosted itself does not proxy
|
||||
* (Espressif issue espressif/esp-hosted-mcu#19).
|
||||
*
|
||||
* KEEP THE TWO COPIES IN SYNC. The canonical copy lives here; the coprocessor
|
||||
* firmware uses a verbatim copy. Both sides are little-endian, so these packed
|
||||
* structs are wire-compatible with no byte-swapping.
|
||||
*/
|
||||
|
||||
#ifndef ESP_NOW_HOSTED_RPC_H
|
||||
#define ESP_NOW_HOSTED_RPC_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <cstdint>
|
||||
#else
|
||||
#include <stdint.h>
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ── CustomRpc message IDs (any uint32_t except 0xFFFFFFFF) ──────────────────
|
||||
* One REQ handler slot on the device; three event handler slots on the host.
|
||||
* The bytes spell "now" + index, a private range unlikely to clash with other
|
||||
* CustomRpc users (e.g. the stock peer_data_transfer example's 1..6). */
|
||||
#define ESP_NOW_HOSTED_MSG_REQ 0x6E6F7701u /* host -> device : request envelope */
|
||||
#define ESP_NOW_HOSTED_MSG_RESP 0x6E6F7702u /* device -> host : reply to a REQ */
|
||||
#define ESP_NOW_HOSTED_MSG_RECV 0x6E6F7703u /* device -> host : async RX frame */
|
||||
#define ESP_NOW_HOSTED_MSG_SEND 0x6E6F7704u /* device -> host : async TX status */
|
||||
|
||||
/* ── Request opcodes ────────────────────────────────────────────────────── */
|
||||
enum {
|
||||
ESP_NOW_HOSTED_OP_INIT = 1, /* esp_now_init + register device recv/send cbs */
|
||||
ESP_NOW_HOSTED_OP_DEINIT = 2, /* unregister cbs + esp_now_deinit */
|
||||
ESP_NOW_HOSTED_OP_ADD_PEER = 3, /* payload: esp_now_hosted_peer_t */
|
||||
ESP_NOW_HOSTED_OP_DEL_PEER = 4, /* payload: 6-byte peer MAC */
|
||||
ESP_NOW_HOSTED_OP_IS_PEER_EXIST = 5, /* payload: 6-byte MAC; ret: 1 byte bool */
|
||||
ESP_NOW_HOSTED_OP_SEND = 6, /* payload: esp_now_hosted_send_req_t */
|
||||
ESP_NOW_HOSTED_OP_GET_VERSION = 7, /* ret: uint32 version */
|
||||
ESP_NOW_HOSTED_OP_SET_PMK = 8, /* payload: 16-byte PMK */
|
||||
ESP_NOW_HOSTED_OP_MOD_PEER = 9, /* payload: esp_now_hosted_peer_t */
|
||||
};
|
||||
|
||||
/* Largest ESP-NOW payload we forward. ESP-NOW v2 (IDF >= 5.4) is 1470 B; well
|
||||
* under esp-hosted's 8166 B CustomRpc cap, so the shim never truncates. */
|
||||
#define ESP_NOW_HOSTED_MAX_FRAME 1470u
|
||||
/* Envelope slack for the largest opcode payload (a SEND req wrapping a frame). */
|
||||
#define ESP_NOW_HOSTED_MAX_PAYLOAD (ESP_NOW_HOSTED_MAX_FRAME + 16u)
|
||||
/* Host request/response round-trip timeout over the transport. Generous:
|
||||
* normal RTT is sub-millisecond, but Wi-Fi/BLE contention on the co-processor
|
||||
* can stall the RX thread. */
|
||||
#define ESP_NOW_HOSTED_TIMEOUT_MS 2000
|
||||
|
||||
/* ── Envelopes ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* These payloads are shared verbatim with the C co-processor firmware, so they
|
||||
* use C's `typedef struct {...} name;` idiom rather than C++ `using` aliases,
|
||||
* which would not compile there. Silence clang-tidy's modernize-use-using for
|
||||
* the shared struct block. */
|
||||
// NOLINTBEGIN(modernize-use-using)
|
||||
typedef struct {
|
||||
uint8_t opcode; /* one of ESP_NOW_HOSTED_OP_* */
|
||||
uint8_t seq; /* wraps 0..255; echoed in the response for matching */
|
||||
uint16_t payload_len; /* bytes of opcode-specific payload that follow */
|
||||
uint8_t payload[]; /* flexible */
|
||||
} __attribute__((packed)) esp_now_hosted_req_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t opcode; /* echoes the request opcode */
|
||||
uint8_t seq; /* echoes the request seq */
|
||||
int32_t status; /* esp_err_t from the native call on the co-processor */
|
||||
uint16_t ret_len; /* bytes of return payload that follow */
|
||||
uint8_t ret[]; /* flexible (e.g. version u32, is_peer_exist bool) */
|
||||
} __attribute__((packed)) esp_now_hosted_resp_t;
|
||||
|
||||
/* ── Opcode payloads ────────────────────────────────────────────────────── */
|
||||
|
||||
/* esp_now_peer_info_t minus the host-only `priv` pointer, which is meaningless
|
||||
* across the transport and never set by ESPHome's espnow component. */
|
||||
typedef struct {
|
||||
uint8_t peer_addr[6];
|
||||
uint8_t lmk[16];
|
||||
uint8_t channel; /* 0 = current channel */
|
||||
uint8_t ifidx; /* wifi_interface_t (0=STA, 1=AP) */
|
||||
uint8_t encrypt; /* bool */
|
||||
} __attribute__((packed)) esp_now_hosted_peer_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t has_addr; /* 0 => peer_addr is NULL (broadcast to all peers) */
|
||||
uint8_t peer_addr[6];
|
||||
uint16_t data_len;
|
||||
uint8_t data[]; /* flexible, up to ESP_NOW_HOSTED_MAX_FRAME */
|
||||
} __attribute__((packed)) esp_now_hosted_send_req_t;
|
||||
|
||||
/* ── Async events (device -> host) ──────────────────────────────────────── */
|
||||
|
||||
/* Reconstructed on the host into an esp_now_recv_info_t + a minimal
|
||||
* wifi_pkt_rx_ctrl_t. ESPHome's espnow reads info->src_addr, info->des_addr,
|
||||
* info->rx_ctrl->rssi and info->rx_ctrl->timestamp. */
|
||||
typedef struct {
|
||||
uint8_t src_addr[6];
|
||||
uint8_t des_addr[6];
|
||||
int8_t rssi;
|
||||
uint8_t channel;
|
||||
uint16_t data_len;
|
||||
uint8_t data[]; /* flexible */
|
||||
} __attribute__((packed)) esp_now_hosted_recv_evt_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t des_addr[6];
|
||||
uint8_t status; /* esp_now_send_status_t (0 = success) */
|
||||
} __attribute__((packed)) esp_now_hosted_send_evt_t;
|
||||
// NOLINTEND(modernize-use-using)
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* ESP_NOW_HOSTED_RPC_H */
|
||||
@@ -112,6 +112,7 @@ void ESP32ImprovComponent::loop() {
|
||||
this->state_callback_.call(this->state_, this->error_state_);
|
||||
#endif
|
||||
}
|
||||
this->release_advertising_();
|
||||
this->incoming_data_.clear();
|
||||
return;
|
||||
}
|
||||
@@ -143,8 +144,9 @@ void ESP32ImprovComponent::loop() {
|
||||
ESP_LOGV(TAG, "Starting with device name advertising");
|
||||
this->advertising_device_name_ = true;
|
||||
this->last_name_adv_time_ = App.get_loop_component_start_time();
|
||||
// Set the payload before requesting, so advertising starts exactly once
|
||||
esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>{}, true);
|
||||
esp32_ble::global_ble->advertising_start();
|
||||
this->request_advertising_();
|
||||
|
||||
// Set initial state based on whether we have an authorizer
|
||||
this->set_state_(this->get_initial_state_(), false);
|
||||
@@ -326,6 +328,8 @@ void ESP32ImprovComponent::stop() {
|
||||
this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] {
|
||||
if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr)
|
||||
return;
|
||||
// Release first so removing the service UUID does not restart advertising on the way out
|
||||
this->release_advertising_();
|
||||
this->service_->stop();
|
||||
this->set_state_(improv::STATE_STOPPED);
|
||||
});
|
||||
@@ -520,6 +524,20 @@ void ESP32ImprovComponent::update_advertising_type_() {
|
||||
}
|
||||
}
|
||||
|
||||
void ESP32ImprovComponent::request_advertising_() {
|
||||
if (this->advertising_requested_)
|
||||
return;
|
||||
this->advertising_requested_ = true;
|
||||
esp32_ble::global_ble->advertising_start();
|
||||
}
|
||||
|
||||
void ESP32ImprovComponent::release_advertising_() {
|
||||
if (!this->advertising_requested_)
|
||||
return;
|
||||
this->advertising_requested_ = false;
|
||||
esp32_ble::global_ble->advertising_stop();
|
||||
}
|
||||
|
||||
improv::State ESP32ImprovComponent::get_initial_state_() const {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
// If we have an authorizer, start in awaiting authorization state
|
||||
|
||||
@@ -104,8 +104,11 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB
|
||||
bool status_indicator_state_{false};
|
||||
uint32_t last_name_adv_time_{0};
|
||||
bool advertising_device_name_{false};
|
||||
bool advertising_requested_{false};
|
||||
void set_status_indicator_state_(bool state);
|
||||
void update_advertising_type_();
|
||||
void request_advertising_();
|
||||
void release_advertising_();
|
||||
|
||||
void set_state_(improv::State state, bool update_advertising = true);
|
||||
void set_error_(improv::Error error);
|
||||
|
||||
@@ -35,7 +35,7 @@ from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .boards import BOARDS, ESP8266_LD_SCRIPTS, board_ld_script
|
||||
from .boards import BOARDS, board_ld_script
|
||||
from .const import (
|
||||
CONF_EARLY_PIN_INIT,
|
||||
CONF_ENABLE_SERIAL,
|
||||
@@ -43,8 +43,6 @@ from .const import (
|
||||
CONF_RESTORE_FROM_FLASH,
|
||||
KEY_BOARD,
|
||||
KEY_ESP8266,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_LDSCRIPT,
|
||||
KEY_PIN_INITIAL_STATES,
|
||||
KEY_SERIAL1_REQUIRED,
|
||||
KEY_SERIAL_REQUIRED,
|
||||
@@ -133,10 +131,6 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
|
||||
# format the given arduino (https://github.com/esp8266/Arduino/releases) version to
|
||||
# a PIO platformio/framework-arduinoespressif8266 value
|
||||
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
|
||||
if ver <= cv.Version(2, 4, 1):
|
||||
return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
if ver <= cv.Version(2, 6, 2):
|
||||
return f"~2.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
# Same encoding the native toolchain uses for its package download, so a
|
||||
# version bump cannot drift between the two paths.
|
||||
from esphome.arduino8266.framework import framework_package_version
|
||||
@@ -159,11 +153,9 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
|
||||
# - https://github.com/esp8266/Arduino/releases
|
||||
# - https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
|
||||
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 1, 2)
|
||||
# The platformio/espressif8266 version to use for arduino 2 framework versions
|
||||
# The platformio/espressif8266 version to use for arduino 3 framework versions
|
||||
# - https://github.com/platformio/platform-espressif8266/releases
|
||||
# - https://api.registry.platformio.org/v3/packages/platformio/platform/espressif8266
|
||||
ARDUINO_2_PLATFORM_VERSION = cv.Version(2, 6, 3)
|
||||
# for arduino 3 framework versions
|
||||
ARDUINO_3_PLATFORM_VERSION = cv.Version(3, 2, 0)
|
||||
# for arduino 4 framework versions
|
||||
ARDUINO_4_PLATFORM_VERSION = cv.Version(4, 2, 1)
|
||||
@@ -188,6 +180,14 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType:
|
||||
version = cv.Version.parse(cv.version_number(value[CONF_VERSION]))
|
||||
source = value.get(CONF_SOURCE, None)
|
||||
|
||||
if version < cv.Version(3, 0, 0):
|
||||
raise cv.Invalid(
|
||||
f"Arduino framework {version} is no longer supported; ESPHome requires "
|
||||
f"C++20, which needs Arduino core 3.x. Use the recommended version "
|
||||
f"({RECOMMENDED_ARDUINO_FRAMEWORK_VERSION}).",
|
||||
path=[CONF_VERSION],
|
||||
)
|
||||
|
||||
value[CONF_VERSION] = str(version)
|
||||
value[CONF_SOURCE] = source or _format_framework_arduino_version(version)
|
||||
|
||||
@@ -195,12 +195,8 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType:
|
||||
if platform_version is None:
|
||||
if version >= cv.Version(3, 1, 0):
|
||||
platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
|
||||
elif version >= cv.Version(3, 0, 0):
|
||||
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
|
||||
elif version >= cv.Version(2, 5, 0):
|
||||
platform_version = _parse_platform_version(str(ARDUINO_2_PLATFORM_VERSION))
|
||||
else:
|
||||
platform_version = _parse_platform_version(str(cv.Version(1, 8, 0)))
|
||||
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
|
||||
value[CONF_PLATFORM_VERSION] = platform_version
|
||||
|
||||
if version != RECOMMENDED_ARDUINO_FRAMEWORK_VERSION:
|
||||
@@ -289,29 +285,11 @@ def check_rosetta() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _choose_ld_script(board: str, ver: cv.Version) -> str | None:
|
||||
"""The flash ld to pin for this board and core, or None for cores
|
||||
without ld-script support."""
|
||||
board_data = BOARDS[board]
|
||||
ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]]
|
||||
if ver <= cv.Version(2, 3, 0):
|
||||
# No ld script support
|
||||
return None
|
||||
if ver <= cv.Version(2, 4, 2):
|
||||
# Old ld script path; the modern per-board override names do not
|
||||
# exist in this core's SDK, so the override cannot be honored.
|
||||
# Substituting the size default would move _FS_end and the
|
||||
# preferences sector, wiping flash-backed state on flash.
|
||||
if KEY_LDSCRIPT in board_data:
|
||||
raise EsphomeError(
|
||||
f"Board {board} requires its {board_data[KEY_LDSCRIPT]} "
|
||||
f"flash layout, which Arduino core {ver} cannot honor; "
|
||||
"use a core newer than 2.4.2"
|
||||
)
|
||||
return ld_scripts[0]
|
||||
def _choose_ld_script(board: str) -> str:
|
||||
"""The flash ld to pin for this board."""
|
||||
# A per-board override preserves a layout the board shipped with
|
||||
# (see d1_wroom_02 in boards.py)
|
||||
return board_ld_script(board_data)
|
||||
return board_ld_script(BOARDS[board])
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.PLATFORM)
|
||||
@@ -435,10 +413,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
if config[CONF_BOARD] in BOARDS:
|
||||
ld_script = _choose_ld_script(config[CONF_BOARD], ver)
|
||||
|
||||
if ld_script is not None:
|
||||
cg.add_platformio_option("board_build.ldscript", ld_script)
|
||||
cg.add_platformio_option(
|
||||
"board_build.ldscript", _choose_ld_script(config[CONF_BOARD])
|
||||
)
|
||||
|
||||
CORE.add_job(add_pin_initial_states_array)
|
||||
CORE.add_job(finalize_waveform_config)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2,12 +2,12 @@ import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.noise import (
|
||||
decode_encryption_key,
|
||||
encryption_schema,
|
||||
is_reserved_key,
|
||||
new_psk_progmem,
|
||||
static_encryption_key,
|
||||
)
|
||||
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
|
||||
from esphome.config_helpers import merge_config
|
||||
from esphome.config_helpers import filter_source_files_from_defines, merge_config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_API,
|
||||
@@ -31,7 +31,6 @@ import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access"
|
||||
CONF_CAPTIVE_PORTAL = "captive_portal"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,11 +40,10 @@ DEPENDENCIES = ["network"]
|
||||
|
||||
|
||||
def AUTO_LOAD(config: ConfigType) -> list[str]:
|
||||
"""Auto-load noise only when encryption is configured."""
|
||||
"""Auto-load noise only when encryption is configured; the api key offer
|
||||
inherits it from the api component."""
|
||||
base = ["sha256", "socket"]
|
||||
# A falsy config is a tooling probe for the maximal set (None from
|
||||
# dependency resolution, {} from the components-graph platform probe);
|
||||
# a validated config always carries defaults, never empty
|
||||
# A falsy config is a tooling probe for the maximal set
|
||||
if not config or CONF_ENCRYPTION in config:
|
||||
return base + ["noise"]
|
||||
return base
|
||||
@@ -132,12 +130,56 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
||||
_validate_no_password_with_encryption(ota_conf)
|
||||
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||
_resolve_encryption_key(encryption_conf, api_conf)
|
||||
if any(
|
||||
conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf
|
||||
) and any(
|
||||
CONF_ENCRYPTION in conf for conf in merged_ota_esphome_configs_by_port.values()
|
||||
elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None:
|
||||
_LOGGER.warning(
|
||||
"'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 "
|
||||
"bytes plus the password on the heap): the device already offers "
|
||||
"encryption with the '%s' %s %s, which authenticates any uploader "
|
||||
"that takes it, and a password only matters for uploaders without "
|
||||
"encryption support; remove '%s' and add '%s' under '%s' so "
|
||||
"uploads use the key and encryption is required",
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_API,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_KEY,
|
||||
CONF_PASSWORD,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_OTA,
|
||||
)
|
||||
elif (
|
||||
CONF_PASSWORD in ota_conf
|
||||
and CONF_ENCRYPTION in api_conf
|
||||
and not api_conf[CONF_ENCRYPTION].get(CONF_KEY)
|
||||
):
|
||||
# The CLI still needs the password; whoever provisions the key skips it
|
||||
_LOGGER.warning(
|
||||
"The '%s' %s %s provisioned at runtime also authenticates OTA "
|
||||
"uploads once provisioned; '%s' %s then only guards plaintext "
|
||||
"uploads. Whoever provisions the key can upload firmware "
|
||||
"without the password, so add a 'provisioning:' block to limit "
|
||||
"when that is possible",
|
||||
CONF_API,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_KEY,
|
||||
CONF_OTA,
|
||||
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
|
||||
if (
|
||||
(CONF_WEB_SERVER in full_conf or "prometheus" in full_conf)
|
||||
and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf)
|
||||
and any(
|
||||
CONF_ENCRYPTION in conf
|
||||
for conf in merged_ota_esphome_configs_by_port.values()
|
||||
)
|
||||
):
|
||||
_warn_web_server_ota(full_conf)
|
||||
_LOGGER.warning(
|
||||
"OTA encryption does not cover the %s OTA platform; its "
|
||||
"plaintext /update endpoint accepts the same image",
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
|
||||
full_conf[CONF_OTA] = new_ota_conf
|
||||
fv.full_config.set(full_conf)
|
||||
@@ -152,33 +194,11 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _warn_web_server_ota(full_conf: ConfigType) -> None:
|
||||
"""The web_server ota platform accepts the same image over plaintext HTTP
|
||||
with basic auth, bypassing the encryption; warn rather than fail so the
|
||||
operator keeps the recovery path."""
|
||||
if CONF_CAPTIVE_PORTAL in full_conf and CONF_WEB_SERVER not in full_conf:
|
||||
# The captive_portal auto-load: the endpoint only exists while the
|
||||
# fallback AP is active
|
||||
_LOGGER.warning(
|
||||
"OTA encryption does not cover the %s OTA platform (auto-loaded "
|
||||
"by captive_portal); the plaintext /update endpoint stays "
|
||||
"reachable while the fallback AP is active",
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"OTA encryption does not cover the %s OTA platform; its "
|
||||
"plaintext /update endpoint accepts the same image",
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -> None:
|
||||
"""Resolve the one encryption key per device into the ota block.
|
||||
|
||||
An explicit ota key must match the api key, a bare block inherits it,
|
||||
a runtime provisioned api key cannot be inherited, and the all-zeros
|
||||
provisioning sentinel is rejected (the device treats it as no key).
|
||||
a runtime provisioned api key cannot be inherited.
|
||||
"""
|
||||
api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
|
||||
if ota_key := encryption_conf.get(CONF_KEY):
|
||||
@@ -201,11 +221,6 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -
|
||||
)
|
||||
else:
|
||||
encryption_conf[CONF_KEY] = api_key
|
||||
if is_reserved_key(encryption_conf[CONF_KEY]):
|
||||
raise cv.Invalid(
|
||||
f"The all-zeros {CONF_KEY} is reserved and provides no protection; "
|
||||
f"generate a real key with: openssl rand -base64 32"
|
||||
)
|
||||
|
||||
|
||||
# Also called on merged same-port configs in final validate, where schemas
|
||||
@@ -267,15 +282,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
|
||||
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
"""Filter out the noise transport when no ota entry configures encryption."""
|
||||
for ota_conf in CORE.config.get(CONF_OTA, []):
|
||||
if (
|
||||
ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
and ota_conf.get(CONF_ENCRYPTION) is not None
|
||||
):
|
||||
return []
|
||||
return ["ota_esphome_noise.cpp"]
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"}
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
||||
@@ -296,11 +305,24 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config.get(CONF_ALLOW_PARTITION_ACCESS):
|
||||
cg.add_define("USE_OTA_PARTITIONS")
|
||||
|
||||
if (encryption_conf := config.get(CONF_ENCRYPTION)) is not None:
|
||||
# A missing key was resolved from the api component in final validate.
|
||||
key = encryption_conf[CONF_KEY]
|
||||
# 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:
|
||||
cg.add_define("USE_OTA_ENCRYPTION")
|
||||
cg.add(var.set_noise_psk(list(decode_encryption_key(key))))
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key)))
|
||||
elif CONF_ENCRYPTION in api_conf:
|
||||
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_REQUIRED")
|
||||
|
||||
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
||||
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user