mirror of
https://github.com/esphome/esphome.git
synced 2026-09-09 06:18:46 +00:00
Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29d0ae8a1f | ||
|
|
8e31d4c1ef | ||
|
|
f9861753fb | ||
|
|
fbb29a2e76 | ||
|
|
e050d58875 | ||
|
|
6c5ab89d5f | ||
|
|
8f511a365a | ||
|
|
006f31af93 | ||
|
|
5bb112f407 | ||
|
|
4ab9298ab3 | ||
|
|
3926612281 | ||
|
|
1ce0bed3f6 | ||
|
|
472b5de30e | ||
|
|
bee0dd2926 | ||
|
|
f91486305f | ||
|
|
157294a99c | ||
|
|
6146e625dd | ||
|
|
8e9e6bbdd7 | ||
|
|
773050d7c9 | ||
|
|
87dc5014a8 | ||
|
|
49f9eaacb1 | ||
|
|
cd4e07f2d0 | ||
|
|
7e1216fe01 | ||
|
|
8027a37d61 | ||
|
|
76e1df2c03 | ||
|
|
66a0485a5c | ||
|
|
3a83323c75 | ||
|
|
817fcb7881 | ||
|
|
08c7e3e9aa | ||
|
|
5b5b5e3cc1 | ||
|
|
eefddf85f1 | ||
|
|
d1665c423d | ||
|
|
db55c1d43f | ||
|
|
27a8768986 | ||
|
|
a0398d120c | ||
|
|
f191d5e0c3 | ||
|
|
227ca90aad | ||
|
|
1700a40b7c | ||
|
|
deb63ea092 | ||
|
|
28588310e7 | ||
|
|
10a9baff74 | ||
|
|
a23f7bb569 | ||
|
|
53075e4139 | ||
|
|
5722ccba37 | ||
|
|
94e5c3839d | ||
|
|
574762f078 | ||
|
|
d34ffaf392 | ||
|
|
e6aa575f2e | ||
|
|
639ce609bf | ||
|
|
62eafc477d | ||
|
|
56c3361b9a | ||
|
|
50ca381198 | ||
|
|
89a56298c2 | ||
|
|
390742cf9b | ||
|
|
5e37872da2 | ||
|
|
d34d3994e1 | ||
|
|
9a877a067c | ||
|
|
9ba4477ada | ||
|
|
8434dc5474 | ||
|
|
e0e85db822 | ||
|
|
5d2ddc658c | ||
|
|
c1aa41f276 | ||
|
|
96b1a03ea4 | ||
|
|
95ab3fb4f2 | ||
|
|
18220e0b39 | ||
|
|
011497d6ee | ||
|
|
7089dae3b6 | ||
|
|
745eb30109 | ||
|
|
e36445fa5f | ||
|
|
cb0c2bdaca | ||
|
|
e47247486b | ||
|
|
657116a213 | ||
|
|
3321566cc0 | ||
|
|
d58b37faa1 | ||
|
|
8966567be0 | ||
|
|
20c7dcb1dd | ||
|
|
688af60cbf | ||
|
|
9c00f13606 | ||
|
|
833dd0e812 | ||
|
|
8e1044e8ea | ||
|
|
e5200db6fd | ||
|
|
e3dd2f44a4 | ||
|
|
3ef7460fca | ||
|
|
ae187f81f2 | ||
|
|
84f78831f9 | ||
|
|
13dbbcaa32 | ||
|
|
b66822d9bd | ||
|
|
d1829c495d | ||
|
|
ce87bf9b17 | ||
|
|
51ea97deff | ||
|
|
ab800dc09d | ||
|
|
f65ab5629e | ||
|
|
b84532d254 | ||
|
|
6b11636491 | ||
|
|
2bb98f2d64 | ||
|
|
f3c786c784 | ||
|
|
2250430999 | ||
|
|
d1068d582f |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -839,7 +840,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({ ... }),
|
||||
cv.Schema({...}),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.9.0b1
|
||||
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.5
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+16
-5
@@ -1289,10 +1289,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
The native API uses challenge-response auth with MD5/SHA256 hashing of a
|
||||
server-issued nonce, so the password is never sent over the wire; the
|
||||
``web_server`` path uses HTTP Basic auth which transmits credentials in
|
||||
cleartext over the LAN. (The native path also supports gzip compression
|
||||
on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the
|
||||
backend reports ``supports_compression() == false`` and the firmware is
|
||||
sent uncompressed regardless of which platform is used.) Falls back to
|
||||
cleartext over the LAN. (The native path also compresses the upload:
|
||||
gzip on ESP8266 and RP2040, which inflate it at reboot, and a deflate
|
||||
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
|
||||
``web_server`` only when that is the only available platform.
|
||||
"""
|
||||
# Use a dict (insertion-ordered) instead of a list so error messages and
|
||||
@@ -1335,12 +1334,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 +1352,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 +1387,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
|
||||
@@ -589,8 +590,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
|
||||
|
||||
@@ -2161,7 +2161,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 +2199,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif // USE_API_NOISE_PSK_FROM_YAML
|
||||
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,6 +22,23 @@ class Automation {
|
||||
static const char *const TAG;
|
||||
};
|
||||
|
||||
// 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:
|
||||
@@ -61,7 +78,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 {}
|
||||
@@ -71,7 +88,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 {}
|
||||
@@ -82,7 +99,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 {}
|
||||
@@ -315,19 +332,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.
|
||||
@@ -364,14 +379,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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -100,21 +100,38 @@ void ESP32BLE::disable() {
|
||||
#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 +153,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 +164,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
|
||||
|
||||
@@ -575,6 +592,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
@@ -226,6 +236,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_() {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,17 @@ 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",
|
||||
"ota_esphome_inflate.c": "USE_OTA_DEFLATE",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def enable_deflate() -> None:
|
||||
"""Compile the on-the-fly inflater for compressed uploads."""
|
||||
cg.add_define("USE_OTA_DEFLATE")
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
||||
@@ -296,11 +313,28 @@ 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]
|
||||
# ESP8266 and RP2040 inflate gzip at reboot; the rest inflate on the fly
|
||||
if not (CORE.is_esp8266 or CORE.is_rp2):
|
||||
enable_deflate()
|
||||
|
||||
# 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")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#include "ota_esphome.h"
|
||||
#ifdef USE_OTA_ENCRYPTION_FROM_API
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
#ifdef USE_OTA
|
||||
#ifdef USE_OTA_PASSWORD
|
||||
#include "esphome/components/sha256/sha256.h"
|
||||
@@ -19,16 +22,34 @@
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <sys/time.h>
|
||||
|
||||
namespace esphome {
|
||||
|
||||
static const char *const TAG = "esphome.ota";
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
|
||||
#ifdef USE_OTA_ENCRYPTION_FROM_API
|
||||
return api::global_api_server->get_noise_ctx();
|
||||
#else
|
||||
return this->noise_ctx_;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
|
||||
// Milliseconds for data transfer. Covers the lwIP retransmit run seen in
|
||||
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
|
||||
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
|
||||
static constexpr uint32_t OTA_PROGRESS_INTERVAL_MS = 1000;
|
||||
static constexpr size_t OTA_SIZE_FIELD_BYTES = 4; // sizes on the wire are 4 bytes MSB first
|
||||
|
||||
// Single-instance pointer — multi-port configs are rejected in final_validate.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -97,18 +118,30 @@ void ESPHomeOTAComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Over-The-Air updates:\n"
|
||||
" Address: %s:%u\n"
|
||||
" Version: %d",
|
||||
network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION);
|
||||
" Version: %d"
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
"\n Encryption: %s"
|
||||
#endif
|
||||
,
|
||||
network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION
|
||||
#ifdef USE_OTA_ENCRYPTION_REQUIRED
|
||||
,
|
||||
LOG_STR_LITERAL("required")
|
||||
#elif defined(USE_OTA_ENCRYPTION_PROVISIONED)
|
||||
// A runtime provisioned key may not exist yet
|
||||
,
|
||||
this->noise_context_().has_psk() ? LOG_STR_LITERAL("offered, plaintext accepted")
|
||||
: LOG_STR_LITERAL("offered once the api key is provisioned")
|
||||
#elif defined(USE_OTA_ENCRYPTION)
|
||||
,
|
||||
LOG_STR_LITERAL("offered, plaintext accepted")
|
||||
#endif
|
||||
);
|
||||
#ifdef USE_OTA_PASSWORD
|
||||
if (!this->password_.empty()) {
|
||||
ESP_LOGCONFIG(TAG, " Password configured");
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ctx_.has_psk()) {
|
||||
ESP_LOGCONFIG(TAG, " Encryption configured");
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Partition access allowed\n"
|
||||
@@ -154,9 +187,32 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10;
|
||||
// Noise needs the extended protocol: the prologue binds the 2-byte feature ack
|
||||
static constexpr uint8_t CLIENT_NOISE_FEATURES =
|
||||
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
|
||||
// Raw deflate, window <= OTA_INFLATE_WINDOW_SIZE. Binding once offered: the
|
||||
// client must then send the image size frame and a deflate stream.
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08;
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
inline bool ESPHomeOTAComponent::noise_offered_() const {
|
||||
return (this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 &&
|
||||
(this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES;
|
||||
}
|
||||
#endif
|
||||
|
||||
inline bool ESPHomeOTAComponent::extended_proto_() const {
|
||||
#ifdef USE_OTA_ENCRYPTION_REQUIRED
|
||||
// FEATURE_READ already refused every client without the extended protocol
|
||||
return true;
|
||||
#else
|
||||
return (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::handle_handshake_() {
|
||||
/// Handle the OTA handshake and authentication.
|
||||
@@ -241,12 +297,9 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
this->ota_features_ = this->handshake_buf_[0];
|
||||
ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_);
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
// Fail closed: with a PSK configured the client must negotiate encryption
|
||||
// (which requires the extended protocol); refuse plaintext uploads.
|
||||
static constexpr uint8_t NOISE_REQUIRED_FEATURES =
|
||||
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
|
||||
if (this->noise_ctx_.has_psk() && (this->ota_features_ & NOISE_REQUIRED_FEATURES) != NOISE_REQUIRED_FEATURES) {
|
||||
#ifdef USE_OTA_ENCRYPTION_REQUIRED
|
||||
// `ota: encryption:` requires the client to negotiate encryption
|
||||
if ((this->ota_features_ & CLIENT_NOISE_FEATURES) != CLIENT_NOISE_FEATURES) {
|
||||
ESP_LOGW(TAG, "Client does not support encryption");
|
||||
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED);
|
||||
return;
|
||||
@@ -256,23 +309,46 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
this->transition_ota_state_(OTAState::FEATURE_ACK);
|
||||
|
||||
const bool supports_compression =
|
||||
(this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression();
|
||||
(this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && ota::OTABackend::supports_compression();
|
||||
|
||||
// Compose the feature-ack response. When the client negotiates the extended protocol we emit
|
||||
// a 2-byte response (marker + server feature flags); otherwise we emit the single-byte
|
||||
// legacy response.
|
||||
this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0;
|
||||
if (this->extended_proto_) {
|
||||
if (this->extended_proto_()) {
|
||||
static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack");
|
||||
this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS;
|
||||
this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0);
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS;
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ctx_.has_psk()) {
|
||||
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
|
||||
// A runtime provisioned key may not exist yet
|
||||
if (this->noise_context_().has_psk()) {
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
|
||||
}
|
||||
#elif defined(USE_OTA_ENCRYPTION)
|
||||
// A yaml key always exists: validation rejects the all-zeros key
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
// Reserve the noise session before the optional inflate buffer, so the
|
||||
// required allocation is not starved by the compression window
|
||||
if (this->noise_offered_()) {
|
||||
this->noise_reserve_session_();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
// Offered only once the session memory is in hand; else uncompressed
|
||||
if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_DEFLATE) != 0) {
|
||||
// Value initialized: a corrupt stream that back references the
|
||||
// window before it is filled then copies zeros, never stale memory
|
||||
this->inflate_.reset(new (std::nothrow) InflateSession());
|
||||
if (this->inflate_ != nullptr) {
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_DEFLATE;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No memory to inflate");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
this->handshake_buf_[0] =
|
||||
@@ -284,15 +360,14 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
case OTAState::FEATURE_ACK: {
|
||||
static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1;
|
||||
static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2;
|
||||
const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE;
|
||||
const size_t ack_size = this->extended_proto_() ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE;
|
||||
if (!this->try_write_(ack_size, LOG_STR("ack feature"))) {
|
||||
return;
|
||||
}
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
// With a PSK configured the rest of the session runs inside the noise
|
||||
// transport; the client sends the first handshake frame next, so there
|
||||
// is nothing to do until data arrives.
|
||||
if (this->noise_ctx_.has_psk()) {
|
||||
// Latch the offer actually sent: a key activating between the two
|
||||
// states must not start a session the client never expects
|
||||
if (this->noise_offered_()) {
|
||||
// handshake_buf_ still holds the feature ack composed above; a
|
||||
// would-block re-entry lands here without rebuilding it
|
||||
if (!this->noise_start_session_(this->handshake_buf_[1])) {
|
||||
@@ -390,16 +465,11 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
// Backend calls overwrite this with OK; reset to UNKNOWN before any
|
||||
// goto error that follows a successful begin()/write()
|
||||
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
size_t total = 0;
|
||||
uint32_t last_progress = 0;
|
||||
uint32_t last_data_ms = 0;
|
||||
DataTransfer xfer;
|
||||
uint8_t buf[OTA_BUFFER_SIZE];
|
||||
char *sbuf = reinterpret_cast<char *>(buf);
|
||||
size_t ota_size;
|
||||
size_t image_size;
|
||||
ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP;
|
||||
#if USE_OTA_VERSION == 2
|
||||
size_t size_acknowledged = 0;
|
||||
#endif
|
||||
|
||||
// Set socket timeouts and blocking mode (see strategy table above)
|
||||
struct timeval tv;
|
||||
@@ -412,7 +482,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
// Acknowledge auth OK - 1 byte
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
|
||||
|
||||
if (this->extended_proto_) {
|
||||
if (this->extended_proto_()) {
|
||||
// Read ota type, 1 byte
|
||||
if (!this->data_readall_(buf, 1)) {
|
||||
this->log_read_error_(LOG_STR("OTA type"));
|
||||
@@ -422,14 +492,13 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
}
|
||||
ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type);
|
||||
|
||||
// Read size, 4 bytes MSB first
|
||||
if (!this->data_readall_(buf, 4)) {
|
||||
this->log_read_error_(LOG_STR("size"));
|
||||
if (!this->read_size_(buf, xfer.ota_size, LOG_STR("size")))
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
|
||||
(static_cast<size_t>(buf[2]) << 8) | buf[3];
|
||||
ESP_LOGV(TAG, "Size is %zu bytes", ota_size);
|
||||
image_size = xfer.ota_size;
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
if (this->inflate_ != nullptr && !this->read_size_(buf, image_size, LOG_STR("image size")))
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
#endif
|
||||
|
||||
#ifndef USE_OTA_PARTITIONS
|
||||
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
|
||||
@@ -449,7 +518,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
#endif
|
||||
|
||||
// begin() returns quickly; flash sectors are erased incrementally during write().
|
||||
error_code = this->backend_->begin(ota_size, ota_type);
|
||||
error_code = this->backend_->begin(image_size, ota_type);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
|
||||
@@ -469,75 +538,25 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
// Acknowledge MD5 OK - 1 byte
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
|
||||
|
||||
// Track when we last received data so a silently-vanished peer (no FIN/RST
|
||||
// delivered, e.g. uploader killed mid-transfer or NAT/router dropped state)
|
||||
// can't wedge the device indefinitely. Without this, the loop only exits
|
||||
// on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP
|
||||
// TCP keepalive isn't enabled here.
|
||||
last_data_ms = millis();
|
||||
while (total < ota_size) {
|
||||
if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) {
|
||||
ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA);
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
xfer.last_data_ms = millis();
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
if (this->inflate_ != nullptr) {
|
||||
error_code = this->inflate_data_(buf, image_size, xfer);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
size_t remaining = ota_size - total;
|
||||
size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE;
|
||||
ssize_t read;
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ != nullptr) {
|
||||
// One frame per call; noise_read_data_ waits internally (readall_), so
|
||||
// there is no would-block retry here and failures are already logged.
|
||||
read = this->noise_read_data_(buf, requested);
|
||||
if (read <= 0) {
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
while (xfer.total < xfer.ota_size) {
|
||||
ssize_t read = this->receive_data_(buf, xfer);
|
||||
if (read < 0) {
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
read = this->client_->read(buf, requested);
|
||||
if (read == -1) {
|
||||
const int err = errno;
|
||||
if (this->would_block_(err)) {
|
||||
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
|
||||
App.feed_wdt();
|
||||
continue;
|
||||
}
|
||||
ESP_LOGW(TAG, "Read err %d", err);
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
error_code = this->write_flash_(buf, read);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
} else if (read == 0) {
|
||||
ESP_LOGW(TAG, "Remote closed");
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
}
|
||||
|
||||
last_data_ms = millis();
|
||||
error_code = this->backend_->write(buf, read);
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGW(TAG, "Flash write err %d", error_code);
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
total += read;
|
||||
#if USE_OTA_VERSION == 2
|
||||
while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) {
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
|
||||
size_acknowledged += OTA_BLOCK_SIZE;
|
||||
}
|
||||
#endif
|
||||
|
||||
uint32_t now = millis();
|
||||
if (now - last_progress > 1000) {
|
||||
last_progress = now;
|
||||
float percentage = (total * 100.0f) / ota_size;
|
||||
ESP_LOGD(TAG, "Progress: %0.1f%%", percentage);
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0);
|
||||
#endif
|
||||
// feed watchdog and give other tasks a chance to run
|
||||
this->yield_and_feed_watchdog_();
|
||||
this->ack_written_(xfer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,6 +753,171 @@ bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *desc) {
|
||||
return this->handshake_buf_pos_ >= to_write;
|
||||
}
|
||||
|
||||
bool ESPHomeOTAComponent::read_size_(uint8_t *buf, size_t &size, const LogString *desc) {
|
||||
if (!this->data_readall_(buf, OTA_SIZE_FIELD_BYTES)) {
|
||||
this->log_read_error_(desc);
|
||||
return false;
|
||||
}
|
||||
size = encode_uint32(buf[0], buf[1], buf[2], buf[3]);
|
||||
ESP_LOGV(TAG, "%s is %zu bytes", LOG_STR_ARG(desc), size);
|
||||
return true;
|
||||
}
|
||||
|
||||
ota::OTAResponseTypes ESPHomeOTAComponent::write_flash_(uint8_t *data, size_t len) {
|
||||
ota::OTAResponseTypes result = this->backend_->write(data, len);
|
||||
if (result != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGW(TAG, "Flash write err %d", result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) {
|
||||
const size_t remaining = xfer.ota_size - xfer.total;
|
||||
const size_t requested = std::min(remaining, OTA_BUFFER_SIZE);
|
||||
ssize_t read;
|
||||
for (;;) {
|
||||
// A silently-vanished peer (no FIN/RST delivered, e.g. uploader killed
|
||||
// mid-transfer or NAT/router dropped state) must not wedge the device:
|
||||
// read() only fails on EOF or a real error, and lwIP TCP keepalive isn't
|
||||
// enabled here.
|
||||
if (millis() - xfer.last_data_ms > OTA_SOCKET_TIMEOUT_DATA) {
|
||||
ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA);
|
||||
return -1;
|
||||
}
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ != nullptr) {
|
||||
// One frame per call; noise_read_data_ waits internally (readall_), so
|
||||
// there is no would-block retry here and failures are already logged.
|
||||
read = this->noise_read_data_(buf, requested);
|
||||
if (read <= 0)
|
||||
return -1;
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
read = this->client_->read(buf, requested);
|
||||
if (read > 0)
|
||||
break;
|
||||
if (read == 0) {
|
||||
this->log_remote_closed_(LOG_STR("data"));
|
||||
return -1;
|
||||
}
|
||||
if (!this->would_block_(errno)) {
|
||||
this->log_socket_error_(LOG_STR("data"));
|
||||
return -1;
|
||||
}
|
||||
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
|
||||
App.feed_wdt();
|
||||
}
|
||||
|
||||
const uint32_t now = millis();
|
||||
xfer.last_data_ms = now;
|
||||
xfer.total += read;
|
||||
this->ack_received_(xfer);
|
||||
if (now - xfer.last_progress > OTA_PROGRESS_INTERVAL_MS) {
|
||||
xfer.last_progress = now;
|
||||
float percentage = (xfer.total * 100.0f) / xfer.ota_size;
|
||||
ESP_LOGD(TAG, "Progress: %0.1f%%", percentage);
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0);
|
||||
#endif
|
||||
// feed watchdog and give other tasks a chance to run
|
||||
this->yield_and_feed_watchdog_();
|
||||
}
|
||||
return read;
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) {
|
||||
#if USE_OTA_VERSION == 2
|
||||
while (xfer.acknowledged + OTA_BLOCK_SIZE <= xfer.total ||
|
||||
(xfer.total == xfer.ota_size && xfer.acknowledged < xfer.ota_size)) {
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
|
||||
xfer.acknowledged += OTA_BLOCK_SIZE;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
// The window doubles as the output buffer; flushed bytes stay as back
|
||||
// reference history for the next windowful.
|
||||
ota::OTAResponseTypes ESPHomeOTAComponent::inflate_flush_(InflateSession &session) {
|
||||
const size_t produced = session.dest - session.window;
|
||||
const size_t pending = produced - session.flushed;
|
||||
if (pending != 0) {
|
||||
if (pending > session.image_size - session.written) {
|
||||
ESP_LOGW(TAG, "Inflate overrun");
|
||||
return ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
}
|
||||
ota::OTAResponseTypes result = this->write_flash_(session.window + session.flushed, pending);
|
||||
if (result != ota::OTA_RESPONSE_OK)
|
||||
return result;
|
||||
session.flushed = produced;
|
||||
session.written += pending;
|
||||
// A compressible region yields many windows per socket read
|
||||
App.feed_wdt();
|
||||
}
|
||||
// Even with nothing new written: a block boundary can fall inside a header
|
||||
this->ack_written_(*session.xfer);
|
||||
return ota::OTA_RESPONSE_OK;
|
||||
}
|
||||
|
||||
ota::OTAResponseTypes ESPHomeOTAComponent::inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer) {
|
||||
InflateSession &session = *this->inflate_;
|
||||
session.self = this;
|
||||
session.xfer = &xfer;
|
||||
session.in = in;
|
||||
session.image_size = image_size;
|
||||
session.written = 0;
|
||||
session.error = ota::OTA_RESPONSE_OK;
|
||||
ota_inflate_init(&session, session.window, OTA_INFLATE_WINDOW_SIZE);
|
||||
// Where the ack must follow the write, flush and ack before waiting for
|
||||
// input, or the client waits for an ack while the decoder waits for data
|
||||
session.source_read_cb = [](OtaInflateState *d) -> int {
|
||||
auto *s = static_cast<InflateSession *>(d);
|
||||
if (ACK_AFTER_WRITE) {
|
||||
s->error = s->self->inflate_flush_(*s);
|
||||
if (s->error != ota::OTA_RESPONSE_OK)
|
||||
return -1;
|
||||
}
|
||||
// More input than announced; reported by the size check below
|
||||
if (s->xfer->total >= s->xfer->ota_size)
|
||||
return -1;
|
||||
ssize_t read = s->self->receive_data_(s->in, *s->xfer);
|
||||
if (read <= 0) {
|
||||
// Already logged by receive_data_
|
||||
s->error = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
return -1;
|
||||
}
|
||||
d->source = s->in + 1;
|
||||
d->source_limit = s->in + read;
|
||||
return s->in[0];
|
||||
};
|
||||
|
||||
int res;
|
||||
do {
|
||||
// The ring index wrapped to 0 exactly when the window filled
|
||||
session.dest = session.window;
|
||||
session.dest_limit = session.window + OTA_INFLATE_WINDOW_SIZE;
|
||||
session.flushed = 0;
|
||||
res = ota_inflate(&session);
|
||||
// A stored block keeps emitting zeros after a failed read, hence eof
|
||||
if (res < 0 || session.eof)
|
||||
break;
|
||||
session.error = this->inflate_flush_(session);
|
||||
} while (res != OTA_INFLATE_DONE && session.error == ota::OTA_RESPONSE_OK);
|
||||
|
||||
// Transport and flash failures are logged where they happen
|
||||
if (session.error != ota::OTA_RESPONSE_OK)
|
||||
return session.error;
|
||||
if (res != OTA_INFLATE_DONE || session.written != image_size || xfer.total != xfer.ota_size) {
|
||||
ESP_LOGW(TAG, "Inflate err %d, %zu of %zu B from %zu of %zu", res, session.written, image_size, xfer.total,
|
||||
xfer.ota_size);
|
||||
return ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
}
|
||||
ESP_LOGD(TAG, "Inflated %zu bytes from %zu", session.written, xfer.total);
|
||||
return ota::OTA_RESPONSE_OK;
|
||||
}
|
||||
#endif // USE_OTA_DEFLATE
|
||||
|
||||
void ESPHomeOTAComponent::cleanup_connection_() {
|
||||
this->client_->close();
|
||||
this->client_ = nullptr;
|
||||
@@ -747,6 +931,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
this->noise_ = nullptr;
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
this->inflate_ = nullptr;
|
||||
#endif
|
||||
// Intentionally no disable_loop() — letting loop() run one more iteration catches
|
||||
// any connection that queued on the listener mid-session (otherwise the wake flag,
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
#include "esphome/components/noise/noise_handshake.h"
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
#include "ota_esphome_inflate.h"
|
||||
#endif
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
@@ -44,8 +47,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
}
|
||||
#endif // USE_OTA_PASSWORD
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
|
||||
#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API)
|
||||
/// psk points at 32 bytes that live in flash for the life of the program
|
||||
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||
#endif
|
||||
|
||||
/// Manually set the port OTA should listen on
|
||||
@@ -85,9 +89,15 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
bool writing{false}; // a produced handshake frame is still being flushed
|
||||
uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE];
|
||||
};
|
||||
// The api server's live context when the api has encryption, else our own
|
||||
const noise::NoiseContext &noise_context_() const;
|
||||
// True once the feature ack offers noise and the client asked for it
|
||||
bool noise_offered_() const;
|
||||
void noise_reserve_session_();
|
||||
bool noise_start_session_(uint8_t server_feature_flags);
|
||||
bool handle_noise_handshake_();
|
||||
bool noise_try_read_frame_();
|
||||
size_t noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len);
|
||||
bool noise_try_write_frame_();
|
||||
void noise_send_reject_(const LogString *reason);
|
||||
ssize_t noise_decrypt_(uint8_t *buf, size_t len);
|
||||
@@ -115,6 +125,38 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
return this->readall_(buf, len);
|
||||
}
|
||||
|
||||
// Upload accounting shared by the data loop and the inflate read callback
|
||||
struct DataTransfer {
|
||||
size_t ota_size{0}; // bytes the client sends
|
||||
size_t total{0}; // bytes received so far
|
||||
#if USE_OTA_VERSION == 2
|
||||
size_t acknowledged{0};
|
||||
#endif
|
||||
uint32_t last_data_ms{0};
|
||||
uint32_t last_progress{0};
|
||||
};
|
||||
// Up to OTA_BUFFER_SIZE bytes into buf; returns bytes read, -1 on failure (logged)
|
||||
inline ssize_t receive_data_(uint8_t *buf, DataTransfer &xfer);
|
||||
// Raw lwIP cannot service the radio during a sector write, so the ack waits
|
||||
// for the write there; a socket task lets the next block arrive meanwhile
|
||||
#ifdef USE_SOCKET_IMPL_LWIP_TCP
|
||||
static constexpr bool ACK_AFTER_WRITE = true;
|
||||
#else
|
||||
static constexpr bool ACK_AFTER_WRITE = false;
|
||||
#endif
|
||||
void send_chunk_acks_(DataTransfer &xfer);
|
||||
inline void ack_received_(DataTransfer &xfer) {
|
||||
if (!ACK_AFTER_WRITE)
|
||||
this->send_chunk_acks_(xfer);
|
||||
}
|
||||
inline void ack_written_(DataTransfer &xfer) {
|
||||
if (ACK_AFTER_WRITE)
|
||||
this->send_chunk_acks_(xfer);
|
||||
}
|
||||
inline bool read_size_(uint8_t *buf, size_t &size, const LogString *desc);
|
||||
// Writes to the backend and logs a failure
|
||||
inline ota::OTAResponseTypes write_flash_(uint8_t *data, size_t len);
|
||||
|
||||
bool try_read_(size_t to_read, const LogString *desc);
|
||||
bool try_write_(size_t to_write, const LogString *desc);
|
||||
|
||||
@@ -144,7 +186,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
std::unique_ptr<uint8_t[]> auth_buf_;
|
||||
#endif // USE_OTA_PASSWORD
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
#ifndef USE_OTA_ENCRYPTION_FROM_API
|
||||
noise::NoiseContext noise_ctx_;
|
||||
#endif
|
||||
std::unique_ptr<NoiseSession> noise_;
|
||||
#endif // USE_OTA_ENCRYPTION
|
||||
|
||||
@@ -165,7 +209,34 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
static_assert(OTA_BUFFER_SIZE >= NOISE_CLIENT_MAX_PLAINTEXT + noise::MAC_SIZE,
|
||||
"OTA_BUFFER_SIZE must fit a full encrypted data frame");
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
// At least 1 << espota2.DEFLATE_WINDOW_BITS; also the inflate output buffer
|
||||
static constexpr size_t OTA_INFLATE_WINDOW_SIZE = 4096;
|
||||
// Heap-allocated only while a deflate upload is negotiated; the decoder
|
||||
// state is the base so the read callback can recover the session
|
||||
struct InflateSession : OtaInflateState {
|
||||
ESPHomeOTAComponent *self;
|
||||
DataTransfer *xfer;
|
||||
uint8_t *in; // caller's buffer for the compressed input, valid during inflate_data_
|
||||
size_t image_size;
|
||||
size_t written; // inflated bytes in flash
|
||||
size_t flushed; // bytes of the current window already in flash
|
||||
ota::OTAResponseTypes error; // first failure inside the read callback
|
||||
uint8_t window[OTA_INFLATE_WINDOW_SIZE];
|
||||
};
|
||||
#ifndef CLANG_TIDY // static analysis sets every define at once
|
||||
static_assert(!ota::OTABackend::supports_compression(),
|
||||
"USE_OTA_DEFLATE is for backends that cannot store a gzip image");
|
||||
#endif
|
||||
// Writes the decoded bytes not yet in flash without moving dest
|
||||
ota::OTAResponseTypes inflate_flush_(InflateSession &session);
|
||||
ota::OTAResponseTypes inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer);
|
||||
std::unique_ptr<InflateSession> inflate_;
|
||||
#endif
|
||||
|
||||
static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
|
||||
// Derived from the feature byte; storing it would pad the trailing bytes
|
||||
bool extended_proto_() const;
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
uint32_t running_app_offset_{0};
|
||||
size_t running_app_size_{0};
|
||||
@@ -179,7 +250,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
uint8_t auth_buf_pos_{0};
|
||||
uint8_t auth_type_{0}; // Store auth type to know which hasher to use
|
||||
#endif // USE_OTA_PASSWORD
|
||||
bool extended_proto_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
/*
|
||||
* uzlib - tiny deflate/inflate library (deflate, gzip, zlib)
|
||||
*
|
||||
* Copyright (c) 2003 by Joergen Ibsen / Jibz
|
||||
* All Rights Reserved
|
||||
* http://www.ibsensoftware.com/
|
||||
*
|
||||
* Copyright (c) 2014-2018 by Paul Sokolovsky
|
||||
*
|
||||
* This software is provided 'as-is', without any express
|
||||
* or implied warranty. In no event will the authors be
|
||||
* held liable for any damages arising from the use of
|
||||
* this software.
|
||||
*
|
||||
* Permission is granted to anyone to use this software
|
||||
* for any purpose, including commercial applications,
|
||||
* and to alter it and redistribute it freely, subject to
|
||||
* the following restrictions:
|
||||
*
|
||||
* 1. The origin of this software must not be
|
||||
* misrepresented; you must not claim that you
|
||||
* wrote the original software. If you use this
|
||||
* software in a product, an acknowledgment in
|
||||
* the product documentation would be appreciated
|
||||
* but is not required.
|
||||
*
|
||||
* 2. Altered source versions must be plainly marked
|
||||
* as such, and must not be misrepresented as
|
||||
* being the original software.
|
||||
*
|
||||
* 3. This notice may not be removed or altered from
|
||||
* any source distribution.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Altered for ESPHome: this is the raw deflate decoder from uzlib's
|
||||
* tinflate.c (v2.9.5) with the gzip/zlib header parsers, checksums,
|
||||
* runtime table builder and in-memory (non ring window) output path
|
||||
* removed, and the public names prefixed with ota_inflate.
|
||||
*/
|
||||
|
||||
#include "ota_esphome_inflate.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#define TINF_OK OTA_INFLATE_OK
|
||||
#define TINF_DONE OTA_INFLATE_DONE
|
||||
#define TINF_DATA_ERROR OTA_INFLATE_DATA_ERROR
|
||||
#define TINF_DICT_ERROR OTA_INFLATE_DICT_ERROR
|
||||
#define TINF_DATA struct OtaInflateState
|
||||
#define TINF_TREE struct OtaInflateTree
|
||||
#define TINF_ARRAY_SIZE(arr) (sizeof(arr) / sizeof(*(arr)))
|
||||
|
||||
/* every output byte also goes into the ring window */
|
||||
#define TINF_PUT(d, c) \
|
||||
{ \
|
||||
*d->dest++ = c; \
|
||||
d->dict_ring[d->dict_idx++] = c; \
|
||||
if (d->dict_idx == d->dict_size) \
|
||||
d->dict_idx = 0; \
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- *
|
||||
* -- constant tables (upstream builds them at runtime) -- *
|
||||
* --------------------------------------------------- */
|
||||
|
||||
static const unsigned char LENGTH_BITS[30] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2,
|
||||
2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5};
|
||||
static const unsigned short LENGTH_BASE[30] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27,
|
||||
31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258};
|
||||
|
||||
static const unsigned char DIST_BITS[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6,
|
||||
6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13};
|
||||
static const unsigned short DIST_BASE[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25,
|
||||
33, 49, 65, 97, 129, 193, 257, 385, 513, 769,
|
||||
1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577};
|
||||
|
||||
/* special ordering of code length codes */
|
||||
static const unsigned char CLCIDX[] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15};
|
||||
|
||||
/* ----------------------- *
|
||||
* -- utility functions -- *
|
||||
* ----------------------- */
|
||||
|
||||
/* given an array of code lengths, build a tree */
|
||||
static void tinf_build_tree(TINF_TREE *t, const unsigned char *lengths, unsigned int num) {
|
||||
unsigned short offs[16];
|
||||
unsigned int i, sum;
|
||||
|
||||
/* clear code length count table */
|
||||
for (i = 0; i < 16; ++i)
|
||||
t->table[i] = 0;
|
||||
|
||||
/* scan symbol lengths, and sum code length counts */
|
||||
for (i = 0; i < num; ++i)
|
||||
t->table[lengths[i]]++;
|
||||
|
||||
/* In the lengths array, 0 means unused code. So, t->table[0] now contains
|
||||
number of unused codes. But table's purpose is to contain # of codes of
|
||||
particular length, and there're 0 codes of length 0. */
|
||||
t->table[0] = 0;
|
||||
|
||||
/* compute offset table for distribution sort */
|
||||
for (sum = 0, i = 0; i < 16; ++i) {
|
||||
offs[i] = sum;
|
||||
sum += t->table[i];
|
||||
}
|
||||
|
||||
/* create code->symbol translation table (symbols sorted by code) */
|
||||
for (i = 0; i < num; ++i) {
|
||||
if (lengths[i])
|
||||
t->trans[offs[lengths[i]]++] = i;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------- *
|
||||
* -- decode functions -- *
|
||||
* ---------------------- */
|
||||
|
||||
static unsigned char uzlib_get_byte(TINF_DATA *d) {
|
||||
/* If end of source buffer is not reached, return next byte from source
|
||||
buffer. */
|
||||
if (d->source < d->source_limit) {
|
||||
return *d->source++;
|
||||
}
|
||||
|
||||
/* Otherwise if there's callback and we haven't seen EOF yet, try to
|
||||
read next byte using it. (Note: the callback can also update ->source
|
||||
and ->source_limit). */
|
||||
if (!d->eof) {
|
||||
int val = d->source_read_cb(d);
|
||||
if (val >= 0) {
|
||||
return (unsigned char) val;
|
||||
}
|
||||
}
|
||||
|
||||
/* Otherwise, we hit EOF (either from ->source_read_cb() or from exhaustion
|
||||
of the buffer), and it will be "sticky", i.e. further calls to this
|
||||
function will end up here too. */
|
||||
d->eof = true;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* get one bit from source stream */
|
||||
static int tinf_getbit(TINF_DATA *d) {
|
||||
unsigned int bit;
|
||||
|
||||
/* check if tag is empty */
|
||||
if (!d->bitcount--) {
|
||||
/* load next tag */
|
||||
d->tag = uzlib_get_byte(d);
|
||||
d->bitcount = 7;
|
||||
}
|
||||
|
||||
/* shift bit out of tag */
|
||||
bit = d->tag & 0x01;
|
||||
d->tag >>= 1;
|
||||
|
||||
return bit;
|
||||
}
|
||||
|
||||
/* read a num bit value from a stream and add base */
|
||||
static unsigned int tinf_read_bits(TINF_DATA *d, int num, int base) {
|
||||
unsigned int val = 0;
|
||||
|
||||
/* read num bits */
|
||||
if (num) {
|
||||
unsigned int limit = 1 << (num);
|
||||
unsigned int mask;
|
||||
|
||||
for (mask = 1; mask < limit; mask *= 2)
|
||||
if (tinf_getbit(d))
|
||||
val += mask;
|
||||
}
|
||||
|
||||
return val + base;
|
||||
}
|
||||
|
||||
/* given a data stream and a tree, decode a symbol */
|
||||
static int tinf_decode_symbol(TINF_DATA *d, TINF_TREE *t) {
|
||||
int sum = 0, cur = 0, len = 0;
|
||||
|
||||
/* get more bits while code value is above sum */
|
||||
do {
|
||||
cur = 2 * cur + tinf_getbit(d);
|
||||
|
||||
if (++len == TINF_ARRAY_SIZE(t->table)) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
sum += t->table[len];
|
||||
cur -= t->table[len];
|
||||
|
||||
} while (cur >= 0);
|
||||
|
||||
sum += cur;
|
||||
if (sum < 0 || sum >= t->size) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
return t->trans[sum];
|
||||
}
|
||||
|
||||
/* given a data stream, decode dynamic trees from it */
|
||||
static int tinf_decode_trees(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
|
||||
/* code lengths for 288 literal/len symbols and 32 dist symbols */
|
||||
unsigned char lengths[288 + 32];
|
||||
unsigned int hlit, hdist, hclen, hlimit;
|
||||
unsigned int i, num, length;
|
||||
|
||||
/* get 5 bits HLIT (257-286) */
|
||||
hlit = tinf_read_bits(d, 5, 257);
|
||||
|
||||
/* get 5 bits HDIST (1-32) */
|
||||
hdist = tinf_read_bits(d, 5, 1);
|
||||
|
||||
/* get 4 bits HCLEN (4-19) */
|
||||
hclen = tinf_read_bits(d, 4, 4);
|
||||
|
||||
for (i = 0; i < 19; ++i)
|
||||
lengths[i] = 0;
|
||||
|
||||
/* read code lengths for code length alphabet */
|
||||
for (i = 0; i < hclen; ++i) {
|
||||
/* get 3 bits code length (0-7) */
|
||||
unsigned int clen = tinf_read_bits(d, 3, 0);
|
||||
|
||||
lengths[CLCIDX[i]] = clen;
|
||||
}
|
||||
|
||||
/* build code length tree, temporarily use length tree */
|
||||
tinf_build_tree(lt, lengths, 19);
|
||||
|
||||
/* decode code lengths for the dynamic trees */
|
||||
hlimit = hlit + hdist;
|
||||
for (num = 0; num < hlimit;) {
|
||||
int sym = tinf_decode_symbol(d, lt);
|
||||
unsigned char fill_value = 0;
|
||||
int lbits, lbase = 3;
|
||||
|
||||
/* error decoding */
|
||||
if (sym < 0)
|
||||
return sym;
|
||||
|
||||
switch (sym) {
|
||||
case 16:
|
||||
/* copy previous code length 3-6 times (read 2 bits) */
|
||||
if (num == 0)
|
||||
return TINF_DATA_ERROR;
|
||||
fill_value = lengths[num - 1];
|
||||
lbits = 2;
|
||||
break;
|
||||
case 17:
|
||||
/* repeat code length 0 for 3-10 times (read 3 bits) */
|
||||
lbits = 3;
|
||||
break;
|
||||
case 18:
|
||||
/* repeat code length 0 for 11-138 times (read 7 bits) */
|
||||
lbits = 7;
|
||||
lbase = 11;
|
||||
break;
|
||||
default:
|
||||
/* values 0-15 represent the actual code lengths */
|
||||
lengths[num++] = sym;
|
||||
/* continue the for loop */
|
||||
continue;
|
||||
}
|
||||
|
||||
/* special code length 16-18 are handled here */
|
||||
length = tinf_read_bits(d, lbits, lbase);
|
||||
if (num + length > hlimit)
|
||||
return TINF_DATA_ERROR;
|
||||
for (; length; --length) {
|
||||
lengths[num++] = fill_value;
|
||||
}
|
||||
}
|
||||
|
||||
/* Check that there's "end of block" symbol */
|
||||
if (lengths[256] == 0) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* build dynamic trees */
|
||||
tinf_build_tree(lt, lengths, hlit);
|
||||
tinf_build_tree(dt, lengths + hlit, hdist);
|
||||
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* build the fixed huffman trees (RFC 1951 3.2.6) through the generic tree
|
||||
builder; altered from upstream, which unrolls them by hand */
|
||||
static void tinf_build_fixed_trees(TINF_TREE *lt, TINF_TREE *dt) {
|
||||
unsigned char lengths[288];
|
||||
unsigned int i;
|
||||
|
||||
for (i = 0; i < 144; ++i)
|
||||
lengths[i] = 8;
|
||||
for (; i < 256; ++i)
|
||||
lengths[i] = 9;
|
||||
for (; i < 280; ++i)
|
||||
lengths[i] = 7;
|
||||
for (; i < 288; ++i)
|
||||
lengths[i] = 8;
|
||||
tinf_build_tree(lt, lengths, 288);
|
||||
|
||||
for (i = 0; i < 32; ++i)
|
||||
lengths[i] = 5;
|
||||
tinf_build_tree(dt, lengths, 32);
|
||||
}
|
||||
|
||||
/* ----------------------------- *
|
||||
* -- block inflate functions -- *
|
||||
* ----------------------------- */
|
||||
|
||||
/* given a stream and two trees, inflate next chunk of output (a byte or more) */
|
||||
static int tinf_inflate_block_data(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
|
||||
if (d->curlen == 0) {
|
||||
unsigned int offs;
|
||||
int dist;
|
||||
int sym = tinf_decode_symbol(d, lt);
|
||||
|
||||
if (d->eof) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
if (sym < 0) {
|
||||
return sym;
|
||||
}
|
||||
|
||||
/* literal byte */
|
||||
if (sym < 256) {
|
||||
TINF_PUT(d, sym);
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* end of block */
|
||||
if (sym == 256) {
|
||||
return TINF_DONE;
|
||||
}
|
||||
|
||||
/* substring from sliding dictionary */
|
||||
sym -= 257;
|
||||
if (sym >= 29) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* possibly get more bits from length code */
|
||||
d->curlen = tinf_read_bits(d, LENGTH_BITS[sym], LENGTH_BASE[sym]);
|
||||
|
||||
dist = tinf_decode_symbol(d, dt);
|
||||
if (dist < 0 || dist >= 30) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* possibly get more bits from distance code */
|
||||
offs = tinf_read_bits(d, DIST_BITS[dist], DIST_BASE[dist]);
|
||||
|
||||
/* calculate and validate actual LZ offset to use */
|
||||
if (offs > d->dict_size) {
|
||||
return TINF_DICT_ERROR;
|
||||
}
|
||||
/* Note: we don't try to catch offset which points to not yet filled
|
||||
part of the dictionary here. Doing so would require keeping another
|
||||
variable to track "filled in" size of the dictionary. Appearance of
|
||||
such an offset cannot lead to accessing memory outside of the
|
||||
dictionary buffer, and clients which don't want to leak unrelated
|
||||
information, should explicitly initialize dictionary buffer passed
|
||||
to uzlib. */
|
||||
|
||||
d->lz_off = d->dict_idx - offs;
|
||||
if (d->lz_off < 0) {
|
||||
d->lz_off += d->dict_size;
|
||||
}
|
||||
}
|
||||
|
||||
/* copy next byte from dict substring */
|
||||
TINF_PUT(d, d->dict_ring[d->lz_off]);
|
||||
if ((unsigned) ++d->lz_off == d->dict_size) {
|
||||
d->lz_off = 0;
|
||||
}
|
||||
d->curlen--;
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* inflate next byte from uncompressed block of data */
|
||||
static int tinf_inflate_uncompressed_block(TINF_DATA *d) {
|
||||
if (d->curlen == 0) {
|
||||
unsigned int length, invlength;
|
||||
|
||||
/* get length */
|
||||
length = uzlib_get_byte(d);
|
||||
length += 256 * uzlib_get_byte(d);
|
||||
/* get one's complement of length */
|
||||
invlength = uzlib_get_byte(d);
|
||||
invlength += 256 * uzlib_get_byte(d);
|
||||
/* check length */
|
||||
if (length != (~invlength & 0x0000ffff))
|
||||
return TINF_DATA_ERROR;
|
||||
|
||||
/* increment length to properly return TINF_DONE below, without
|
||||
producing data at the same time */
|
||||
d->curlen = length + 1;
|
||||
|
||||
/* make sure we start next block on a byte boundary */
|
||||
d->bitcount = 0;
|
||||
}
|
||||
|
||||
if (--d->curlen == 0) {
|
||||
return TINF_DONE;
|
||||
}
|
||||
|
||||
unsigned char c = uzlib_get_byte(d);
|
||||
TINF_PUT(d, c);
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* ---------------------- *
|
||||
* -- public functions -- *
|
||||
* ---------------------- */
|
||||
|
||||
/* initialize decompression structure */
|
||||
void ota_inflate_init(TINF_DATA *d, unsigned char *dict, unsigned int dict_len) {
|
||||
d->source = NULL;
|
||||
d->source_limit = NULL;
|
||||
d->tag = 0;
|
||||
d->eof = 0;
|
||||
d->bitcount = 0;
|
||||
d->lz_off = 0;
|
||||
d->bfinal = 0;
|
||||
d->btype = -1;
|
||||
d->dict_size = dict_len;
|
||||
d->dict_ring = dict;
|
||||
d->dict_idx = 0;
|
||||
d->curlen = 0;
|
||||
d->ltree.trans = d->ltrans;
|
||||
d->ltree.size = TINF_ARRAY_SIZE(d->ltrans);
|
||||
d->dtree.trans = d->dtrans;
|
||||
d->dtree.size = TINF_ARRAY_SIZE(d->dtrans);
|
||||
}
|
||||
|
||||
/* inflate next output bytes from compressed stream */
|
||||
int ota_inflate(TINF_DATA *d) {
|
||||
do {
|
||||
int res;
|
||||
|
||||
/* start a new block */
|
||||
if (d->btype == -1) {
|
||||
int old_btype;
|
||||
next_blk:
|
||||
old_btype = d->btype;
|
||||
/* read final block flag */
|
||||
d->bfinal = tinf_getbit(d);
|
||||
/* read block type (2 bits) */
|
||||
d->btype = tinf_read_bits(d, 2, 0);
|
||||
|
||||
if (d->btype == 1 && old_btype != 1) {
|
||||
/* build fixed huffman trees */
|
||||
tinf_build_fixed_trees(&d->ltree, &d->dtree);
|
||||
} else if (d->btype == 2) {
|
||||
/* decode trees from stream */
|
||||
res = tinf_decode_trees(d, &d->ltree, &d->dtree);
|
||||
if (res != TINF_OK) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* process current block */
|
||||
switch (d->btype) {
|
||||
case 0:
|
||||
/* decompress uncompressed block */
|
||||
res = tinf_inflate_uncompressed_block(d);
|
||||
break;
|
||||
case 1:
|
||||
case 2:
|
||||
/* decompress block with fixed/dynamic huffman trees */
|
||||
/* trees were decoded previously, so it's the same routine for both */
|
||||
res = tinf_inflate_block_data(d, &d->ltree, &d->dtree);
|
||||
break;
|
||||
default:
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
if (res == TINF_DONE && !d->bfinal) {
|
||||
/* the block has ended (without producing more data), but we
|
||||
can't return without data, so start procesing next block */
|
||||
goto next_blk;
|
||||
}
|
||||
|
||||
if (res != TINF_OK) {
|
||||
return res;
|
||||
}
|
||||
|
||||
} while (d->dest < d->dest_limit);
|
||||
|
||||
return TINF_OK;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
// Raw deflate decoder cut down from uzlib (https://github.com/pfalcon/uzlib,
|
||||
// zlib licence, see the .c file); output goes through a ring window.
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
enum OtaInflateResult {
|
||||
OTA_INFLATE_OK = 0, /* more data produced, call again */
|
||||
OTA_INFLATE_DONE = 1, /* end of compressed stream reached */
|
||||
OTA_INFLATE_DATA_ERROR = -3,
|
||||
OTA_INFLATE_DICT_ERROR = -5,
|
||||
};
|
||||
|
||||
struct OtaInflateTree {
|
||||
uint16_t table[16]; /* table of code length counts */
|
||||
uint16_t *trans; /* code -> symbol translation table, size entries */
|
||||
uint16_t size;
|
||||
};
|
||||
|
||||
struct OtaInflateState {
|
||||
/* Next byte in the input buffer and one past its end */
|
||||
const unsigned char *source;
|
||||
const unsigned char *source_limit;
|
||||
/* Called when source is exhausted; returns the next byte or -1 at EOF.
|
||||
It may refill source/source_limit for buffered operation. */
|
||||
int (*source_read_cb)(struct OtaInflateState *d);
|
||||
|
||||
unsigned int tag;
|
||||
unsigned int bitcount;
|
||||
|
||||
/* Output cursor and one past the end of the output buffer */
|
||||
unsigned char *dest;
|
||||
unsigned char *dest_limit;
|
||||
|
||||
bool eof;
|
||||
|
||||
int btype;
|
||||
int bfinal;
|
||||
unsigned int curlen;
|
||||
int lz_off;
|
||||
/* Ring window holding the last dict_size output bytes for back references */
|
||||
unsigned char *dict_ring;
|
||||
unsigned int dict_size;
|
||||
unsigned int dict_idx;
|
||||
|
||||
struct OtaInflateTree ltree; /* dynamic length/symbol tree */
|
||||
struct OtaInflateTree dtree; /* dynamic distance tree */
|
||||
uint16_t ltrans[288];
|
||||
uint16_t dtrans[32]; /* the distance alphabet has 30 symbols, so the tree is kept small */
|
||||
};
|
||||
|
||||
/* dict must cover the encoder's window (its largest back reference) */
|
||||
void ota_inflate_init(struct OtaInflateState *d, unsigned char *dict, unsigned int dict_len);
|
||||
/* Fills dest up to dest_limit (OK) or to the end of the stream (DONE). dest may
|
||||
alias dict only if dest_limit - dest == dict_len and dest is reset to dict
|
||||
exactly when a call returns OK, so the ring index and dest stay in lockstep */
|
||||
int ota_inflate(struct OtaInflateState *d);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -3,6 +3,7 @@
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
#include "esphome/components/noise/noise.h"
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
@@ -32,7 +33,13 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Allocate the session and start the responder handshake.
|
||||
void ESPHomeOTAComponent::noise_reserve_session_() {
|
||||
// Default-init: the frame buffer is written before it is read
|
||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession);
|
||||
}
|
||||
|
||||
/** Start the responder handshake, on the session reserved at offer time.
|
||||
*
|
||||
* The prologue binds the whole plaintext preamble, so any tampering with the
|
||||
* negotiation (a stripped feature flag, a changed version) breaks the first
|
||||
@@ -40,24 +47,14 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
|
||||
* "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags
|
||||
*/
|
||||
bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
|
||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession());
|
||||
if (this->noise_ == nullptr) {
|
||||
ESP_LOGW(TAG, "Session allocation failed");
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
|
||||
// A provisioned key cleared between the offer and here is not guarded: the
|
||||
// session runs on the zero key load_psk fills in and fails the client's MAC
|
||||
static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version
|
||||
static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1;
|
||||
static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags
|
||||
uint8_t prologue[OTA_NOISE_PROLOGUE_INIT_LEN + sizeof(MAGIC_BYTES) + PROLOGUE_ACK_LEN + PROLOGUE_CLIENT_FEATURES_LEN +
|
||||
PROLOGUE_FEATURE_ACK_LEN];
|
||||
#ifdef USE_ESP8266
|
||||
memcpy_P(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
|
||||
#else
|
||||
std::memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
|
||||
#endif
|
||||
progmem_memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
|
||||
uint8_t *p = prologue + OTA_NOISE_PROLOGUE_INIT_LEN;
|
||||
// Magic bytes, already validated in MAGIC_READ
|
||||
std::memcpy(p, MAGIC_BYTES, sizeof(MAGIC_BYTES));
|
||||
@@ -71,9 +68,13 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
|
||||
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
|
||||
*p++ = server_feature_flags;
|
||||
|
||||
int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue));
|
||||
// The caller only starts a session when the context holds a key
|
||||
int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY
|
||||
: this->noise_->handshake.init(this->noise_context_(), prologue, sizeof(prologue));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
// Raw noise codes throughout: the name table would cost flash in builds
|
||||
// where only the OTA uses noise
|
||||
ESP_LOGW(TAG, "Session init: %d", err);
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
@@ -105,14 +106,16 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
|
||||
s.frame_pos = 0;
|
||||
s.frame_len = 0;
|
||||
if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) {
|
||||
ESP_LOGW(TAG, "Bad handshake error byte: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
|
||||
ESP_LOGW(TAG, "Client rejected the handshake: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Handshake read: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
this->noise_send_reject_(noise::reject_reason_for(err));
|
||||
// A MAC failure here almost always means the uploader has a different key
|
||||
const LogString *reason = noise::reject_reason_for(err);
|
||||
ESP_LOGW(TAG, "Handshake read: %s (%d)", LOG_STR_ARG(reason), err);
|
||||
this->noise_send_reject_(reason);
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
@@ -123,7 +126,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
|
||||
int err =
|
||||
s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Handshake write: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
ESP_LOGW(TAG, "Handshake write: %d", err);
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
@@ -138,7 +141,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
|
||||
case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: {
|
||||
int err = s.handshake.split(s.send_cipher, s.recv_cipher);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Handshake split: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
ESP_LOGW(TAG, "Handshake split: %d", err);
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
@@ -154,33 +157,41 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload length from a frame header, or 0 (logged) when the indicator or
|
||||
/// the length is out of range. Callers pass min_len >= 1 so 0 is never valid.
|
||||
size_t ESPHomeOTAComponent::noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len) {
|
||||
const size_t payload_len = encode_uint16(header[1], header[2]);
|
||||
if (header[0] != noise::FRAME_INDICATOR || payload_len < min_len || payload_len > max_len) {
|
||||
ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], payload_len);
|
||||
return 0;
|
||||
}
|
||||
return payload_len;
|
||||
}
|
||||
|
||||
/// Non-blocking read of one handshake frame into the session buffer.
|
||||
bool ESPHomeOTAComponent::noise_try_read_frame_() {
|
||||
NoiseSession &s = *this->noise_;
|
||||
while (s.frame_pos < noise::FRAME_HEADER_SIZE) {
|
||||
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, noise::FRAME_HEADER_SIZE - s.frame_pos);
|
||||
if (!this->handle_read_error_(read, LOG_STR("read noise header"))) {
|
||||
return false;
|
||||
while (true) {
|
||||
// The header first, then the body once the header says how long it is
|
||||
const uint16_t want = s.frame_len == 0 ? noise::FRAME_HEADER_SIZE : s.frame_len;
|
||||
if (s.frame_pos < want) {
|
||||
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, want - s.frame_pos);
|
||||
if (!this->handle_read_error_(read, LOG_STR("read noise"))) {
|
||||
return false;
|
||||
}
|
||||
s.frame_pos += read;
|
||||
continue;
|
||||
}
|
||||
s.frame_pos += read;
|
||||
}
|
||||
if (s.frame_len == 0) {
|
||||
const uint16_t payload_len = encode_uint16(s.frame_buf[1], s.frame_buf[2]);
|
||||
if (s.frame_buf[0] != noise::FRAME_INDICATOR || payload_len < 1 || payload_len > 1 + noise::MAX_HANDSHAKE_SIZE) {
|
||||
ESP_LOGW(TAG, "Bad handshake frame: 0x%02X, %u bytes", s.frame_buf[0], payload_len);
|
||||
if (s.frame_len != 0) {
|
||||
return true;
|
||||
}
|
||||
const size_t payload_len = this->noise_frame_payload_len_(s.frame_buf, 1, 1 + noise::MAX_HANDSHAKE_SIZE);
|
||||
if (payload_len == 0) {
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
s.frame_len = noise::FRAME_HEADER_SIZE + payload_len;
|
||||
}
|
||||
while (s.frame_pos < s.frame_len) {
|
||||
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos);
|
||||
if (!this->handle_read_error_(read, LOG_STR("read noise frame"))) {
|
||||
return false;
|
||||
}
|
||||
s.frame_pos += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Non-blocking write of the pending session-buffer frame.
|
||||
@@ -214,7 +225,7 @@ ssize_t ESPHomeOTAComponent::noise_decrypt_(uint8_t *buf, size_t len) {
|
||||
noise_buffer_set_inout(mbuf, buf, len, len);
|
||||
int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Decrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
ESP_LOGW(TAG, "Decrypt: %d", err);
|
||||
return -1;
|
||||
}
|
||||
return mbuf.size;
|
||||
@@ -229,9 +240,8 @@ ssize_t ESPHomeOTAComponent::noise_read_frame_blocking_(uint8_t *buf, size_t min
|
||||
if (!this->readall_(header, sizeof(header))) {
|
||||
return -1;
|
||||
}
|
||||
const size_t ciphertext_len = encode_uint16(header[1], header[2]);
|
||||
if (header[0] != noise::FRAME_INDICATOR || ciphertext_len < min_ciphertext || ciphertext_len > max_ciphertext) {
|
||||
ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], ciphertext_len);
|
||||
const size_t ciphertext_len = this->noise_frame_payload_len_(header, min_ciphertext, max_ciphertext);
|
||||
if (ciphertext_len == 0) {
|
||||
return -1;
|
||||
}
|
||||
if (!this->readall_(buf, ciphertext_len)) {
|
||||
@@ -267,7 +277,7 @@ bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) {
|
||||
noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE);
|
||||
int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Encrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
ESP_LOGW(TAG, "Encrypt: %d", err);
|
||||
return false;
|
||||
}
|
||||
noise::write_frame_header(frame, mbuf.size);
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any
|
||||
from esphome import automation, core
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import wifi
|
||||
from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant
|
||||
from esphome.components.udp import CONF_ON_RECEIVE
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -17,6 +18,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
@@ -132,6 +134,24 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _validate_variant(config: ConfigType) -> ConfigType:
|
||||
# ESP-NOW rides the Wi-Fi PHY. Radio-less esp32 variants have no native
|
||||
# ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that
|
||||
# supplies the esp_now_* symbols. Fail here with a clear message instead of
|
||||
# letting the build reach an "undefined reference to esp_now_*" link error.
|
||||
variant = get_esp32_variant()
|
||||
if wifi.variant_has_wifi(variant):
|
||||
return config
|
||||
if variant != VARIANT_ESP32P4:
|
||||
raise cv.Invalid(f"ESP-NOW is not supported on {variant} (no Wi-Fi radio)")
|
||||
if "esp32_hosted" not in fv.full_config.get():
|
||||
raise cv.Invalid(f"ESP-NOW on {variant} requires the esp32_hosted component")
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_variant
|
||||
|
||||
|
||||
async def _trigger_to_code(config: ConfigType) -> MockObj:
|
||||
if address := config.get(CONF_ADDRESS):
|
||||
address = address.parts
|
||||
|
||||
@@ -91,7 +91,14 @@ void I2SAudioSpeakerBase::loop() {
|
||||
this->speaker_task_handle_ = nullptr;
|
||||
|
||||
this->stop_i2s_driver_();
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
|
||||
// ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at
|
||||
// the top of loop(): the audio source's task can raise a start at any point above, including
|
||||
// during stop_i2s_driver_(), and nothing would ever re-issue it.
|
||||
const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
|
||||
if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) {
|
||||
ESP_LOGD(TAG, "Start requested while stopping; keeping the request");
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START);
|
||||
}
|
||||
this->status_clear_error();
|
||||
|
||||
this->on_task_stopped();
|
||||
@@ -111,21 +118,24 @@ void I2SAudioSpeakerBase::loop() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Still starting up or winding down from a previous run
|
||||
if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
|
||||
this->status_momentary_error("driver-failure", 1000);
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||
&this->speaker_task_handle_);
|
||||
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||
&this->speaker_task_handle_);
|
||||
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
||||
this->status_momentary_error("task-failure", 1000);
|
||||
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
||||
}
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
||||
this->status_momentary_error("task-failure", 1000);
|
||||
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
||||
}
|
||||
break;
|
||||
case speaker::STATE_RUNNING: // Intentional fallthrough
|
||||
@@ -211,8 +221,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t
|
||||
}
|
||||
|
||||
bool I2SAudioSpeakerBase::has_buffered_data() const {
|
||||
if (this->audio_ring_buffer_.use_count() > 0) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
return temp_ring_buffer->available() > 0;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <esp_log.h>
|
||||
|
||||
#include <driver/uart.h>
|
||||
#include <soc/soc_caps.h>
|
||||
|
||||
#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
|
||||
#include <driver/usb_serial_jtag.h>
|
||||
@@ -76,7 +77,11 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) {
|
||||
uart_config.parity = UART_PARITY_DISABLE;
|
||||
uart_config.stop_bits = UART_STOP_BITS_1;
|
||||
uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE;
|
||||
#if SOC_UART_SUPPORT_XTAL_CLK
|
||||
uart_config.source_clk = UART_SCLK_XTAL;
|
||||
#else
|
||||
uart_config.source_clk = UART_SCLK_DEFAULT;
|
||||
#endif
|
||||
uart_param_config(uart_num, &uart_config);
|
||||
// The logger only writes to UART, never reads, so use the minimum RX buffer.
|
||||
// ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes).
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..defines import (
|
||||
from ..types import LvCompound, LvType
|
||||
from . import Widget, WidgetType, get_widgets
|
||||
from .buttonmatrix import CONF_BUTTONMATRIX
|
||||
from .label import CONF_LABEL
|
||||
from .textarea import CONF_TEXTAREA, lv_textarea_t
|
||||
|
||||
CONF_KEYBOARD = "keyboard"
|
||||
@@ -49,7 +50,7 @@ class KeyboardType(WidgetType):
|
||||
)
|
||||
|
||||
def get_uses(self):
|
||||
return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX
|
||||
return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX, CONF_LABEL
|
||||
|
||||
async def to_code(self, w: Widget, config: dict):
|
||||
add_lv_use("KEY_LISTENER")
|
||||
|
||||
@@ -10,6 +10,7 @@ from ..types import lv_obj_t
|
||||
from . import Widget, WidgetType
|
||||
from .canvas import CONF_CANVAS
|
||||
from .img import CONF_IMAGE
|
||||
from .label import CONF_LABEL
|
||||
|
||||
CONF_QRCODE = "qrcode"
|
||||
CONF_DARK_COLOR = "dark_color"
|
||||
@@ -41,7 +42,7 @@ class QrCodeType(WidgetType):
|
||||
)
|
||||
|
||||
def get_uses(self):
|
||||
return CONF_CANVAS, CONF_IMAGE
|
||||
return CONF_CANVAS, CONF_IMAGE, CONF_LABEL
|
||||
|
||||
async def to_code(self, w: Widget, config):
|
||||
await w.set_property(
|
||||
|
||||
@@ -28,6 +28,7 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr
|
||||
from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties
|
||||
from .button import button_spec
|
||||
from .buttonmatrix import CONF_BUTTONMATRIX, buttonmatrix_spec
|
||||
from .label import CONF_LABEL
|
||||
from .obj import obj_spec
|
||||
|
||||
CONF_TABVIEW = "tabview"
|
||||
@@ -74,7 +75,7 @@ class TabviewType(WidgetType):
|
||||
)
|
||||
|
||||
def get_uses(self):
|
||||
return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON
|
||||
return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON, CONF_LABEL
|
||||
|
||||
async def to_code(self, w: Widget, config: dict):
|
||||
await w.set_property(
|
||||
|
||||
@@ -192,6 +192,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
if CORE.using_arduino:
|
||||
if CORE.is_esp8266:
|
||||
cg.add_library("ESP8266mDNS", None)
|
||||
# No MDNS global in the build; mdns_esp8266.cpp owns a guarded MDNSResponder
|
||||
cg.add_build_flag("-DNO_GLOBAL_MDNS")
|
||||
elif CORE.is_rp2:
|
||||
cg.add_library("LEAmDNS", None)
|
||||
|
||||
|
||||
@@ -13,8 +13,47 @@
|
||||
|
||||
namespace esphome::mdns {
|
||||
|
||||
// Main-loop calls into LEAmDNS that send (update() and close(); begin(), addService() and
|
||||
// the scheduled restart never reach a send) can yield inside UdpContext::sendTimeout(); a
|
||||
// packet arriving then re-enters LEAmDNS from lwIP on the same UdpContext and both sides
|
||||
// free the same tx pbufs (#18760). Received packets stay queued during such a call and are
|
||||
// processed from the main loop afterwards.
|
||||
class GuardedMDNSResponder : public ::esp8266::MDNSImplementation::MDNSResponder {
|
||||
public:
|
||||
void update_guarded() { this->run_guarded_(&GuardedMDNSResponder::update); }
|
||||
void close_guarded() { this->run_guarded_(&GuardedMDNSResponder::close); }
|
||||
|
||||
private:
|
||||
void run_guarded_(bool (GuardedMDNSResponder::*fn)()) {
|
||||
UdpContext *ctx = this->m_pUDPContext;
|
||||
if (ctx == nullptr) {
|
||||
(this->*fn)();
|
||||
return;
|
||||
}
|
||||
// Set every time: a restart replaces the context together with its stock handler. Only
|
||||
// begin() and the scheduled netif callback restart, never update() or close(), so the
|
||||
// context cannot change underneath this call.
|
||||
ctx->onRx([this]() {
|
||||
if (!this->in_loop_call_) {
|
||||
this->_callProcess();
|
||||
}
|
||||
});
|
||||
this->in_loop_call_ = true;
|
||||
(this->*fn)();
|
||||
// close() releases the context; a yield in here queues further packets for this loop too
|
||||
while (this->m_pUDPContext != nullptr && this->m_pUDPContext->next()) {
|
||||
this->_parseMessage();
|
||||
}
|
||||
this->in_loop_call_ = false;
|
||||
}
|
||||
|
||||
volatile bool in_loop_call_{false};
|
||||
};
|
||||
|
||||
static GuardedMDNSResponder mdns_responder; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services) {
|
||||
MDNS.begin(App.get_name().c_str());
|
||||
mdns_responder.begin(App.get_name().c_str());
|
||||
|
||||
for (const auto &service : services) {
|
||||
// Strip the leading underscore from the proto and service_type. While it is
|
||||
@@ -30,10 +69,10 @@ static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SER
|
||||
service_type++;
|
||||
}
|
||||
uint16_t port = service.port.value();
|
||||
MDNS.addService(FPSTR(service_type), FPSTR(proto), port);
|
||||
mdns_responder.addService(FPSTR(service_type), FPSTR(proto), port);
|
||||
for (const auto &record : service.txt_records) {
|
||||
MDNS.addServiceTxt(FPSTR(service_type), FPSTR(proto), FPSTR(MDNS_STR_ARG(record.key)),
|
||||
FPSTR(MDNS_STR_ARG(record.value)));
|
||||
mdns_responder.addServiceTxt(FPSTR(service_type), FPSTR(proto), FPSTR(MDNS_STR_ARG(record.key)),
|
||||
FPSTR(MDNS_STR_ARG(record.value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +91,7 @@ void MDNSComponent::start_polling_window_() {
|
||||
if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active()))
|
||||
return;
|
||||
#endif
|
||||
MDNS.update();
|
||||
mdns_responder.update_guarded();
|
||||
});
|
||||
this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); });
|
||||
}
|
||||
@@ -81,7 +120,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::
|
||||
#endif
|
||||
|
||||
void MDNSComponent::on_shutdown() {
|
||||
MDNS.close();
|
||||
mdns_responder.close_guarded();
|
||||
delay(10);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ void MicroWakeWord::setup() {
|
||||
return;
|
||||
}
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (this->ring_buffer_.use_count() > 1) {
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
|
||||
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
|
||||
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
|
||||
@@ -446,9 +446,9 @@ void MicroWakeWord::loop() {
|
||||
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
|
||||
}
|
||||
|
||||
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
|
||||
// Retries on a subsequent loop if the task is still running on the other core
|
||||
if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) {
|
||||
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
|
||||
this->inference_task_.deallocate();
|
||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||
xQueueReset(this->detection_queue_);
|
||||
this->set_state_(State::STOPPED);
|
||||
|
||||
@@ -48,7 +48,7 @@ class MicrophoneSource final {
|
||||
template<typename F> void add_data_callback(F &&data_callback) {
|
||||
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
|
||||
if (this->enabled_ || this->passive_) {
|
||||
if (this->processed_samples_.use_count() == 0) {
|
||||
if (this->processed_samples_ == nullptr) {
|
||||
// Create vector if its unused
|
||||
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ void MipiDsi::setup() {
|
||||
.bus_id = 0, // index from 0, specify the DSI host to use
|
||||
.num_data_lanes =
|
||||
this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability
|
||||
.phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, // Clock source for the DPHY
|
||||
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
|
||||
// phy_clk_src left at 0 to enable runtime auto-select.
|
||||
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
|
||||
};
|
||||
auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_);
|
||||
if (err != ESP_OK) {
|
||||
|
||||
@@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_
|
||||
}
|
||||
size_t bytes_written = 0;
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (temp_ring_buffer.use_count() > 0) {
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
// Only write to the ring buffer if the reference is valid
|
||||
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
||||
if (bytes_written > 0) {
|
||||
@@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() {
|
||||
// avoids unnecessary single-frame splices.
|
||||
const size_t ring_buffer_size =
|
||||
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
|
||||
if (this->audio_source_.use_count() == 0) {
|
||||
if (this->audio_source_ == nullptr) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (!temp_ring_buffer) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||
this->ring_buffer_ = temp_ring_buffer;
|
||||
}
|
||||
|
||||
if (!temp_ring_buffer) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
|
||||
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
|
||||
|
||||
bool SourceSpeaker::has_buffered_data() const {
|
||||
return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
|
||||
return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data());
|
||||
}
|
||||
|
||||
void SourceSpeaker::set_mute_state(bool mute_state) {
|
||||
@@ -382,8 +382,8 @@ void MixerSpeaker::loop() {
|
||||
ESP_LOGV(TAG, "Stopping");
|
||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
|
||||
}
|
||||
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
|
||||
this->task_.deallocate();
|
||||
// Retries on a subsequent loop if the task is still running on the other core
|
||||
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
|
||||
ESP_LOGD(TAG, "Stopped");
|
||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
|
||||
this->all_stopped_since_ms_ = 0;
|
||||
@@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
if (speaker->is_running() && !speaker->get_pause_state()) {
|
||||
// Speaker is running and not paused, so it possibly can provide audio data
|
||||
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
|
||||
if (audio_source.use_count() == 0) {
|
||||
if (audio_source == nullptr) {
|
||||
// No audio source allocated, so skip processing this speaker
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery
|
||||
if (traits.supports_color_mode(ColorMode::RGB_COLD_WARM_WHITE))
|
||||
color_modes.add(ESPHOME_F("rgbww"));
|
||||
|
||||
if (traits.supports_color_capability(ColorCapability::BRIGHTNESS))
|
||||
root[ESPHOME_F("brightness")] = true;
|
||||
|
||||
if (traits.supports_color_mode(ColorMode::COLOR_TEMPERATURE) ||
|
||||
traits.supports_color_mode(ColorMode::COLD_WARM_WHITE)) {
|
||||
root[MQTT_MIN_MIREDS] = traits.get_min_mireds();
|
||||
|
||||
@@ -14,12 +14,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, TimePeriod
|
||||
|
||||
from . import ( # noqa: F401 pylint: disable=unused-import
|
||||
FILTER_SOURCE_FILES,
|
||||
Nextion,
|
||||
nextion_ns,
|
||||
nextion_ref,
|
||||
)
|
||||
from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import
|
||||
from .base_component import (
|
||||
CONF_AUTO_WAKE_ON_TOUCH,
|
||||
CONF_COMMAND_SPACING,
|
||||
|
||||
@@ -209,14 +209,8 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) {
|
||||
http_client.setTimeout(this->tft_upload_http_timeout_);
|
||||
|
||||
bool begin_status = false;
|
||||
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 7, 0)
|
||||
http_client.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
#elif USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 6, 0)
|
||||
http_client.setFollowRedirects(true);
|
||||
#endif
|
||||
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 6, 0)
|
||||
http_client.setRedirectLimit(3);
|
||||
#endif
|
||||
begin_status = http_client.begin(*this->get_wifi_client_(), this->tft_url_.c_str());
|
||||
if (!begin_status) {
|
||||
this->connection_state_.is_updating_ = false;
|
||||
|
||||
@@ -4,7 +4,9 @@ from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_KEY
|
||||
from esphome.const import CONF_ENCRYPTION, CONF_KEY
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
@@ -23,6 +25,14 @@ def validate_encryption_key(value: Any) -> str:
|
||||
|
||||
if len(decoded) != 32:
|
||||
raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
|
||||
if not any(decoded):
|
||||
# The device treats the all-zeros key as no key at all (it is the
|
||||
# provisioning sentinel), so it must never reach a build
|
||||
raise cv.Invalid(
|
||||
f"The all-zeros {CONF_KEY} is reserved and provides no protection; "
|
||||
f"omit the {CONF_KEY} to provision it at runtime, or generate a real "
|
||||
"key with: openssl rand -base64 32"
|
||||
)
|
||||
|
||||
# Return original data for roundtrip conversion
|
||||
return value
|
||||
@@ -45,15 +55,6 @@ def decode_encryption_key(value: str) -> bytes:
|
||||
return decoded
|
||||
|
||||
|
||||
def is_reserved_key(value: str) -> bool:
|
||||
"""Whether the key is the reserved all-zeros provisioning sentinel.
|
||||
|
||||
The device treats it as no key configured, so consumers that require a
|
||||
real key must reject it.
|
||||
"""
|
||||
return not any(decode_encryption_key(value))
|
||||
|
||||
|
||||
ENCRYPTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
|
||||
@@ -61,6 +62,21 @@ ENCRYPTION_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def static_encryption_key(conf: ConfigType) -> str | None:
|
||||
"""The build time key of a component config; None without one or when
|
||||
the key is provisioned at runtime."""
|
||||
return (conf.get(CONF_ENCRYPTION) or {}).get(CONF_KEY) or None
|
||||
|
||||
|
||||
def new_psk_progmem(parent_id: ID, key: str) -> MockObj:
|
||||
"""Emit the decoded key as a PROGMEM array; the component keeps a pointer
|
||||
so the key never occupies RAM."""
|
||||
return cg.progmem_array(
|
||||
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
|
||||
list(decode_encryption_key(key)),
|
||||
)
|
||||
|
||||
|
||||
def encryption_schema(config: ConfigType | None) -> ConfigType:
|
||||
# A bare `encryption:` block is valid; a missing key means the consumer
|
||||
# falls back to its keyless behavior (api provisioning, ota inheriting
|
||||
@@ -72,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_NOISE")
|
||||
cg.add_library("esphome/noise-c", "0.1.21")
|
||||
cg.add_library("esphome/noise-c", "0.1.26")
|
||||
# noise-c depends on libsodium, but declaring it here too lets the
|
||||
# library manager see the full set up front instead of discovering
|
||||
# libsodium only after noise-c has downloaded, so the two can download
|
||||
# in parallel. The version must match noise-c's library.json.
|
||||
cg.add_library("esphome/libsodium", "1.10021.4")
|
||||
cg.add_library("esphome/libsodium", "1.10021.8")
|
||||
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
||||
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
||||
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "noise.h"
|
||||
#ifdef USE_NOISE
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -15,6 +16,14 @@ namespace esphome::noise {
|
||||
|
||||
static const char *const TAG = "noise";
|
||||
|
||||
void NoiseContext::load_psk(psk_t &out) const {
|
||||
if (this->psk_ == nullptr) {
|
||||
out.fill(0);
|
||||
return;
|
||||
}
|
||||
progmem_memcpy(out.data(), this->psk_, out.size());
|
||||
}
|
||||
|
||||
const LogString *noise_err_to_logstr(int err) {
|
||||
if (err == NOISE_ERROR_NO_MEMORY)
|
||||
return LOG_STR("NO_MEMORY");
|
||||
|
||||
@@ -23,16 +23,16 @@ class NoiseContext {
|
||||
}
|
||||
return acc == 0;
|
||||
}
|
||||
void set_psk(psk_t psk) {
|
||||
this->psk_ = psk;
|
||||
this->has_psk_ = !is_all_zeros(psk);
|
||||
}
|
||||
const psk_t &get_psk() const { return this->psk_; }
|
||||
bool has_psk() const { return this->has_psk_; }
|
||||
/// psk points at 32 bytes that outlive the context (PROGMEM or caller owned
|
||||
/// RAM); nullptr means no key. Runtime callers map the all-zeros key to
|
||||
/// nullptr themselves; validation keeps it out of yaml.
|
||||
void set_psk(const uint8_t *psk) { this->psk_ = psk; }
|
||||
/// Copy the key out (flash-aware on ESP8266); all zeros when none is set.
|
||||
void load_psk(psk_t &out) const;
|
||||
bool has_psk() const { return this->psk_ != nullptr; }
|
||||
|
||||
protected:
|
||||
psk_t psk_{};
|
||||
bool has_psk_{false};
|
||||
const uint8_t *psk_{nullptr};
|
||||
};
|
||||
|
||||
/// Convert a noise error code to a readable error
|
||||
|
||||
@@ -20,7 +20,7 @@ NoiseResponderHandshake::~NoiseResponderHandshake() {
|
||||
}
|
||||
}
|
||||
|
||||
int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
|
||||
int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len) {
|
||||
if (this->handshake_ != nullptr) {
|
||||
noise_handshakestate_free(this->handshake_);
|
||||
this->handshake_ = nullptr;
|
||||
@@ -44,6 +44,9 @@ int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, siz
|
||||
HANDSHAKE_STEP_LOG("noise_handshakestate_new_by_id", err);
|
||||
return err;
|
||||
}
|
||||
// noise-c keeps its own copy, so the key only passes through the stack here
|
||||
psk_t psk;
|
||||
ctx.load_psk(psk);
|
||||
err = noise_handshakestate_set_pre_shared_key(this->handshake_, psk.data(), psk.size());
|
||||
if (err != 0) {
|
||||
HANDSHAKE_STEP_LOG("noise_handshakestate_set_pre_shared_key", err);
|
||||
|
||||
@@ -36,9 +36,9 @@ class NoiseResponderHandshake {
|
||||
NoiseResponderHandshake(const NoiseResponderHandshake &) = delete;
|
||||
NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete;
|
||||
|
||||
/// Create and start the handshake with the given PSK and prologue. A
|
||||
/// repeated call frees the previous handshake state and starts over.
|
||||
[[nodiscard]] int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len);
|
||||
/// Create and start the handshake with the context's PSK and the prologue.
|
||||
/// A repeated call frees the previous handshake state and starts over.
|
||||
[[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len);
|
||||
/// ACTION_FAILED is the catch-all: returned before init(), after split()
|
||||
/// has released the state, and when noise-c reports a failed handshake.
|
||||
[[nodiscard]] Action action() const;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <concepts>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
#include <vector>
|
||||
@@ -102,6 +103,8 @@ enum OTAType : uint8_t {
|
||||
// - set_update_md5: expected digest of the incoming image, hex string.
|
||||
// - write: consume the next chunk; end: finalize and mark bootable.
|
||||
// - abort: safe to call in any state, including after end().
|
||||
// - supports_compression: constexpr, whether a gzip image is stored as is and
|
||||
// inflated at reboot.
|
||||
template<typename T>
|
||||
concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *data, size_t len, const char *md5) {
|
||||
{ backend.begin(image_size, OTA_TYPE_UPDATE_APP) } -> std::same_as<OTAResponseTypes>;
|
||||
@@ -110,7 +113,9 @@ concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *dat
|
||||
{ backend.write(data, len) } -> std::same_as<OTAResponseTypes>;
|
||||
{ backend.end() } -> std::same_as<OTAResponseTypes>;
|
||||
backend.abort();
|
||||
{ backend.supports_compression() } -> std::same_as<bool>;
|
||||
{ T::supports_compression() } -> std::same_as<bool>;
|
||||
// The value must be a constant expression
|
||||
typename std::bool_constant<T::supports_compression()>;
|
||||
};
|
||||
|
||||
/** Listener interface for OTA state changes.
|
||||
|
||||
@@ -13,7 +13,7 @@ class ArduinoLibreTinyOTABackend final {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
bool supports_compression() { return false; }
|
||||
static constexpr bool supports_compression() { return false; }
|
||||
|
||||
private:
|
||||
bool md5_set_{false};
|
||||
|
||||
@@ -15,7 +15,10 @@ class ArduinoRP2OTABackend final {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
bool supports_compression() { return false; }
|
||||
// The core's OTA stub inflates a staged gzip image at reboot, on every chip
|
||||
// from 4.0.3 (ESPHome pins 6.0.0). begin() only sees the gzip size; the
|
||||
// inflated size is known when the stub reads the trailer.
|
||||
static constexpr bool supports_compression() { return USE_ARDUINO_VERSION_CODE >= VERSION_CODE(4, 0, 3); }
|
||||
|
||||
private:
|
||||
bool md5_set_{false};
|
||||
|
||||
@@ -20,7 +20,7 @@ class ESP8266OTABackend final {
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
// Compression supported in all ESP8266 Arduino versions ESPHome supports (>= 2.7.0)
|
||||
bool supports_compression() { return true; }
|
||||
static constexpr bool supports_compression() { return true; }
|
||||
|
||||
protected:
|
||||
/// Erase flash sector if current address is at sector boundary
|
||||
|
||||
@@ -33,7 +33,7 @@ class IDFOTABackend final {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
bool supports_compression() { return false; }
|
||||
static constexpr bool supports_compression() { return false; }
|
||||
|
||||
protected:
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
|
||||
@@ -25,7 +25,7 @@ struct StubOTABackend {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_UNKNOWN; }
|
||||
OTAResponseTypes end() { return OTA_RESPONSE_ERROR_UNKNOWN; }
|
||||
void abort() {}
|
||||
bool supports_compression() { return false; }
|
||||
static constexpr bool supports_compression() { return false; }
|
||||
};
|
||||
std::unique_ptr<StubOTABackend> make_ota_backend();
|
||||
} // namespace esphome::ota
|
||||
@@ -33,6 +33,7 @@ std::unique_ptr<StubOTABackend> make_ota_backend();
|
||||
|
||||
namespace esphome::ota {
|
||||
using OTABackendPtr = decltype(make_ota_backend());
|
||||
static_assert(OTABackendContract<OTABackendPtr::element_type>,
|
||||
using OTABackend = OTABackendPtr::element_type;
|
||||
static_assert(OTABackendContract<OTABackend>,
|
||||
"The platform's OTA backend is missing part of the backend surface (ota_backend.h)");
|
||||
} // namespace esphome::ota
|
||||
|
||||
@@ -19,7 +19,7 @@ class HostOTABackend final {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
bool supports_compression() { return false; }
|
||||
static constexpr bool supports_compression() { return false; }
|
||||
|
||||
protected:
|
||||
md5::MD5Digest md5_{};
|
||||
|
||||
@@ -4,11 +4,7 @@ from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, esp32_rmt, remote_base
|
||||
from esphome.components.libretiny import get_libretiny_family
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7238,
|
||||
FAMILY_RTL8720C,
|
||||
)
|
||||
from esphome.components.libretiny.const import FAMILY_BK7238, FAMILY_RTL8720C
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -49,7 +45,9 @@ DigitalWriteAction = remote_transmitter_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7231N, FAMILY_BK7238)
|
||||
# Keep in sync with the USE_LIBRETINY_VARIANT_RTL8720C / REMOTE_TRANSMITTER_BK_PWM gates in
|
||||
# remote_transmitter.h, which decide where set_non_blocking() is declared
|
||||
_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7238)
|
||||
|
||||
|
||||
def _validate_non_blocking_platform(value: bool) -> bool:
|
||||
@@ -59,9 +57,7 @@ def _validate_non_blocking_platform(value: bool) -> bool:
|
||||
return cv.boolean(value)
|
||||
if CORE.is_libretiny and get_libretiny_family() in _NON_BLOCKING_LIBRETINY_FAMILIES:
|
||||
return cv.boolean(value)
|
||||
raise cv.Invalid(
|
||||
"non_blocking is only supported on ESP32, RTL8720C, BK7231N and BK7238"
|
||||
)
|
||||
raise cv.Invalid("non_blocking is only supported on ESP32, RTL8720C and BK7238")
|
||||
|
||||
|
||||
MULTI_CONF = True
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
#endif // SOC_RMT_SUPPORTED
|
||||
#endif // USE_ESP32
|
||||
|
||||
// The BK7231N-style PWM block (hardware shadow-load duty updates) enables the ISR-driven
|
||||
// transmitter on these families; family-level proxy for the SDK's CFG_SOC_NAME gate.
|
||||
// See remote_transmitter_bk72xx.cpp.
|
||||
#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7238)
|
||||
// Enables the ISR-driven transmitter on Beken. Gated on BK7238 alone: the shadow-load PWM
|
||||
// block is shared with BK7231N, but LibreTiny builds that family against an older BDK whose
|
||||
// PWM driver has no pwm_init_param()/pwm_start(). See remote_transmitter_bk72xx.cpp.
|
||||
// Keep in sync with _NON_BLOCKING_LIBRETINY_FAMILIES in __init__.py.
|
||||
#ifdef USE_LIBRETINY_VARIANT_BK7238
|
||||
#define REMOTE_TRANSMITTER_BK_PWM
|
||||
#endif
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@
|
||||
// with the core's fixes for type-name collisions between the two
|
||||
#include <ArduinoPrivate.h>
|
||||
|
||||
// Only the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit)
|
||||
// supports glitch-free per-edge duty updates; older SoCs compile the generic bit-bang
|
||||
// implementation (remote_transmitter.cpp) instead, and this file compiles to nothing.
|
||||
// REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h.
|
||||
// Needs the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit)
|
||||
// for glitch-free per-edge duty updates, and an SDK exposing pwm_init_param()/pwm_start().
|
||||
// BK7231N has the block but LibreTiny builds it against an older BDK offering only the
|
||||
// sddev_control API (CMD_PWM_INIT_PARAM), so it stays on the generic bit-bang path until
|
||||
// someone can add and validate that path on real hardware. Every other Beken SoC lacks the
|
||||
// block. REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h; when it is
|
||||
// unset this file compiles to nothing and remote_transmitter.cpp is used instead.
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Envelope chain shared by the LibreTiny families that pace transmission from a hardware
|
||||
// timer interrupt: RTL8720C (gtimer) and the BK7231N-style PWM block (BKTIMER1). Everything
|
||||
// platform-specific sits behind five hooks implemented in the per-family files -- carrier
|
||||
// setup, duty writes, one-shot arming and timer stop. Families without a usable timer keep
|
||||
// the generic bit-bang implementation and compile none of this.
|
||||
// Envelope chain shared by the LibreTiny families that pace transmission from a hardware timer
|
||||
// interrupt: RTL8720C (gtimer) and BK7238 (BKTIMER1). Everything platform-specific sits behind
|
||||
// five hooks implemented in the per-family files -- carrier setup, duty writes, one-shot arming
|
||||
// and timer stop. Families without a usable timer keep the generic bit-bang implementation and
|
||||
// compile none of this.
|
||||
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
|
||||
ESP_LOGV(TAG, "Stopping");
|
||||
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
|
||||
}
|
||||
if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) {
|
||||
this->task_.deallocate();
|
||||
// Retries on a subsequent loop if the task is still running on the other core
|
||||
if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) {
|
||||
ESP_LOGD(TAG, "Stopped");
|
||||
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
|
||||
}
|
||||
@@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic
|
||||
bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
|
||||
} else {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (temp_ring_buffer) {
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
// Only write to the ring buffer if the reference is valid
|
||||
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
||||
} else {
|
||||
@@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const {
|
||||
bool has_ring_buffer_data = false;
|
||||
if (this->requires_resampling_()) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (temp_ring_buffer) {
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
has_ring_buffer_data = (temp_ring_buffer->available() > 0);
|
||||
}
|
||||
}
|
||||
@@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
|
||||
this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_));
|
||||
|
||||
if (!temp_ring_buffer) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
err = ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
this_resampler->ring_buffer_ = temp_ring_buffer;
|
||||
|
||||
@@ -18,6 +18,16 @@ void RFBridgeComponent::ack_() {
|
||||
}
|
||||
|
||||
bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) {
|
||||
if (this->bucket_frame_candidate_ && byte == RF_CODE_START) {
|
||||
// A queued next frame proves the trailing 0x55 really was the bucket
|
||||
// frame's terminator: Portisch builds pulse entries from alternating
|
||||
// signal edges, so the two level bits inside one pulse byte are always
|
||||
// opposite — 0xAA (two high-level nibbles) cannot occur in pulse data.
|
||||
// Finalize before this byte starts the new frame, so back-to-back
|
||||
// deliveries are split even when loop() never observed a quiet gap
|
||||
// between them.
|
||||
this->finish_bucket_frame_();
|
||||
}
|
||||
size_t at = this->rx_buffer_.size();
|
||||
this->rx_buffer_.push_back(byte);
|
||||
const uint8_t *raw = &this->rx_buffer_[0];
|
||||
@@ -84,26 +94,21 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) {
|
||||
break;
|
||||
}
|
||||
case RF_CODE_RFIN_BUCKET: {
|
||||
if (byte != RF_CODE_STOP) {
|
||||
return true;
|
||||
if (at == 2) {
|
||||
// The count byte: Portisch sends at most 7 buckets + sync, so 0 or
|
||||
// >8 cannot be a genuine capture — reject before it can occupy the
|
||||
// buffer for a full frame timeout.
|
||||
return byte != 0 && byte <= B1_MAX_BUCKET_COUNT;
|
||||
}
|
||||
|
||||
uint8_t buckets = raw[2] << 1;
|
||||
std::string str;
|
||||
char next_byte[3]; // 2 hex chars + null
|
||||
|
||||
for (uint32_t i = 0; i <= at; i++) {
|
||||
buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]);
|
||||
str += next_byte;
|
||||
if ((i > 3) && buckets) {
|
||||
buckets--;
|
||||
}
|
||||
if ((i < 3) || (buckets % 2) || (i == at - 1)) {
|
||||
str += " ";
|
||||
}
|
||||
}
|
||||
ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str());
|
||||
break;
|
||||
// 0x55 is legal DATA inside a B1 frame: bucket durations are sent
|
||||
// with only their HIGH byte masked to 7 bits, so a duration such as
|
||||
// 0x0155 puts a raw 0x55 low byte inside the table — the first 0x55
|
||||
// must therefore not end the capture. The header declares the table
|
||||
// length (raw[2] pairs), so a 0x55 there is always data; one at or
|
||||
// past the first pulse index is a terminator CANDIDATE, confirmed
|
||||
// once the UART goes quiet (finish_bucket_frame_ in loop()).
|
||||
this->bucket_frame_candidate_ = byte == RF_CODE_STOP && at >= 3 + static_cast<size_t>(raw[2]) * 2;
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
ESP_LOGW(TAG, "Unknown action: 0x%02X", action);
|
||||
@@ -119,6 +124,47 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void RFBridgeComponent::finish_bucket_frame_() {
|
||||
if (this->rx_buffer_.size() < 4) {
|
||||
// The candidate flag requires a header + non-empty bucket table, so
|
||||
// this cannot happen while flag and buffer stay consistent; guard the
|
||||
// raw[2] / size-1 reads against any future divergence anyway.
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
return;
|
||||
}
|
||||
const uint8_t *raw = this->rx_buffer_.data();
|
||||
const size_t at = this->rx_buffer_.size() - 1;
|
||||
|
||||
uint8_t buckets = raw[2] << 1;
|
||||
std::string str;
|
||||
char next_byte[3]; // 2 hex chars + null
|
||||
|
||||
for (uint32_t i = 0; i <= at; i++) {
|
||||
buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]);
|
||||
str += next_byte;
|
||||
if ((i > 3) && buckets) {
|
||||
buckets--;
|
||||
}
|
||||
if ((i < 3) || (buckets % 2) || (i == at - 1)) {
|
||||
str += " ";
|
||||
}
|
||||
}
|
||||
ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str());
|
||||
|
||||
// Deliberately NOT ACKed: Portisch's B1 command handler leaves its
|
||||
// last_sniffing_command at the previous mode (RF_CODE_RFIN), and its
|
||||
// host-ACK handler re-arms sniffing from that stale value — so ACKing a
|
||||
// bucket delivery silently reverts the radio to standard sniffing and
|
||||
// ends bucket capture. Its delivery path is fire-and-forget and never
|
||||
// waits for a host ACK. Stock Itead firmware never sends B1 frames, so
|
||||
// suppressing this ACK cannot change stock-firmware behavior.
|
||||
// https://github.com/esphome/esphome/issues/17682
|
||||
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
}
|
||||
|
||||
void RFBridgeComponent::write_byte_str_(const std::string &codes) {
|
||||
uint8_t code;
|
||||
int size = codes.length();
|
||||
@@ -130,12 +176,31 @@ void RFBridgeComponent::write_byte_str_(const std::string &codes) {
|
||||
|
||||
void RFBridgeComponent::loop() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_bridge_byte_ > 50) {
|
||||
size_t avail = this->available();
|
||||
if (avail == 0 && this->bucket_frame_candidate_ && now - this->last_bridge_byte_ > BUCKET_CANDIDATE_QUIET_MS) {
|
||||
// The trailing 0x55 was followed by UART quiet, so it really was the
|
||||
// frame terminator and not an interior data byte.
|
||||
this->finish_bucket_frame_();
|
||||
this->last_bridge_byte_ = now;
|
||||
}
|
||||
const bool receiving_bucket = this->rx_buffer_.size() >= 2 && this->rx_buffer_[1] == RF_CODE_RFIN_BUCKET;
|
||||
if (receiving_bucket) {
|
||||
// Never declare an in-progress bucket frame dead while its continuation
|
||||
// bytes are already queued: a stalled loop() otherwise discards a live
|
||||
// frame that the UART buffer proves is still arriving.
|
||||
if (avail == 0 && now - this->last_bridge_byte_ > BUCKET_FRAME_TIMEOUT_MS) {
|
||||
ESP_LOGD(TAG, "Discarding incomplete RFBridge Bucket frame (%u bytes)",
|
||||
static_cast<unsigned>(this->rx_buffer_.size()));
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
this->last_bridge_byte_ = now;
|
||||
}
|
||||
} else if (now - this->last_bridge_byte_ > 50) {
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
this->last_bridge_byte_ = now;
|
||||
}
|
||||
|
||||
size_t avail = this->available();
|
||||
while (avail > 0) {
|
||||
uint8_t buf[64];
|
||||
size_t to_read = std::min(avail, sizeof(buf));
|
||||
@@ -146,12 +211,14 @@ void RFBridgeComponent::loop() {
|
||||
for (size_t i = 0; i < to_read; i++) {
|
||||
if (this->rx_buffer_.size() > MAX_RX_BUFFER_SIZE) {
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
}
|
||||
if (this->parse_bridge_byte_(buf[i])) {
|
||||
ESP_LOGVV(TAG, "Parsed: 0x%02X", buf[i]);
|
||||
this->last_bridge_byte_ = now;
|
||||
} else {
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,17 @@ static const uint8_t RF_CODE_BEEP = 0xC0;
|
||||
static const uint8_t RF_CODE_STOP = 0x55;
|
||||
static const uint8_t RF_DEBOUNCE = 200;
|
||||
static const size_t MAX_RX_BUFFER_SIZE = 512;
|
||||
// ~10 byte times at 19200 baud: long enough to prove the UART went quiet
|
||||
// after a possible bucket-frame terminator, short enough to finish well
|
||||
// before the next radio capture can be delivered.
|
||||
static const uint32_t BUCKET_CANDIDATE_QUIET_MS = 5;
|
||||
// Portisch drains a B1 frame's header, bucket table, and pulse data as
|
||||
// separate UART writes, so an in-progress bucket frame tolerates a longer
|
||||
// inter-region gap than the generic 50 ms inter-byte timeout.
|
||||
static const uint32_t BUCKET_FRAME_TIMEOUT_MS = 250;
|
||||
// Portisch's uart_put_RF_buckets sends at most 7 buckets plus the sync
|
||||
// bucket, so a B1 count byte above 8 (or 0) is malformed for any protocol.
|
||||
static const uint8_t B1_MAX_BUCKET_COUNT = 8;
|
||||
|
||||
struct RFBridgeData {
|
||||
uint16_t sync;
|
||||
@@ -67,10 +78,12 @@ class RFBridgeComponent final : public uart::UARTDevice, public Component {
|
||||
void ack_();
|
||||
void decode_();
|
||||
bool parse_bridge_byte_(uint8_t byte);
|
||||
void finish_bucket_frame_();
|
||||
void write_byte_str_(const std::string &codes);
|
||||
|
||||
std::vector<uint8_t> rx_buffer_;
|
||||
uint32_t last_bridge_byte_{0};
|
||||
bool bucket_frame_candidate_{false};
|
||||
|
||||
CallbackManager<void(RFBridgeData)> data_callback_;
|
||||
CallbackManager<void(RFBridgeAdvancedData)> advanced_data_callback_;
|
||||
|
||||
@@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() {
|
||||
if (!this->is_playing_) {
|
||||
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks
|
||||
if (this->read_task_.is_created() || this->decode_task_.is_created()) {
|
||||
this->read_task_.deallocate();
|
||||
this->decode_task_.deallocate();
|
||||
// Both are attempted every time; a task that is still running on the other core is freed by a
|
||||
// subsequent call, and freeing an already freed task succeeds without doing anything
|
||||
bool read_task_freed = this->read_task_.deallocate();
|
||||
bool decode_task_freed = this->decode_task_.deallocate();
|
||||
if (!read_task_freed || !decode_task_freed) {
|
||||
// A task is still running on the other core, so keep the pipeline in its current state and try
|
||||
// again on the next call
|
||||
return AudioPipelineState::PLAYING;
|
||||
}
|
||||
if (this->hard_stop_) {
|
||||
// Stop command was sent, so immediately end the playback
|
||||
this->speaker_->stop();
|
||||
@@ -315,17 +322,17 @@ void AudioPipeline::read_task(void *params) {
|
||||
if (err == ESP_OK) {
|
||||
size_t file_ring_buffer_size = this_pipeline->buffer_size_;
|
||||
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer;
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock();
|
||||
|
||||
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size);
|
||||
this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer;
|
||||
}
|
||||
|
||||
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
err = ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
reader->add_sink(this_pipeline->raw_file_ring_buffer_);
|
||||
err = reader->add_sink(temp_ring_buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,7 +403,9 @@ void AudioPipeline::decode_task(void *params) {
|
||||
make_unique<audio::AudioDecoder>(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_);
|
||||
|
||||
esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_);
|
||||
decoder->add_source(this_pipeline->raw_file_ring_buffer_);
|
||||
if (err == ESP_OK) {
|
||||
err = decoder->add_source(this_pipeline->raw_file_ring_buffer_);
|
||||
}
|
||||
|
||||
if (err != ESP_OK) {
|
||||
// Send specific error message
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate, sensor
|
||||
from esphome.components.climate import climate_ns
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTION,
|
||||
CONF_CURRENT_TEMPERATURE,
|
||||
CONF_CUSTOM_FAN_MODE,
|
||||
CONF_CUSTOM_FAN_MODES,
|
||||
CONF_CUSTOM_PRESET,
|
||||
CONF_CUSTOM_PRESETS,
|
||||
CONF_FAN_MODE,
|
||||
CONF_HUMIDITY_SENSOR,
|
||||
CONF_ID,
|
||||
CONF_INITIAL_STATE,
|
||||
CONF_MODE,
|
||||
CONF_OPTIMISTIC,
|
||||
CONF_PRESET,
|
||||
CONF_RESTORE_MODE,
|
||||
CONF_SENSOR,
|
||||
CONF_SUPPORTED_FAN_MODES,
|
||||
CONF_SUPPORTED_MODES,
|
||||
CONF_SUPPORTED_PRESETS,
|
||||
CONF_SUPPORTED_SWING_MODES,
|
||||
CONF_SWING_MODE,
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
CONF_TARGET_TEMPERATURE_HIGH,
|
||||
CONF_TARGET_TEMPERATURE_LOW,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import template_ns
|
||||
|
||||
CONF_CURRENT_HUMIDITY = "current_humidity"
|
||||
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||
CONF_SUPPORTS_ACTION = "supports_action"
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = "supports_two_point_target_temperature"
|
||||
CONF_SUPPORTS_TARGET_HUMIDITY = "supports_target_humidity"
|
||||
CONF_SUPPORTS_CURRENT_TEMPERATURE = "supports_current_temperature"
|
||||
CONF_SUPPORTS_CURRENT_HUMIDITY = "supports_current_humidity"
|
||||
CONF_SET_MODE_ACTION = "set_mode_action"
|
||||
CONF_SET_TARGET_TEMPERATURE_ACTION = "set_target_temperature_action"
|
||||
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION = "set_target_temperature_low_action"
|
||||
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION = "set_target_temperature_high_action"
|
||||
CONF_SET_TARGET_HUMIDITY_ACTION = "set_target_humidity_action"
|
||||
CONF_SET_FAN_MODE_ACTION = "set_fan_mode_action"
|
||||
CONF_SET_CUSTOM_FAN_MODE_ACTION = "set_custom_fan_mode_action"
|
||||
CONF_SET_SWING_MODE_ACTION = "set_swing_mode_action"
|
||||
CONF_SET_PRESET_ACTION = "set_preset_action"
|
||||
CONF_SET_CUSTOM_PRESET_ACTION = "set_custom_preset_action"
|
||||
|
||||
TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Component)
|
||||
TemplateClimatePublishAction = template_ns.class_(
|
||||
"TemplateClimatePublishAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(TemplateClimate),
|
||||
)
|
||||
|
||||
TemplateClimateRestoreMode = template_ns.enum(
|
||||
"TemplateClimateRestoreMode", is_class=True
|
||||
)
|
||||
CLIMATE_RESTORE_MODES = {
|
||||
"NO_RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE,
|
||||
"RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE,
|
||||
}
|
||||
|
||||
# Per-field actions that forward a requested value on. The third item is the type of `x`.
|
||||
SET_ACTIONS = (
|
||||
(CONF_SET_MODE_ACTION, "get_set_mode_trigger", climate.ClimateMode),
|
||||
(
|
||||
CONF_SET_TARGET_TEMPERATURE_ACTION,
|
||||
"get_set_target_temperature_trigger",
|
||||
cg.float_,
|
||||
),
|
||||
(
|
||||
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
|
||||
"get_set_target_temperature_low_trigger",
|
||||
cg.float_,
|
||||
),
|
||||
(
|
||||
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
|
||||
"get_set_target_temperature_high_trigger",
|
||||
cg.float_,
|
||||
),
|
||||
(CONF_SET_TARGET_HUMIDITY_ACTION, "get_set_target_humidity_trigger", cg.float_),
|
||||
(CONF_SET_FAN_MODE_ACTION, "get_set_fan_mode_trigger", climate.ClimateFanMode),
|
||||
(
|
||||
CONF_SET_CUSTOM_FAN_MODE_ACTION,
|
||||
"get_set_custom_fan_mode_trigger",
|
||||
cg.StringRef,
|
||||
),
|
||||
(
|
||||
CONF_SET_SWING_MODE_ACTION,
|
||||
"get_set_swing_mode_trigger",
|
||||
climate.ClimateSwingMode,
|
||||
),
|
||||
(CONF_SET_PRESET_ACTION, "get_set_preset_trigger", climate.ClimatePreset),
|
||||
(CONF_SET_CUSTOM_PRESET_ACTION, "get_set_custom_preset_trigger", cg.StringRef),
|
||||
)
|
||||
|
||||
# supports_* keys have no default so that an omitted key can mean "derive it from the sensor or
|
||||
# set action that makes the trait useful", which is not expressible once a default fills it in.
|
||||
DERIVED_SUPPORTS = (
|
||||
(CONF_SUPPORTS_CURRENT_TEMPERATURE, (CONF_SENSOR,)),
|
||||
(CONF_SUPPORTS_CURRENT_HUMIDITY, (CONF_HUMIDITY_SENSOR,)),
|
||||
(
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
|
||||
(
|
||||
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
|
||||
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
|
||||
),
|
||||
),
|
||||
(CONF_SUPPORTS_TARGET_HUMIDITY, (CONF_SET_TARGET_HUMIDITY_ACTION,)),
|
||||
)
|
||||
|
||||
|
||||
# Custom fan modes/presets are opaque user-defined strings with no build-time correctness check
|
||||
# elsewhere (Climate::set_supported_custom_fan_modes()/set_supported_custom_presets() don't block
|
||||
# empty entries), so reject empty ones here -- they could never be selected at runtime anyway.
|
||||
validate_custom_climate_string = cv.All(cv.string_strict, cv.Length(min=1))
|
||||
|
||||
|
||||
def _validate_two_point(config: ConfigType) -> ConfigType:
|
||||
has_low = CONF_TARGET_TEMPERATURE_LOW in config
|
||||
has_high = CONF_TARGET_TEMPERATURE_HIGH in config
|
||||
if has_low != has_high:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_TARGET_TEMPERATURE_LOW}' and '{CONF_TARGET_TEMPERATURE_HIGH}' must be used together"
|
||||
)
|
||||
if (has_low or has_high) and CONF_TARGET_TEMPERATURE in config:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_TARGET_TEMPERATURE}' cannot be used together with "
|
||||
f"'{CONF_TARGET_TEMPERATURE_LOW}'/'{CONF_TARGET_TEMPERATURE_HIGH}'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_set_actions(config: ConfigType) -> ConfigType:
|
||||
has_low = CONF_SET_TARGET_TEMPERATURE_LOW_ACTION in config
|
||||
has_high = CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION in config
|
||||
if has_low != has_high:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_SET_TARGET_TEMPERATURE_LOW_ACTION}' and "
|
||||
f"'{CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION}' must be used together"
|
||||
)
|
||||
if (has_low or has_high) and CONF_SET_TARGET_TEMPERATURE_ACTION in config:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_SET_TARGET_TEMPERATURE_ACTION}' cannot be used together with "
|
||||
f"'{CONF_SET_TARGET_TEMPERATURE_LOW_ACTION}'/'{CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION}'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _resolve_supports(config: ConfigType) -> ConfigType:
|
||||
# An explicit true stays valid without either, since climate.template.publish can report the
|
||||
# value; an explicit false that contradicts the configuration is an error, not a silent override.
|
||||
for key, sources in DERIVED_SUPPORTS:
|
||||
configured = [source for source in sources if source in config]
|
||||
if key not in config:
|
||||
config[key] = bool(configured)
|
||||
elif not config[key] and configured:
|
||||
raise cv.Invalid(
|
||||
f"'{key}' cannot be false while '{configured[0]}' is configured",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_initial_state(config: ConfigType) -> ConfigType:
|
||||
# Climate keeps target_temperature and target_temperature_low in a union, so writing the wrong
|
||||
# one of the pair corrupts the setpoint with no runtime complaint.
|
||||
if (initial_state := config.get(CONF_INITIAL_STATE)) is None:
|
||||
return config
|
||||
|
||||
two_point = config[CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE]
|
||||
if two_point and CONF_TARGET_TEMPERATURE in initial_state:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_TARGET_TEMPERATURE}' is not available while "
|
||||
f"'{CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE}' is enabled; use "
|
||||
f"'{CONF_TARGET_TEMPERATURE_LOW}'/'{CONF_TARGET_TEMPERATURE_HIGH}' instead",
|
||||
path=[CONF_INITIAL_STATE, CONF_TARGET_TEMPERATURE],
|
||||
)
|
||||
if not two_point:
|
||||
for key in (CONF_TARGET_TEMPERATURE_LOW, CONF_TARGET_TEMPERATURE_HIGH):
|
||||
if key in initial_state:
|
||||
raise cv.Invalid(
|
||||
f"'{key}' requires '{CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE}' to be enabled",
|
||||
path=[CONF_INITIAL_STATE, key],
|
||||
)
|
||||
if (
|
||||
CONF_TARGET_HUMIDITY in initial_state
|
||||
and not config[CONF_SUPPORTS_TARGET_HUMIDITY]
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled",
|
||||
path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# Same settable fields as climate.template.publish, minus current_temperature/current_humidity/
|
||||
# action: those are reported values (from a sensor or the device), not meaningful static defaults.
|
||||
INITIAL_STATE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_MODE): climate.validate_climate_mode,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
|
||||
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
|
||||
cv.Exclusive(
|
||||
CONF_CUSTOM_FAN_MODE, "fan_mode"
|
||||
): validate_custom_climate_string,
|
||||
cv.Optional(CONF_SWING_MODE): climate.validate_climate_swing_mode,
|
||||
cv.Exclusive(CONF_PRESET, "preset"): climate.validate_climate_preset,
|
||||
cv.Exclusive(CONF_CUSTOM_PRESET, "preset"): validate_custom_climate_string,
|
||||
}
|
||||
),
|
||||
_validate_two_point,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
climate.climate_schema(TemplateClimate)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
||||
cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor),
|
||||
# action only ever arrives through climate.template.publish, so unlike the other
|
||||
# supports_* keys there is no set action to derive it from.
|
||||
cv.Optional(CONF_SUPPORTS_ACTION, default=False): cv.boolean,
|
||||
cv.Optional(CONF_SUPPORTS_CURRENT_TEMPERATURE): cv.boolean,
|
||||
cv.Optional(CONF_SUPPORTS_CURRENT_HUMIDITY): cv.boolean,
|
||||
cv.Optional(CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE): cv.boolean,
|
||||
cv.Optional(CONF_SUPPORTS_TARGET_HUMIDITY): cv.boolean,
|
||||
cv.Required(CONF_SUPPORTED_MODES): cv.All(
|
||||
cv.ensure_list(climate.validate_climate_mode), cv.Unique()
|
||||
),
|
||||
cv.Optional(CONF_SUPPORTED_FAN_MODES): cv.All(
|
||||
cv.ensure_list(climate.validate_climate_fan_mode), cv.Unique()
|
||||
),
|
||||
cv.Optional(CONF_CUSTOM_FAN_MODES): cv.All(
|
||||
cv.ensure_list(validate_custom_climate_string), cv.Unique()
|
||||
),
|
||||
cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.All(
|
||||
cv.ensure_list(climate.validate_climate_swing_mode), cv.Unique()
|
||||
),
|
||||
cv.Optional(CONF_SUPPORTED_PRESETS): cv.All(
|
||||
cv.ensure_list(climate.validate_climate_preset), cv.Unique()
|
||||
),
|
||||
cv.Optional(CONF_CUSTOM_PRESETS): cv.All(
|
||||
cv.ensure_list(validate_custom_climate_string), cv.Unique()
|
||||
),
|
||||
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
|
||||
cv.Optional(CONF_RESTORE_MODE, default="RESTORE"): cv.enum(
|
||||
CLIMATE_RESTORE_MODES, upper=True
|
||||
),
|
||||
cv.Optional(CONF_INITIAL_STATE): INITIAL_STATE_SCHEMA,
|
||||
cv.Optional(CONF_SET_MODE_ACTION): automation.validate_automation(
|
||||
single=True
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_SET_TARGET_TEMPERATURE_ACTION
|
||||
): automation.validate_automation(single=True),
|
||||
cv.Optional(
|
||||
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION
|
||||
): automation.validate_automation(single=True),
|
||||
cv.Optional(
|
||||
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION
|
||||
): automation.validate_automation(single=True),
|
||||
cv.Optional(
|
||||
CONF_SET_TARGET_HUMIDITY_ACTION
|
||||
): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_SET_FAN_MODE_ACTION): automation.validate_automation(
|
||||
single=True
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_SET_CUSTOM_FAN_MODE_ACTION
|
||||
): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_SET_SWING_MODE_ACTION): automation.validate_automation(
|
||||
single=True
|
||||
),
|
||||
cv.Optional(CONF_SET_PRESET_ACTION): automation.validate_automation(
|
||||
single=True
|
||||
),
|
||||
cv.Optional(CONF_SET_CUSTOM_PRESET_ACTION): automation.validate_automation(
|
||||
single=True
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
_validate_set_actions,
|
||||
_resolve_supports,
|
||||
_validate_initial_state,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await climate.register_climate(var, config)
|
||||
|
||||
if (sens := config.get(CONF_SENSOR)) is not None:
|
||||
cg.add(var.set_sensor(await cg.get_variable(sens)))
|
||||
|
||||
if (sens := config.get(CONF_HUMIDITY_SENSOR)) is not None:
|
||||
cg.add(var.set_humidity_sensor(await cg.get_variable(sens)))
|
||||
|
||||
for key, flag in (
|
||||
(CONF_SUPPORTS_ACTION, climate_ns.CLIMATE_SUPPORTS_ACTION),
|
||||
(
|
||||
CONF_SUPPORTS_CURRENT_TEMPERATURE,
|
||||
climate_ns.CLIMATE_SUPPORTS_CURRENT_TEMPERATURE,
|
||||
),
|
||||
(CONF_SUPPORTS_CURRENT_HUMIDITY, climate_ns.CLIMATE_SUPPORTS_CURRENT_HUMIDITY),
|
||||
(
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
|
||||
climate_ns.CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
|
||||
),
|
||||
(CONF_SUPPORTS_TARGET_HUMIDITY, climate_ns.CLIMATE_SUPPORTS_TARGET_HUMIDITY),
|
||||
):
|
||||
if config[key]:
|
||||
cg.add(var.add_feature_flags(flag))
|
||||
|
||||
for mode in config[CONF_SUPPORTED_MODES]:
|
||||
cg.add(var.add_supported_mode(mode))
|
||||
|
||||
for mode in config.get(CONF_SUPPORTED_FAN_MODES, []):
|
||||
cg.add(var.add_supported_fan_mode(mode))
|
||||
|
||||
if CONF_CUSTOM_FAN_MODES in config:
|
||||
cg.add(
|
||||
var.set_supported_custom_fan_modes(
|
||||
cg.ArrayInitializer(*config[CONF_CUSTOM_FAN_MODES])
|
||||
)
|
||||
)
|
||||
|
||||
for mode in config.get(CONF_SUPPORTED_SWING_MODES, []):
|
||||
cg.add(var.add_supported_swing_mode(mode))
|
||||
|
||||
for preset in config.get(CONF_SUPPORTED_PRESETS, []):
|
||||
cg.add(var.add_supported_preset(preset))
|
||||
|
||||
if CONF_CUSTOM_PRESETS in config:
|
||||
cg.add(
|
||||
var.set_supported_custom_presets(
|
||||
cg.ArrayInitializer(*config[CONF_CUSTOM_PRESETS])
|
||||
)
|
||||
)
|
||||
|
||||
for key, trigger_getter, arg_type in SET_ACTIONS:
|
||||
if (conf := config.get(key)) is not None:
|
||||
await automation.build_automation(
|
||||
getattr(var, trigger_getter)(), [(arg_type, "x")], conf
|
||||
)
|
||||
|
||||
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
|
||||
cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE]))
|
||||
|
||||
if (initial_state := config.get(CONF_INITIAL_STATE)) is not None:
|
||||
if (v := initial_state.get(CONF_MODE)) is not None:
|
||||
cg.add(var.set_mode(v))
|
||||
if (v := initial_state.get(CONF_TARGET_TEMPERATURE)) is not None:
|
||||
cg.add(var.set_target_temperature(v))
|
||||
if (v := initial_state.get(CONF_TARGET_TEMPERATURE_LOW)) is not None:
|
||||
cg.add(var.set_target_temperature_low(v))
|
||||
if (v := initial_state.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None:
|
||||
cg.add(var.set_target_temperature_high(v))
|
||||
if (v := initial_state.get(CONF_TARGET_HUMIDITY)) is not None:
|
||||
cg.add(var.set_target_humidity(v))
|
||||
if (v := initial_state.get(CONF_FAN_MODE)) is not None:
|
||||
cg.add(var.set_fan_mode(v))
|
||||
if (v := initial_state.get(CONF_CUSTOM_FAN_MODE)) is not None:
|
||||
cg.add(var.set_custom_fan_mode(v))
|
||||
if (v := initial_state.get(CONF_SWING_MODE)) is not None:
|
||||
cg.add(var.set_swing_mode(v))
|
||||
if (v := initial_state.get(CONF_PRESET)) is not None:
|
||||
cg.add(var.set_preset(v))
|
||||
if (v := initial_state.get(CONF_CUSTOM_PRESET)) is not None:
|
||||
cg.add(var.set_custom_preset(v))
|
||||
|
||||
|
||||
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(TemplateClimate),
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||
cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode),
|
||||
cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action),
|
||||
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
|
||||
climate.validate_climate_fan_mode
|
||||
),
|
||||
cv.Exclusive(CONF_CUSTOM_FAN_MODE, "fan_mode"): cv.templatable(
|
||||
validate_custom_climate_string
|
||||
),
|
||||
cv.Optional(CONF_SWING_MODE): cv.templatable(
|
||||
climate.validate_climate_swing_mode
|
||||
),
|
||||
cv.Exclusive(CONF_PRESET, "preset"): cv.templatable(
|
||||
climate.validate_climate_preset
|
||||
),
|
||||
cv.Exclusive(CONF_CUSTOM_PRESET, "preset"): cv.templatable(
|
||||
validate_custom_climate_string
|
||||
),
|
||||
}
|
||||
),
|
||||
_validate_two_point,
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"climate.template.publish",
|
||||
TemplateClimatePublishAction,
|
||||
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def climate_template_publish_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
|
||||
if (v := config.get(CONF_CURRENT_TEMPERATURE)) is not None:
|
||||
cg.add(var.set_current_temperature(await cg.templatable(v, args, cg.float_)))
|
||||
if (v := config.get(CONF_CURRENT_HUMIDITY)) is not None:
|
||||
cg.add(var.set_current_humidity(await cg.templatable(v, args, cg.float_)))
|
||||
if (v := config.get(CONF_TARGET_TEMPERATURE)) is not None:
|
||||
cg.add(var.set_target_temperature(await cg.templatable(v, args, cg.float_)))
|
||||
if (v := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None:
|
||||
cg.add(var.set_target_temperature_low(await cg.templatable(v, args, cg.float_)))
|
||||
if (v := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None:
|
||||
cg.add(
|
||||
var.set_target_temperature_high(await cg.templatable(v, args, cg.float_))
|
||||
)
|
||||
if (v := config.get(CONF_TARGET_HUMIDITY)) is not None:
|
||||
cg.add(var.set_target_humidity(await cg.templatable(v, args, cg.float_)))
|
||||
if (v := config.get(CONF_MODE)) is not None:
|
||||
cg.add(var.set_mode(await cg.templatable(v, args, climate.ClimateMode)))
|
||||
if (v := config.get(CONF_ACTION)) is not None:
|
||||
cg.add(var.set_action(await cg.templatable(v, args, climate.ClimateAction)))
|
||||
if (v := config.get(CONF_FAN_MODE)) is not None:
|
||||
cg.add(var.set_fan_mode(await cg.templatable(v, args, climate.ClimateFanMode)))
|
||||
if (v := config.get(CONF_CUSTOM_FAN_MODE)) is not None:
|
||||
cg.add(var.set_custom_fan_mode(await cg.templatable(v, args, cg.std_string)))
|
||||
if (v := config.get(CONF_SWING_MODE)) is not None:
|
||||
cg.add(
|
||||
var.set_swing_mode(await cg.templatable(v, args, climate.ClimateSwingMode))
|
||||
)
|
||||
if (v := config.get(CONF_PRESET)) is not None:
|
||||
cg.add(var.set_preset(await cg.templatable(v, args, climate.ClimatePreset)))
|
||||
if (v := config.get(CONF_CUSTOM_PRESET)) is not None:
|
||||
cg.add(var.set_custom_preset(await cg.templatable(v, args, cg.std_string)))
|
||||
|
||||
return var
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include "template_climate.h"
|
||||
#include "esphome/core/automation.h"
|
||||
|
||||
namespace esphome::template_ {
|
||||
|
||||
template<typename... Ts>
|
||||
class TemplateClimatePublishAction final : public Action<Ts...>, public Parented<TemplateClimate> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(float, current_temperature)
|
||||
TEMPLATABLE_VALUE(float, current_humidity)
|
||||
TEMPLATABLE_VALUE(float, target_temperature)
|
||||
TEMPLATABLE_VALUE(float, target_temperature_low)
|
||||
TEMPLATABLE_VALUE(float, target_temperature_high)
|
||||
TEMPLATABLE_VALUE(float, target_humidity)
|
||||
TEMPLATABLE_VALUE(climate::ClimateMode, mode)
|
||||
TEMPLATABLE_VALUE(climate::ClimateAction, action)
|
||||
TEMPLATABLE_VALUE(climate::ClimateFanMode, fan_mode)
|
||||
TEMPLATABLE_VALUE(std::string, custom_fan_mode)
|
||||
TEMPLATABLE_VALUE(climate::ClimateSwingMode, swing_mode)
|
||||
TEMPLATABLE_VALUE(climate::ClimatePreset, preset)
|
||||
TEMPLATABLE_VALUE(std::string, custom_preset)
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
if (this->current_temperature_.has_value())
|
||||
this->parent_->current_temperature = this->current_temperature_.value(x...);
|
||||
if (this->current_humidity_.has_value())
|
||||
this->parent_->current_humidity = this->current_humidity_.value(x...);
|
||||
if (this->target_temperature_.has_value())
|
||||
this->parent_->set_target_temperature(this->target_temperature_.value(x...));
|
||||
if (this->target_temperature_low_.has_value())
|
||||
this->parent_->set_target_temperature_low(this->target_temperature_low_.value(x...));
|
||||
if (this->target_temperature_high_.has_value())
|
||||
this->parent_->set_target_temperature_high(this->target_temperature_high_.value(x...));
|
||||
if (this->target_humidity_.has_value())
|
||||
this->parent_->set_target_humidity(this->target_humidity_.value(x...));
|
||||
if (this->mode_.has_value())
|
||||
this->parent_->set_mode(this->mode_.value(x...));
|
||||
if (this->action_.has_value())
|
||||
this->parent_->action = this->action_.value(x...);
|
||||
if (this->fan_mode_.has_value())
|
||||
this->parent_->set_fan_mode(this->fan_mode_.value(x...));
|
||||
if (this->custom_fan_mode_.has_value())
|
||||
this->parent_->set_custom_fan_mode(StringRef(this->custom_fan_mode_.value(x...)));
|
||||
if (this->swing_mode_.has_value())
|
||||
this->parent_->set_swing_mode(this->swing_mode_.value(x...));
|
||||
if (this->preset_.has_value())
|
||||
this->parent_->set_preset(this->preset_.value(x...));
|
||||
if (this->custom_preset_.has_value())
|
||||
this->parent_->set_custom_preset(StringRef(this->custom_preset_.value(x...)));
|
||||
|
||||
this->parent_->publish_state();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::template_
|
||||
@@ -0,0 +1,164 @@
|
||||
#include "template_climate.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::template_ {
|
||||
|
||||
static const char *const TAG = "template.climate";
|
||||
|
||||
void TemplateClimate::setup() {
|
||||
if (this->restore_mode_ == TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE) {
|
||||
auto restore = this->restore_state_();
|
||||
if (restore.has_value()) {
|
||||
restore->apply(this);
|
||||
}
|
||||
}
|
||||
|
||||
// Sensors publish every reading, not just changes, so only re-publish when the value moved.
|
||||
// NAN means the sensor went unavailable and is passed through rather than dropped; the second
|
||||
// check stops an unavailable sensor re-publishing forever, since NAN never equals NAN.
|
||||
#ifdef USE_SENSOR
|
||||
if (this->sensor_ != nullptr) {
|
||||
this->current_temperature = this->sensor_->state;
|
||||
this->sensor_->add_on_state_callback([this](float state) {
|
||||
if (state != this->current_temperature && !(std::isnan(state) && std::isnan(this->current_temperature))) {
|
||||
this->current_temperature = state;
|
||||
this->publish_state();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this->humidity_sensor_ != nullptr) {
|
||||
this->current_humidity = this->humidity_sensor_->state;
|
||||
this->humidity_sensor_->add_on_state_callback([this](float state) {
|
||||
if (state != this->current_humidity && !(std::isnan(state) && std::isnan(this->current_humidity))) {
|
||||
this->current_humidity = state;
|
||||
this->publish_state();
|
||||
}
|
||||
});
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void TemplateClimate::dump_config() {
|
||||
LOG_CLIMATE("", "Template Climate", this);
|
||||
ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_));
|
||||
}
|
||||
|
||||
void TemplateClimate::control(const climate::ClimateCall &call) {
|
||||
// Each field present fires its set_*_action; on_control sees the whole call. optimistic: true
|
||||
// also applies the values right away, false waits for a climate.template.publish report.
|
||||
if (auto mode = call.get_mode()) {
|
||||
if (this->optimistic_)
|
||||
this->mode = *mode;
|
||||
this->set_mode_trigger_.trigger(*mode);
|
||||
}
|
||||
|
||||
if (auto target_temp = call.get_target_temperature()) {
|
||||
if (this->optimistic_)
|
||||
this->target_temperature = *target_temp;
|
||||
this->set_target_temperature_trigger_.trigger(*target_temp);
|
||||
}
|
||||
|
||||
if (auto target_temp_low = call.get_target_temperature_low()) {
|
||||
if (this->optimistic_)
|
||||
this->target_temperature_low = *target_temp_low;
|
||||
this->set_target_temperature_low_trigger_.trigger(*target_temp_low);
|
||||
}
|
||||
|
||||
if (auto target_temp_high = call.get_target_temperature_high()) {
|
||||
if (this->optimistic_)
|
||||
this->target_temperature_high = *target_temp_high;
|
||||
this->set_target_temperature_high_trigger_.trigger(*target_temp_high);
|
||||
}
|
||||
|
||||
if (auto target_humidity = call.get_target_humidity()) {
|
||||
if (this->optimistic_)
|
||||
this->target_humidity = *target_humidity;
|
||||
this->set_target_humidity_trigger_.trigger(*target_humidity);
|
||||
}
|
||||
|
||||
if (auto fan_mode = call.get_fan_mode()) {
|
||||
if (this->optimistic_)
|
||||
this->set_fan_mode_(*fan_mode);
|
||||
this->set_fan_mode_trigger_.trigger(*fan_mode);
|
||||
}
|
||||
|
||||
if (call.has_custom_fan_mode()) {
|
||||
if (this->optimistic_)
|
||||
this->set_custom_fan_mode_(call.get_custom_fan_mode());
|
||||
this->set_custom_fan_mode_trigger_.trigger(call.get_custom_fan_mode());
|
||||
}
|
||||
|
||||
if (auto swing_mode = call.get_swing_mode()) {
|
||||
if (this->optimistic_)
|
||||
this->swing_mode = *swing_mode;
|
||||
this->set_swing_mode_trigger_.trigger(*swing_mode);
|
||||
}
|
||||
|
||||
if (auto preset = call.get_preset()) {
|
||||
if (this->optimistic_)
|
||||
this->set_preset_(*preset);
|
||||
this->set_preset_trigger_.trigger(*preset);
|
||||
}
|
||||
|
||||
if (call.has_custom_preset()) {
|
||||
if (this->optimistic_)
|
||||
this->set_custom_preset_(call.get_custom_preset());
|
||||
this->set_custom_preset_trigger_.trigger(call.get_custom_preset());
|
||||
}
|
||||
|
||||
if (this->optimistic_)
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
// A climate.template.publish report (and initial_state:) never goes through ClimateCall::validate_(),
|
||||
// so check here instead -- otherwise a typo is published as state the receiving end will reject.
|
||||
void TemplateClimate::set_mode(climate::ClimateMode mode) {
|
||||
if (!this->traits_.supports_mode(mode)) {
|
||||
ESP_LOGW(TAG, "'%s' - Unsupported mode %u", this->get_name().c_str(), static_cast<unsigned>(mode));
|
||||
return;
|
||||
}
|
||||
this->mode = mode;
|
||||
}
|
||||
|
||||
void TemplateClimate::set_swing_mode(climate::ClimateSwingMode swing_mode) {
|
||||
if (!this->traits_.supports_swing_mode(swing_mode)) {
|
||||
ESP_LOGW(TAG, "'%s' - Unsupported swing mode %u", this->get_name().c_str(), static_cast<unsigned>(swing_mode));
|
||||
return;
|
||||
}
|
||||
this->swing_mode = swing_mode;
|
||||
}
|
||||
|
||||
void TemplateClimate::set_fan_mode(climate::ClimateFanMode fan_mode) {
|
||||
if (!this->traits_.supports_fan_mode(fan_mode)) {
|
||||
ESP_LOGW(TAG, "'%s' - Unsupported fan mode %u", this->get_name().c_str(), static_cast<unsigned>(fan_mode));
|
||||
return;
|
||||
}
|
||||
this->set_fan_mode_(fan_mode);
|
||||
}
|
||||
|
||||
void TemplateClimate::set_preset(climate::ClimatePreset preset) {
|
||||
if (!this->traits_.supports_preset(preset)) {
|
||||
ESP_LOGW(TAG, "'%s' - Unsupported preset %u", this->get_name().c_str(), static_cast<unsigned>(preset));
|
||||
return;
|
||||
}
|
||||
this->set_preset_(preset);
|
||||
}
|
||||
|
||||
void TemplateClimate::set_custom_fan_mode(StringRef mode) {
|
||||
if (this->find_custom_fan_mode_(mode.c_str(), mode.size()) == nullptr) {
|
||||
ESP_LOGW(TAG, "'%s' - Unsupported custom fan mode '%s'", this->get_name().c_str(), mode.c_str());
|
||||
return;
|
||||
}
|
||||
this->set_custom_fan_mode_(mode);
|
||||
}
|
||||
|
||||
void TemplateClimate::set_custom_preset(StringRef preset) {
|
||||
if (this->find_custom_preset_(preset.c_str(), preset.size()) == nullptr) {
|
||||
ESP_LOGW(TAG, "'%s' - Unsupported custom preset '%s'", this->get_name().c_str(), preset.c_str());
|
||||
return;
|
||||
}
|
||||
this->set_custom_preset_(preset);
|
||||
}
|
||||
|
||||
} // namespace esphome::template_
|
||||
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/climate/climate.h"
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::template_ {
|
||||
|
||||
enum class TemplateClimateRestoreMode {
|
||||
TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE,
|
||||
TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE,
|
||||
};
|
||||
|
||||
class TemplateClimate final : public climate::Climate, public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
climate::ClimateTraits traits() override { return this->traits_; }
|
||||
|
||||
void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); }
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
// The matching feature flag is added from codegen, so the configuration alone decides it.
|
||||
void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; }
|
||||
void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; }
|
||||
#endif
|
||||
|
||||
void add_supported_mode(climate::ClimateMode mode) { this->traits_.add_supported_mode(mode); }
|
||||
void add_supported_fan_mode(climate::ClimateFanMode mode) { this->traits_.add_supported_fan_mode(mode); }
|
||||
void add_supported_swing_mode(climate::ClimateSwingMode mode) { this->traits_.add_supported_swing_mode(mode); }
|
||||
void add_supported_preset(climate::ClimatePreset preset) { this->traits_.add_supported_preset(preset); }
|
||||
|
||||
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
|
||||
void set_restore_mode(TemplateClimateRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
|
||||
|
||||
// Fired from control() for each field the call carries, so a device-backed config can forward
|
||||
// it on. Which of these are configured also decides the two-point/target-humidity traits.
|
||||
Trigger<climate::ClimateMode> *get_set_mode_trigger() { return &this->set_mode_trigger_; }
|
||||
Trigger<float> *get_set_target_temperature_trigger() { return &this->set_target_temperature_trigger_; }
|
||||
Trigger<float> *get_set_target_temperature_low_trigger() { return &this->set_target_temperature_low_trigger_; }
|
||||
Trigger<float> *get_set_target_temperature_high_trigger() { return &this->set_target_temperature_high_trigger_; }
|
||||
Trigger<float> *get_set_target_humidity_trigger() { return &this->set_target_humidity_trigger_; }
|
||||
Trigger<climate::ClimateFanMode> *get_set_fan_mode_trigger() { return &this->set_fan_mode_trigger_; }
|
||||
Trigger<StringRef> *get_set_custom_fan_mode_trigger() { return &this->set_custom_fan_mode_trigger_; }
|
||||
Trigger<climate::ClimateSwingMode> *get_set_swing_mode_trigger() { return &this->set_swing_mode_trigger_; }
|
||||
Trigger<climate::ClimatePreset> *get_set_preset_trigger() { return &this->set_preset_trigger_; }
|
||||
Trigger<StringRef> *get_set_custom_preset_trigger() { return &this->set_custom_preset_trigger_; }
|
||||
|
||||
// Used by TemplateClimatePublishAction, which is not a Climate subclass and so cannot reach the
|
||||
// protected setters, and by codegen to apply `initial_state:` before setup() runs.
|
||||
void set_target_temperature(float value) { this->target_temperature = value; }
|
||||
void set_target_temperature_low(float value) { this->target_temperature_low = value; }
|
||||
void set_target_temperature_high(float value) { this->target_temperature_high = value; }
|
||||
void set_target_humidity(float value) { this->target_humidity = value; }
|
||||
void set_mode(climate::ClimateMode mode);
|
||||
void set_swing_mode(climate::ClimateSwingMode mode);
|
||||
void set_fan_mode(climate::ClimateFanMode mode);
|
||||
void set_custom_fan_mode(const char *mode) { this->set_custom_fan_mode(StringRef(mode)); }
|
||||
void set_custom_fan_mode(StringRef mode);
|
||||
void set_preset(climate::ClimatePreset preset);
|
||||
void set_custom_preset(const char *preset) { this->set_custom_preset(StringRef(preset)); }
|
||||
void set_custom_preset(StringRef preset);
|
||||
|
||||
protected:
|
||||
void control(const climate::ClimateCall &call) override;
|
||||
|
||||
climate::ClimateTraits traits_;
|
||||
bool optimistic_{false};
|
||||
TemplateClimateRestoreMode restore_mode_{TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE};
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
sensor::Sensor *sensor_{nullptr};
|
||||
sensor::Sensor *humidity_sensor_{nullptr};
|
||||
#endif
|
||||
|
||||
Trigger<climate::ClimateMode> set_mode_trigger_;
|
||||
Trigger<float> set_target_temperature_trigger_;
|
||||
Trigger<float> set_target_temperature_low_trigger_;
|
||||
Trigger<float> set_target_temperature_high_trigger_;
|
||||
Trigger<float> set_target_humidity_trigger_;
|
||||
Trigger<climate::ClimateFanMode> set_fan_mode_trigger_;
|
||||
Trigger<StringRef> set_custom_fan_mode_trigger_;
|
||||
Trigger<climate::ClimateSwingMode> set_swing_mode_trigger_;
|
||||
Trigger<climate::ClimatePreset> set_preset_trigger_;
|
||||
Trigger<StringRef> set_custom_preset_trigger_;
|
||||
};
|
||||
|
||||
} // namespace esphome::template_
|
||||
@@ -1,10 +1,13 @@
|
||||
#include "tuya.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/gpio.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/util.h"
|
||||
|
||||
#ifdef USE_NETWORK
|
||||
#include "esphome/components/network/util.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_WIFI
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#endif
|
||||
@@ -22,6 +25,14 @@ static const int MAX_RETRIES = 5;
|
||||
// Max bytes to log for datapoint values (larger values are truncated)
|
||||
static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16;
|
||||
|
||||
static bool network_is_connected() {
|
||||
#ifdef USE_NETWORK
|
||||
return network::is_connected();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void Tuya::setup() {
|
||||
this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); });
|
||||
if (this->status_pin_ != nullptr) {
|
||||
@@ -554,14 +565,14 @@ void Tuya::send_empty_command_(TuyaCommandType command) {
|
||||
}
|
||||
|
||||
void Tuya::set_status_pin_() {
|
||||
bool is_network_ready = network::is_connected() && remote_is_connected();
|
||||
bool is_network_ready = network_is_connected() && remote_is_connected();
|
||||
this->status_pin_->digital_write(is_network_ready);
|
||||
}
|
||||
|
||||
uint8_t Tuya::get_wifi_status_code_() {
|
||||
uint8_t status = 0x02;
|
||||
|
||||
if (network::is_connected()) {
|
||||
if (network_is_connected()) {
|
||||
status = 0x03;
|
||||
|
||||
// Protocol version 3 also supports specifying when connected to "the cloud"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any, NoReturn
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
from esphome.automation import Trigger
|
||||
@@ -48,17 +47,10 @@ UDP_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def is_relocated(option: str) -> Callable[[Any], NoReturn]:
|
||||
def validator(value: Any) -> NoReturn:
|
||||
raise cv.Invalid(
|
||||
f"The '{option}' option should now be configured in the 'packet_transport' component"
|
||||
)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
RELOCATED = {
|
||||
cv.Optional(x): is_relocated(x)
|
||||
cv.Optional(x): cv.invalid(
|
||||
f"The '{x}' option should now be configured in the 'packet_transport' component"
|
||||
)
|
||||
for x in (
|
||||
CONF_PROVIDERS,
|
||||
CONF_ENCRYPTION,
|
||||
|
||||
@@ -434,11 +434,12 @@ void USBUartTypeCdcAcm::on_connected() {
|
||||
auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_,
|
||||
channel->cdc_dev_.interrupt_interface_number, 0);
|
||||
if (err_comm != ESP_OK) {
|
||||
// Continue anyway: the interface number stays valid for CDC request addressing
|
||||
ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number,
|
||||
esp_err_to_name(err_comm));
|
||||
channel->cdc_dev_.interrupt_interface_number = 0xFF; // Mark as unavailable, but continue anyway
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number);
|
||||
channel->cdc_dev_.interrupt_interface_claimed = true;
|
||||
}
|
||||
}
|
||||
auto err =
|
||||
@@ -465,14 +466,15 @@ void USBUartTypeCdcAcm::on_disconnected() {
|
||||
usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress);
|
||||
usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress);
|
||||
}
|
||||
if (channel->cdc_dev_.notify_ep != nullptr) {
|
||||
// Only tear down the notify pipe when we claimed its interface ourselves;
|
||||
// no transfer is ever submitted on it, so there is nothing else to cancel.
|
||||
if (channel->cdc_dev_.notify_ep != nullptr && channel->cdc_dev_.interrupt_interface_claimed) {
|
||||
usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress);
|
||||
usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress);
|
||||
}
|
||||
if (channel->cdc_dev_.interrupt_interface_number != 0xFF &&
|
||||
channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) {
|
||||
if (channel->cdc_dev_.interrupt_interface_claimed) {
|
||||
usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number);
|
||||
channel->cdc_dev_.interrupt_interface_number = 0xFF;
|
||||
channel->cdc_dev_.interrupt_interface_claimed = false;
|
||||
}
|
||||
usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number);
|
||||
// Reset the input and output started flags to their initial state to avoid the possibility of spurious restarts
|
||||
|
||||
@@ -34,7 +34,10 @@ struct CdcEps {
|
||||
const usb_ep_desc_t *in_ep;
|
||||
const usb_ep_desc_t *out_ep;
|
||||
uint8_t bulk_interface_number;
|
||||
// Also the wIndex target for CDC class requests (SET_LINE_CODING etc.), so it
|
||||
// must remain valid even when the interface itself is not claimed.
|
||||
uint8_t interrupt_interface_number;
|
||||
bool interrupt_interface_claimed{false};
|
||||
};
|
||||
|
||||
enum CH34xChipType : uint8_t {
|
||||
|
||||
@@ -66,13 +66,14 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
ID,
|
||||
CoroPriority,
|
||||
EsphomeError,
|
||||
HexInt,
|
||||
coroutine_with_priority,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
from . import wpa2_eap
|
||||
|
||||
@@ -208,6 +209,7 @@ WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition)
|
||||
WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition)
|
||||
WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action)
|
||||
WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action)
|
||||
WiFiRoamAction = wifi_ns.class_("WiFiRoamAction", automation.Action)
|
||||
WiFiConfigureAction = wifi_ns.class_(
|
||||
"WiFiConfigureAction", automation.Action, cg.Component
|
||||
)
|
||||
@@ -820,6 +822,18 @@ async def wifi_disable_to_code(config, action_id, template_arg, args):
|
||||
return cg.new_Pvariable(action_id, template_arg)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"wifi.roam", WiFiRoamAction, cv.Schema({}), synchronous=True
|
||||
)
|
||||
async def wifi_roam_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> cg.MockObj:
|
||||
return cg.new_Pvariable(action_id, template_arg)
|
||||
|
||||
|
||||
KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results"
|
||||
RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save"
|
||||
RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression"
|
||||
|
||||
@@ -31,6 +31,11 @@ template<typename... Ts> class WiFiDisableAction final : public Action<Ts...> {
|
||||
void play(const Ts &...x) override { global_wifi_component->disable(); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class WiFiRoamAction final : public Action<Ts...> {
|
||||
public:
|
||||
void play(const Ts &...x) override { global_wifi_component->force_roam_check(); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class WiFiConfigureAction final : public Action<Ts...>, public Component {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(std::string, ssid)
|
||||
|
||||
@@ -846,17 +846,18 @@ void WiFiComponent::loop() {
|
||||
this->notify_connect_state_listeners_();
|
||||
#endif
|
||||
|
||||
// Post-connect roaming: check for better AP
|
||||
if (this->post_connect_roaming_) {
|
||||
if (this->is_roaming_scan_active()) {
|
||||
if (this->scan_done_) {
|
||||
this->process_roaming_scan_();
|
||||
}
|
||||
// else: scan in progress, wait
|
||||
} else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
|
||||
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
|
||||
this->check_roaming_(now);
|
||||
// Post-connect roaming: check for better AP. A scan may have been started by an
|
||||
// explicit force_roam_check() even when post_connect_roaming_ is disabled, so the
|
||||
// scan must always be consumed here to avoid leaving roaming_state_ stuck.
|
||||
if (this->is_roaming_scan_active()) {
|
||||
if (this->scan_done_) {
|
||||
this->process_roaming_scan_();
|
||||
}
|
||||
// else: scan in progress, wait
|
||||
} else if (this->post_connect_roaming_ && this->roaming_state_ == RoamingState::IDLE &&
|
||||
this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
|
||||
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
|
||||
this->check_roaming_(now);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -2463,6 +2464,17 @@ void WiFiComponent::notify_scan_results_listeners_() {
|
||||
}
|
||||
#endif // USE_WIFI_SCAN_RESULTS_LISTENERS
|
||||
|
||||
void WiFiComponent::force_roam_check() {
|
||||
if (!this->is_connected() || this->roaming_state_ != RoamingState::IDLE || this->roaming_suppressed_()) {
|
||||
ESP_LOGD(TAG, "Roam check requested, but not able to check now");
|
||||
return;
|
||||
}
|
||||
// Reset the attempt counter so a prior run of failed roams doesn't block this explicit request
|
||||
// Note that this re-arms automatic roaming if enabled.
|
||||
this->roaming_attempts_ = 0;
|
||||
this->check_roaming_(millis());
|
||||
}
|
||||
|
||||
void WiFiComponent::check_roaming_(uint32_t now) {
|
||||
// Guard: not for hidden networks (may not appear in scan)
|
||||
const WiFiAP *selected = this->get_selected_sta_();
|
||||
@@ -2484,7 +2496,11 @@ void WiFiComponent::check_roaming_(uint32_t now) {
|
||||
|
||||
ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
|
||||
this->roaming_state_ = RoamingState::SCANNING;
|
||||
this->wifi_scan_start_(this->passive_scan_);
|
||||
if (!this->wifi_scan_start_(this->passive_scan_)) {
|
||||
// Scan failed to start (e.g. busy) - don't get stuck in SCANNING forever
|
||||
ESP_LOGD(TAG, "Roam scan failed to start");
|
||||
this->roaming_state_ = RoamingState::IDLE;
|
||||
}
|
||||
}
|
||||
|
||||
void WiFiComponent::process_roaming_scan_() {
|
||||
|
||||
@@ -40,11 +40,6 @@
|
||||
#include <ESP8266WiFi.h>
|
||||
#include <ESP8266WiFiType.h>
|
||||
|
||||
#if defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE < VERSION_CODE(2, 4, 0)
|
||||
extern "C" {
|
||||
#include <user_interface.h>
|
||||
};
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef USE_RP2
|
||||
@@ -570,6 +565,12 @@ class WiFiComponent final : public Component {
|
||||
void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; }
|
||||
void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; }
|
||||
|
||||
/** Force an immediate post-connect roaming check, bypassing the periodic interval and the
|
||||
* per-connection attempt limit. Does nothing (besides a debug log) if not connected, if a
|
||||
* roam scan or connect is already in progress, or if roaming is currently suppressed.
|
||||
*/
|
||||
void force_roam_check();
|
||||
|
||||
#ifdef USE_WIFI_CONNECT_TRIGGER
|
||||
Trigger<> *get_connect_trigger() { return &this->connect_trigger_; }
|
||||
#endif
|
||||
|
||||
@@ -21,7 +21,6 @@ extern "C" {
|
||||
#include "lwip/apps/sntp.h"
|
||||
#include "lwip/netif.h" // struct netif
|
||||
#include <AddrList.h>
|
||||
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0)
|
||||
#include "LwipDhcpServer.h"
|
||||
#if USE_ARDUINO_VERSION_CODE < VERSION_CODE(3, 1, 0)
|
||||
#include <ESP8266WiFi.h>
|
||||
@@ -30,7 +29,6 @@ extern "C" {
|
||||
#define wifi_softap_set_dhcps_lease_time(time) dhcpSoftAP.set_dhcps_lease_time(time)
|
||||
#define wifi_softap_set_dhcps_offer_option(offer, mode) dhcpSoftAP.set_dhcps_offer_option(offer, mode)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
@@ -293,7 +291,6 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
|
||||
conf.bssid_set = 0;
|
||||
}
|
||||
|
||||
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0)
|
||||
if (ap.password_.empty()) {
|
||||
conf.threshold.authmode = AUTH_OPEN;
|
||||
} else {
|
||||
@@ -310,7 +307,6 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
|
||||
}
|
||||
}
|
||||
conf.threshold.rssi = -127;
|
||||
#endif
|
||||
|
||||
ETS_UART_INTR_DISABLE();
|
||||
bool ret = wifi_station_set_config_current(&conf);
|
||||
@@ -602,7 +598,6 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0)
|
||||
case EVENT_OPMODE_CHANGED: {
|
||||
auto it = event->event_info.opmode_changed;
|
||||
ESP_LOGV(TAG, "Changed Mode old=%s new=%s", LOG_STR_ARG(get_op_mode_str(it.old_opmode)),
|
||||
@@ -620,7 +615,6 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -705,7 +699,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
config.bssid = nullptr;
|
||||
config.channel = 0;
|
||||
config.show_hidden = 1;
|
||||
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0)
|
||||
config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE;
|
||||
// Use shorter dwell times for roaming scans - we only need to detect strong
|
||||
// nearby APs, not do a thorough survey. This also reduces off-channel time
|
||||
@@ -724,7 +717,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
config.scan_time.active.min = roaming ? SCAN_ACTIVE_MIN_ROAMING_MS : SCAN_ACTIVE_MIN_DEFAULT_MS;
|
||||
config.scan_time.active.max = roaming ? SCAN_ACTIVE_MAX_ROAMING_MS : SCAN_ACTIVE_MAX_DEFAULT_MS;
|
||||
}
|
||||
#endif
|
||||
bool ret = wifi_station_scan(&config, &WiFiComponent::s_wifi_scan_done_callback);
|
||||
if (!ret) {
|
||||
ESP_LOGV(TAG, "wifi_station_scan failed");
|
||||
@@ -830,7 +822,7 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0) && USE_ARDUINO_VERSION_CODE < VERSION_CODE(3, 1, 0)
|
||||
#if USE_ARDUINO_VERSION_CODE < VERSION_CODE(3, 1, 0)
|
||||
dhcpSoftAP.begin(&info);
|
||||
#endif
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ Upper = vol.Upper
|
||||
Length = vol.Length
|
||||
Exclusive = vol.Exclusive
|
||||
Inclusive = vol.Inclusive
|
||||
Unique = vol.Unique
|
||||
ALLOW_EXTRA = vol.ALLOW_EXTRA
|
||||
UNDEFINED = vol.UNDEFINED
|
||||
RequiredFieldInvalid = vol.RequiredFieldInvalid
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user