Compare commits

...
Author SHA1 Message Date
esphome[bot] f91486305f Bump bundled esphome-device-builder to 1.14.5 (#19040) 2026-09-08 15:21:48 +02:00
Johnandpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> f191d5e0c3 [atm90e32] Verify offset calibration writes (#18701)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-09-08 20:02:49 +12:00
Jesse Hills 227ca90aad [core] Restore the shared git hooks after post-checkout runs script/setup (#19036) 2026-09-08 20:00:08 +12:00
Jesse Hills 1700a40b7c [core] Restore the shared git hooks after post-checkout runs script/setup (#19036) 2026-09-08 19:59:55 +12:00
raykholo 28588310e7 [anova] Re-assert temperature unit on every poll cycle (#17141) 2026-09-08 18:57:33 +12:00
10a9baff74 [rf_bridge] Fix bucket sniffing with Portisch firmware (#17683)
Co-authored-by: Bryan Li <bryanli@Bryans-MacBook-Pro.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 18:36:42 +12:00
Gytis a23f7bb569 [lvgl] Add missing label dependency to qrcode, keyboard and tabview (#18387) 2026-09-08 18:31:30 +12:00
J. Nick Koston 53075e4139 [core] Skip PlatformIO's private-package authorization probe (#18823) 2026-09-08 16:42:30 +12:00
Jonathan Swoboda 5722ccba37 [tuya] Build without a network component (#18948) 2026-09-08 16:36:04 +12:00
Jesse Hills 94e5c3839d [udp] Use cv.invalid for relocated packet_transport options (#19032) 2026-09-08 16:17:22 +12:00
Davide D M 574762f078 [debug] Check reboot source pref on ESP_RST_WDT and guard against empty source (#17537) 2026-09-08 13:59:05 +12:00
Pieter ViljoenandJesse Hills d34ffaf392 [ble_client] Report Established from nodes that never read services (#17920)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-08 01:57:50 +00:00
Samuel SiebandSamuel Sieb e6aa575f2e [dallas_temp] filter 85 temp from sensor reset (#17877)
Co-authored-by: Samuel Sieb <samuel@sieb.net>
2026-09-08 13:27:49 +12:00
AndreKR 639ce609bf [logger] Fix garbled stack traces (#17939) 2026-09-08 13:13:51 +12:00
Ryan Ronnander 62eafc477d [mqtt] Restore brightness flag in light discovery (#18950) 2026-09-08 13:09:02 +12:00
mipa87andClaude 56c3361b9a [audio] Do not treat MP3_STREAM_INFO_CHANGED as a fatal decoder error (#19028)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-08 12:13:03 +12:00
mipa87Claudepre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
50ca381198 [i2s_audio] Keep a start request that arrives while the speaker task stops (#19027)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-09-08 12:10:55 +12:00
dependabot[bot] 89a56298c2 Bump prek from 0.5.1 to 0.5.2 (#19021)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-08 00:00:28 +00:00
dependabot[bot]esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>Jesse Hills
390742cf9b Bump ruff from 0.16.5 to 0.16.6 (#19022)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-07 23:47:46 +00:00
Jesse Hillsandpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> 5e37872da2 [ci] Sync pre-commit revs and prek version from requirements files (#19026)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-09-08 10:32:18 +12:00
Jesse Hills d34d3994e1 Merge branch 'beta' into dev 2026-09-07 12:50:44 +12:00
Jesse Hills 9a877a067c Merge pull request #19007 from esphome/bump-2026.9.0b2
2026.9.0b2
2026-09-07 12:50:25 +12:00
Jesse Hills 9ba4477ada Bump version to 2026.9.0b2 2026-09-07 10:46:22 +12:00
Jesse Hills 8434dc5474 [esp32_hosted] Add ESP-NOW-over-hosted shim for the ESP32-P4 (#17712) 2026-09-07 10:46:21 +12:00
J. Nick Koston e0e85db822 [core] Show the other downloader's progress while a prefetch job waits on its lock (#18983) 2026-09-07 10:46:21 +12:00
J. Nick Koston 5d2ddc658c [mdns] Guard LEAmDNS main loop calls against lwIP re-entrancy on ESP8266 (#18990) 2026-09-07 10:46:21 +12:00
J. Nick Koston c1aa41f276 [noise] Bump noise-c to 0.1.24 and libsodium to 1.10021.6 (#18989) 2026-09-07 10:46:21 +12:00
esphome[bot] 96b1a03ea4 Bump bundled esphome-device-builder to 1.14.4 (#19006) 2026-09-07 10:46:21 +12:00
J. Nick Koston 95ab3fb4f2 [ota] Offer encryption with the api key so enabling it works over OTA (#18979) 2026-09-07 10:46:21 +12:00
Ricardo Sanz 18220e0b39 [climate][template] New template climate component (#14455) 2026-09-07 10:46:21 +12:00
esphome[bot] 011497d6ee Bump bundled esphome-device-builder to 1.14.3 (#18996) 2026-09-07 10:46:20 +12:00
esphome[bot] 7089dae3b6 Bump bundled esphome-device-builder to 1.14.2 (#18988) 2026-09-07 10:46:20 +12:00
esphome[bot] 745eb30109 Bump bundled esphome-device-builder to 1.14.1 (#18981) 2026-09-07 10:46:20 +12:00
Keith Burzinski e36445fa5f [usb_uart] Keep the comm interface number valid when its claim fails (#18968) 2026-09-07 10:46:20 +12:00
Jesse Hills cb0c2bdaca [esp32_ble] Reference count BLE advertising (#18943) 2026-09-07 10:46:20 +12:00
J. Nick Kostonandpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> e47247486b [esp8266] Drop Arduino framework versions before 3.0.0 (#18917) to
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-09-07 10:46:20 +12:00
esphome[bot]esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>Jonathan Swoboda
657116a213 Bump bundled esphome-device-builder to 1.14.0 (#18960)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-09-07 10:46:20 +12:00
Keith Burzinski 3321566cc0 [remote_transmitter] Fix BK7231N build by limiting the PWM path to BK7238 (#18958) 2026-09-07 10:46:20 +12:00
Jesse Hills d58b37faa1 [esp32_hosted] Add ESP-NOW-over-hosted shim for the ESP32-P4 (#17712) 2026-09-07 10:29:20 +12:00
J. Nick Koston 8966567be0 [core] Show the other downloader's progress while a prefetch job waits on its lock (#18983) 2026-09-07 10:28:31 +12:00
J. Nick Koston 20c7dcb1dd [mdns] Guard LEAmDNS main loop calls against lwIP re-entrancy on ESP8266 (#18990) 2026-09-07 10:26:44 +12:00
J. Nick Koston 688af60cbf [noise] Bump noise-c to 0.1.24 and libsodium to 1.10021.6 (#18989) 2026-09-07 10:12:52 +12:00
esphome[bot] 9c00f13606 Bump bundled esphome-device-builder to 1.14.4 (#19006) 2026-09-06 22:05:16 +00:00
J. Nick Koston 833dd0e812 [ota] Offer encryption with the api key so enabling it works over OTA (#18979) 2026-09-06 23:59:40 +02:00
Ricardo Sanz 8e1044e8ea [climate][template] New template climate component (#14455) 2026-09-06 14:03:07 -07:00
esphome[bot] e5200db6fd Bump bundled esphome-device-builder to 1.14.3 (#18996) 2026-09-06 09:28:02 +02:00
e3dd2f44a4 [mipi_dsi] Let IDF pick the DPHY PLL reference clock (#18984)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-09-05 21:08:21 +00:00
esphome[bot] 3ef7460fca Bump bundled esphome-device-builder to 1.14.2 (#18988) 2026-09-05 15:25:58 +00:00
Clyde Stubbspre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>Claude
ae187f81f2 [wifi] Allow a forced roam check (#17349)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-05 21:44:37 +10:00
esphome[bot] 84f78831f9 Bump bundled esphome-device-builder to 1.14.1 (#18981) 2026-09-05 13:07:15 +02:00
Keith Burzinski 13dbbcaa32 [usb_uart] Keep the comm interface number valid when its claim fails (#18968) 2026-09-05 13:00:25 +02:00
Clyde Stubbs b66822d9bd [ai] Advice to agents to limit verbiage (#18980) 2026-09-05 12:21:47 +02:00
142 changed files with 6295 additions and 916 deletions
+11 -2
View File
@@ -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
+2 -3
View File
@@ -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)$
+2 -1
View File
@@ -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:
+36 -19
View File
@@ -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
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5
RUN \
platformio settings set enable_telemetry No \
+13 -1
View File
@@ -1335,12 +1335,14 @@ def _upload_via_native_api(
break
from esphome import espota2
from esphome.components.noise import static_encryption_key
remote_port = int(ota_conf[CONF_PORT])
password = ota_conf.get(CONF_PASSWORD)
# Fail closed: an encryption block whose key did not resolve must never
# fall back to a plaintext upload
noise_psk = None
plaintext_fallback = False
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
noise_psk = encryption_conf.get(CONF_KEY)
if not noise_psk:
@@ -1351,6 +1353,10 @@ def _upload_via_native_api(
# Ensure the key is a string, as required by the underlying OTA implementation.
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
noise_psk = str(noise_psk)
elif api_key := static_encryption_key(config.get(CONF_API) or {}):
# Remove before 2027.3.0: the api key is tried, falling back to plaintext
noise_psk = str(api_key)
plaintext_fallback = True
def check_partition_access(option_string: str) -> None:
if not ota_conf.get("allow_partition_access"):
@@ -1382,7 +1388,13 @@ def _upload_via_native_api(
_validate_bootloader_binary(binary)
return espota2.run_ota(
network_devices, remote_port, password, binary, ota_type, noise_psk
network_devices,
remote_port,
password,
binary,
ota_type,
noise_psk,
plaintext_fallback=plaintext_fallback,
)
+1 -3
View File
@@ -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__)
+51 -56
View File
@@ -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
+11 -2
View File
@@ -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
+2 -2
View File
@@ -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
+5 -1
View File
@@ -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;
+14 -23
View File
@@ -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
+11 -1
View File
@@ -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
};
+206 -154
View File
@@ -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);
+49 -22
View File
@@ -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};
+4 -3
View File
@@ -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_ =
+24 -10
View File
@@ -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) {
+13
View File
@@ -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"
@@ -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;
}
+6 -2
View File
@@ -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 {
+1 -5
View File
@@ -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"]
+1 -5
View File
@@ -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"]
+2 -9
View File
@@ -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"
@@ -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 */
+76 -54
View File
@@ -2,12 +2,12 @@ import logging
import esphome.codegen as cg
from esphome.components.noise import (
decode_encryption_key,
encryption_schema,
is_reserved_key,
new_psk_progmem,
static_encryption_key,
)
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
from esphome.config_helpers import merge_config
from esphome.config_helpers import filter_source_files_from_defines, merge_config
import esphome.config_validation as cv
from esphome.const import (
CONF_API,
@@ -31,7 +31,6 @@ import esphome.final_validate as fv
from esphome.types import ConfigType
CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access"
CONF_CAPTIVE_PORTAL = "captive_portal"
_LOGGER = logging.getLogger(__name__)
@@ -41,11 +40,10 @@ DEPENDENCIES = ["network"]
def AUTO_LOAD(config: ConfigType) -> list[str]:
"""Auto-load noise only when encryption is configured."""
"""Auto-load noise only when encryption is configured; the api key offer
inherits it from the api component."""
base = ["sha256", "socket"]
# A falsy config is a tooling probe for the maximal set (None from
# dependency resolution, {} from the components-graph platform probe);
# a validated config always carries defaults, never empty
# A falsy config is a tooling probe for the maximal set
if not config or CONF_ENCRYPTION in config:
return base + ["noise"]
return base
@@ -132,12 +130,56 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
_validate_no_password_with_encryption(ota_conf)
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
_resolve_encryption_key(encryption_conf, api_conf)
if any(
conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf
) and any(
CONF_ENCRYPTION in conf for conf in merged_ota_esphome_configs_by_port.values()
elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None:
_LOGGER.warning(
"'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 "
"bytes plus the password on the heap): the device already offers "
"encryption with the '%s' %s %s, which authenticates any uploader "
"that takes it, and a password only matters for uploaders without "
"encryption support; remove '%s' and add '%s' under '%s' so "
"uploads use the key and encryption is required",
CONF_OTA,
CONF_PASSWORD,
CONF_API,
CONF_ENCRYPTION,
CONF_KEY,
CONF_PASSWORD,
CONF_ENCRYPTION,
CONF_OTA,
)
elif (
CONF_PASSWORD in ota_conf
and CONF_ENCRYPTION in api_conf
and not api_conf[CONF_ENCRYPTION].get(CONF_KEY)
):
# The CLI still needs the password; whoever provisions the key skips it
_LOGGER.warning(
"The '%s' %s %s provisioned at runtime also authenticates OTA "
"uploads once provisioned; '%s' %s then only guards plaintext "
"uploads. Whoever provisions the key can upload firmware "
"without the password, so add a 'provisioning:' block to limit "
"when that is possible",
CONF_API,
CONF_ENCRYPTION,
CONF_KEY,
CONF_OTA,
CONF_PASSWORD,
)
# web_server and prometheus keep the shared listener up; the captive
# portal's copy only exists on the fallback AP and is the recovery path
if (
(CONF_WEB_SERVER in full_conf or "prometheus" in full_conf)
and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf)
and any(
CONF_ENCRYPTION in conf
for conf in merged_ota_esphome_configs_by_port.values()
)
):
_warn_web_server_ota(full_conf)
_LOGGER.warning(
"OTA encryption does not cover the %s OTA platform; its "
"plaintext /update endpoint accepts the same image",
CONF_WEB_SERVER,
)
full_conf[CONF_OTA] = new_ota_conf
fv.full_config.set(full_conf)
@@ -152,33 +194,11 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
)
def _warn_web_server_ota(full_conf: ConfigType) -> None:
"""The web_server ota platform accepts the same image over plaintext HTTP
with basic auth, bypassing the encryption; warn rather than fail so the
operator keeps the recovery path."""
if CONF_CAPTIVE_PORTAL in full_conf and CONF_WEB_SERVER not in full_conf:
# The captive_portal auto-load: the endpoint only exists while the
# fallback AP is active
_LOGGER.warning(
"OTA encryption does not cover the %s OTA platform (auto-loaded "
"by captive_portal); the plaintext /update endpoint stays "
"reachable while the fallback AP is active",
CONF_WEB_SERVER,
)
else:
_LOGGER.warning(
"OTA encryption does not cover the %s OTA platform; its "
"plaintext /update endpoint accepts the same image",
CONF_WEB_SERVER,
)
def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -> None:
"""Resolve the one encryption key per device into the ota block.
An explicit ota key must match the api key, a bare block inherits it,
a runtime provisioned api key cannot be inherited, and the all-zeros
provisioning sentinel is rejected (the device treats it as no key).
a runtime provisioned api key cannot be inherited.
"""
api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
if ota_key := encryption_conf.get(CONF_KEY):
@@ -201,11 +221,6 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -
)
else:
encryption_conf[CONF_KEY] = api_key
if is_reserved_key(encryption_conf[CONF_KEY]):
raise cv.Invalid(
f"The all-zeros {CONF_KEY} is reserved and provides no protection; "
f"generate a real key with: openssl rand -base64 32"
)
# Also called on merged same-port configs in final validate, where schemas
@@ -267,15 +282,9 @@ CONFIG_SCHEMA = cv.All(
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
def FILTER_SOURCE_FILES() -> list[str]:
"""Filter out the noise transport when no ota entry configures encryption."""
for ota_conf in CORE.config.get(CONF_OTA, []):
if (
ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME
and ota_conf.get(CONF_ENCRYPTION) is not None
):
return []
return ["ota_esphome_noise.cpp"]
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"}
)
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
@@ -296,11 +305,24 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_ALLOW_PARTITION_ACCESS):
cg.add_define("USE_OTA_PARTITIONS")
if (encryption_conf := config.get(CONF_ENCRYPTION)) is not None:
# A missing key was resolved from the api component in final validate.
key = encryption_conf[CONF_KEY]
# One key per device: an api encryption block supplies it (static or
# runtime) and offers; the ota block only adds the requirement
api_conf = CORE.config.get(CONF_API) or {}
encryption_conf = config.get(CONF_ENCRYPTION)
own_key = None
if encryption_conf is not None and static_encryption_key(api_conf) is None:
own_key = encryption_conf[CONF_KEY]
if own_key is not None:
cg.add_define("USE_OTA_ENCRYPTION")
cg.add(var.set_noise_psk(list(decode_encryption_key(key))))
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key)))
elif CONF_ENCRYPTION in api_conf:
cg.add_define("USE_OTA_ENCRYPTION")
cg.add_define("USE_OTA_ENCRYPTION_FROM_API")
if static_encryption_key(api_conf) is None:
# The key arrives at runtime, so the offer has to look for it
cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED")
if encryption_conf is not None:
cg.add_define("USE_OTA_ENCRYPTION_REQUIRED")
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
+60 -23
View File
@@ -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"
@@ -26,6 +29,16 @@
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
@@ -97,18 +110,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,10 +179,22 @@ 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;
// 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;
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 +278,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;
@@ -261,18 +295,21 @@ void ESPHomeOTAComponent::handle_handshake_() {
// 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
} else {
this->handshake_buf_[0] =
@@ -284,15 +321,15 @@ 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->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 &&
(this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) {
// 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])) {
@@ -412,7 +449,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"));
+10 -3
View File
@@ -44,8 +44,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 +86,12 @@ 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;
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);
@@ -144,7 +148,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
@@ -166,6 +172,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
"OTA_BUFFER_SIZE must fit a full encrypted data frame");
#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 +187,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
@@ -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>
@@ -40,24 +41,17 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
* "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags
*/
bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
// A provisioned key cleared between the offer and here is not guarded: the
// session runs on the zero key load_psk fills in and fails the client's MAC.
// Default-init: the frame buffer is written before it is read
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession());
if (this->noise_ == nullptr) {
ESP_LOGW(TAG, "Session allocation failed");
this->cleanup_connection_();
return false;
}
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession);
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 +65,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 +103,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 +123,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 +138,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 +154,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 +222,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 +237,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 +274,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);
+20
View File
@@ -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();
@@ -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).
+2 -1
View File
@@ -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")
+2 -1
View File
@@ -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(
+2 -1
View File
@@ -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(
+2
View File
@@ -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)
+45 -6
View File
@@ -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);
}
+2 -2
View File
@@ -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) {
+3
View File
@@ -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();
+1 -6
View File
@@ -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,
+28 -12
View File
@@ -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.24")
# 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.6")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
+9
View File
@@ -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");
+8 -8
View File
@@ -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
+4 -1
View File
@@ -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);
+3 -3
View File
@@ -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;
+88 -21
View File
@@ -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;
}
}
}
+13
View File
@@ -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_;
@@ -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_
+14 -3
View File
@@ -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"
+4 -12
View File
@@ -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,
+7 -5
View File
@@ -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
+3
View File
@@ -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 {
+15 -1
View File
@@ -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"
+5
View File
@@ -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)
+27 -11
View File
@@ -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_() {
+6
View File
@@ -565,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
+1
View File
@@ -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
+4
View File
@@ -71,6 +71,7 @@
#define USE_ESP32_HOSTED
#define USE_ESP32_HOSTED_HTTP_UPDATE
#define USE_ESP32_IMPROV_STATE_CALLBACK
#define USE_ESP_NOW_HOSTED
#define USE_EVENT
#define USE_FAN
#define USE_GPIO_BINARY_SENSOR_INTERRUPT
@@ -244,6 +245,9 @@
#define USE_RUNTIME_STATS
#define USE_OTA
#define USE_OTA_ENCRYPTION
#define USE_OTA_ENCRYPTION_FROM_API
#define USE_OTA_ENCRYPTION_PROVISIONED
#define USE_OTA_ENCRYPTION_REQUIRED
#define USE_OTA_PASSWORD
#define USE_OTA_VERSION 2
#define USE_TIME_TIMEZONE
+109 -13
View File
@@ -202,6 +202,49 @@ class OTANetworkError(OTAError):
"""Network-level OTA failure (timeout, reset, closed connection); retrying may succeed."""
# Remove before 2027.3.0
class OTAEncryptionFallback(OTAError):
"""The encrypted attempt failed and the caller may retry in plaintext."""
# Remove before 2027.3.0
PLAINTEXT_FALLBACK_NOTICE = (
"A device with an api encryption key offers encryption after this "
"install; add 'encryption:' under 'ota: platform: esphome' to require it. "
"This plaintext fallback is removed in 2027.3.0."
)
# Remove before 2027.3.0
class _EncryptionAttempt:
"""The key an upload tries and whether it may fall back to plaintext;
a rejected handshake falls back at once, a transport fault only on repeat."""
def __init__(self, noise_psk: str | None, plaintext_fallback: bool) -> None:
self.noise_psk = noise_psk
self.plaintext_fallback = plaintext_fallback
self.handshake_faults = 0
def handshake_fault_falls_back(self) -> bool:
self.handshake_faults += 1
return self.plaintext_fallback and self.handshake_faults >= 2
def downgrade(self, reason: str) -> None:
_LOGGER.warning(
"%s. Retrying in plaintext; a device that requires encryption "
"refuses it. %s",
reason,
PLAINTEXT_FALLBACK_NOTICE,
)
self.noise_psk = None
self.plaintext_fallback = False
# Remove before 2027.3.0: only the fallback decision needs this distinction
class OTAHandshakeNetworkError(OTANetworkError):
"""A transport failure inside the noise handshake; retrying encrypted may succeed."""
def _committed_error(err: OTANetworkError) -> OTAError:
"""Wrap a network failure that happened once the device had the full image.
@@ -464,6 +507,7 @@ def perform_ota(
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
noise_psk: str | None = None,
plaintext_fallback: bool = False,
) -> None:
# Validate up front; an out-of-range value would only surface as a
# ValueError deep inside send_check, bypassing OTAError handling
@@ -528,19 +572,28 @@ def perform_ota(
else:
features = 0
if noise_psk:
# Fail closed: never fall back to a plaintext upload when an
# encryption key is configured, an active attacker could otherwise
# strip the feature flag and capture the image (it contains the wifi
# credentials and the api encryption key).
if not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE):
if noise_psk and not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE):
if plaintext_fallback:
# Remove before 2027.3.0: older firmware that cannot encrypt still
# gets its update on this connection
_LOGGER.warning(
"The device did not offer OTA encryption; continuing in plaintext. %s",
PLAINTEXT_FALLBACK_NOTICE,
)
noise_psk = None
else:
# Fail closed: an attacker could otherwise strip the offer and
# capture the image (wifi credentials, api key)
raise OTAError(
"An OTA encryption key is configured but the device did not "
"offer encryption; refusing to send the image in plaintext. "
"If the running firmware predates OTA encryption, first update "
"it without the 'ota: encryption:' block (over a trusted "
"network or via USB), then restore the block and upload again."
"The running firmware predates ESPHome 2026.9.0 or has no "
"'api: encryption: key'. With an api key, install once "
"without the 'ota: encryption:' block (that build offers "
"encryption), then restore it; otherwise flash by serial or "
"the web_server OTA platform."
)
if noise_psk:
# The prologue binds every negotiation byte both sides saw, so any
# tampering with the plaintext preamble breaks the handshake.
prologue = (
@@ -549,8 +602,18 @@ def perform_ota(
+ bytes([RESPONSE_OK, version, features_to_send])
+ bytes([RESPONSE_FEATURE_FLAGS, features])
)
# Built outside the try: a local failure must never downgrade the upload
sock = NoiseSocketWrapper(sock, noise_psk, prologue)
sock.do_handshake()
try:
sock.do_handshake()
except OTANetworkError as err:
# A transport fault: retry encrypted before considering plaintext
raise OTAHandshakeNetworkError(str(err)) from err
except OTAError as err:
# Remove before 2027.3.0
if plaintext_fallback:
raise OTAEncryptionFallback(str(err)) from err
raise
_LOGGER.info("Encrypted connection established")
if ota_type != OTA_TYPE_UPDATE_APP:
@@ -757,6 +820,7 @@ def run_ota_impl_(
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
noise_psk: str | None = None,
plaintext_fallback: bool = False,
) -> tuple[int, str | None]:
from esphome.core import CORE
@@ -795,7 +859,9 @@ def run_ota_impl_(
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
last_error = ""
reached_device = False
for attempt in range(total_attempts):
attempt = 0
encryption = _EncryptionAttempt(noise_psk, plaintext_fallback)
while attempt < total_attempts:
af, socktype, _, _, sa = res[attempt % len(res)]
if reached_device or attempt >= len(res):
_LOGGER.info(
@@ -815,17 +881,40 @@ def run_ota_impl_(
sock.close()
_LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err)
last_error = f"connecting to {sa[0]} failed: {err}"
attempt += 1
continue
_LOGGER.info("Connected to %s", sa[0])
reached_device = True
with contextlib.closing(sock), Path(filename).open("rb") as file_handle:
try:
perform_ota(sock, password, file_handle, filename, ota_type, noise_psk)
perform_ota(
sock,
password,
file_handle,
filename,
ota_type,
encryption.noise_psk,
encryption.plaintext_fallback,
)
except OTAEncryptionFallback as err:
# Same address and attempt budget: not a network retry
last_error = str(err)
encryption.downgrade(last_error)
continue
except OTAHandshakeNetworkError as err:
last_error = str(err)
if encryption.handshake_fault_falls_back():
encryption.downgrade(last_error)
continue
_LOGGER.warning("%s", last_error)
attempt += 1
continue
except OTANetworkError as err:
# Transient network failure; retry
last_error = str(err)
_LOGGER.warning("%s", last_error)
attempt += 1
continue
except OTAError as err:
# Device-reported error (wrong password, wrong flash size, ...);
@@ -847,10 +936,17 @@ def run_ota(
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
noise_psk: str | None = None,
plaintext_fallback: bool = False,
) -> tuple[int, str | None]:
try:
return run_ota_impl_(
remote_host, remote_port, password, filename, ota_type, noise_psk
remote_host,
remote_port,
password,
filename,
ota_type,
noise_psk,
plaintext_fallback,
)
except OTAError as err:
_LOGGER.error(err)
+57 -4
View File
@@ -23,6 +23,7 @@ from esphome.net_retry import (
)
if TYPE_CHECKING:
from filelock import FileLock
import requests
PathType = str | os.PathLike
@@ -909,6 +910,61 @@ def _part_path(dest: Path) -> Path:
return dest.with_name(dest.name + ".part")
def downloaded_bytes(dest: Path, size: int | None = None) -> int:
"""Bytes of ``dest`` on disk (its ``.part`` while streaming), capped at ``size``."""
done = 0
for candidate in (_part_path(dest), dest):
try:
done = candidate.stat().st_size
break
except FileNotFoundError:
continue
return done if size is None else min(done, size)
# Short lock-acquire slices so a waiting worker still observes Ctrl-C
_DOWNLOAD_LOCK_POLL = 1
# Waiting on another process's download; past this the caller leaves the
# file to its holder (the later sequential install waits on the same lock)
DOWNLOAD_LOCK_TIMEOUT = 60
class DownloadLockUnavailable(OSError):
"""The lock file cannot be used at all (a lock-less filesystem)."""
def wait_for_download_lock(
lock: "FileLock",
tracker: Callable[[int], None],
on_disk: Callable[[], int],
name: str,
) -> None:
"""Acquire ``lock``, reporting ``on_disk()`` to ``tracker`` each poll so the
bar follows the holder's download. Raises filelock's ``Timeout`` once
``DOWNLOAD_LOCK_TIMEOUT`` seconds pass."""
from filelock import Timeout
deadline = time.monotonic() + DOWNLOAD_LOCK_TIMEOUT
waiting = False
while True:
try:
lock.acquire(timeout=_DOWNLOAD_LOCK_POLL)
return
except Timeout:
pass
except OSError as err:
# Distinct from an OSError out of on_disk(), which must not
# read as "locks unsupported"
raise DownloadLockUnavailable(*err.args) from err
if not waiting:
waiting = True
_LOGGER.info("Waiting for another process downloading %s", name)
tracker(on_disk()) # raises when the batch is cancelled
if time.monotonic() >= deadline:
raise Timeout(lock.lock_file)
def discard_partial_download(dest: Path) -> None:
"""Remove ``dest`` and the resume sidecars of an abandoned download."""
part = _part_path(dest)
@@ -1319,10 +1375,7 @@ def download_from_mirrors(
)
# Tick with the bytes already on disk so a combined bar holds
# steady during the backoff instead of rewinding to zero
done = 0
if progress is not None:
part = _part_path(path_target)
done = part.stat().st_size if part.is_file() else 0
done = downloaded_bytes(path_target) if progress is not None else 0
_cancellable_sleep(delay, progress, done)
# 3. Report every attempted URL if all mirrors failed. failures spans
+1 -4
View File
@@ -69,10 +69,7 @@ def _make_create_connection() -> Callable[..., socket.socket]:
from aiohappyeyeballs import start_connection
from urllib3.exceptions import LocationParseError
from urllib3.util.connection import ( # noqa: PLC2701
_set_socket_options,
allowed_gai_family,
)
from urllib3.util.connection import _set_socket_options, allowed_gai_family # noqa: PLC2701
from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701
from esphome import async_thread
+5 -1
View File
@@ -616,11 +616,15 @@ def _make_registry_client() -> Any:
elsewhere, not by the PlatformIO registry.
"""
from platformio.package.manager._registry import PackageManagerRegistryMixin
from platformio.registry.client import RegistryClient
class _Registry(PackageManagerRegistryMixin):
def __init__(self) -> None:
self._registry_client = None
self.pkg_type = "library"
self._registry_client = RegistryClient()
# The probe sleeps ~500 ms per lookup (see runner.patch_registry_private_packages);
# instance-level so the ESPHome process never patches PlatformIO's class
self._registry_client.allowed_private_packages = lambda: False
@staticmethod
def is_system_compatible(value: Any, custom_system: Any = None) -> bool:
+46 -43
View File
@@ -33,11 +33,14 @@ import time
from typing import Any, NamedTuple
from esphome.framework_helpers import (
DownloadLockUnavailable,
content_length,
discard_partial_download,
downloaded_bytes,
failure_reason,
resume_fetch_job,
run_batch_downloads,
wait_for_download_lock,
warn_prefetch_failures,
)
from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree
@@ -61,16 +64,10 @@ _RESOLVE_WORKERS = 8
# A hung child must not block the build; downloads resume on the next run
_PREFETCH_TIMEOUT = 20 * 60
# Waiting on another process's URL download; past this, leave it to pio
_DOWNLOAD_LOCK_TIMEOUT = 60
# Child exit for a handled, already-warned failure; 1 would collide with
# the interpreter's own import-failure exit
_EXIT_HANDLED = 3
# Short lock-acquire slices so a waiting worker still observes Ctrl-C
_URI_LOCK_POLL = 1
# Resolution errored (vs a clean skip); suppresses the warm sentinel
_RESOLVE_FAILED = object()
@@ -462,51 +459,54 @@ def _uri_jobs(
def _serialized_fetch_job(
dl_path: Path, lock_path: str, body: Any, unlocked_ok: bool = True
dl_path: Path,
lock_path: str,
body: Any,
size: int,
stream_dest: Path | None = None,
unlocked_ok: bool = True,
) -> Any:
"""Wrap ``body`` so the shared destination is single-writer.
Interleaved writers truncate each other's ``.part`` bytes (see
registry.py). The bounded poll observes Ctrl-C via the tracker; a
blown deadline is a clean skip (the holder's copy is what the build
needs). On a lock-less filesystem a sha256-verified body runs
unlocked with one warning; a checksum-less one
(``unlocked_ok=False``) is a counted failure instead.
"""Wrap ``body`` so the shared destination is single-writer (interleaved
writers truncate each other's ``.part``, see registry.py). A blown deadline
is a clean skip. On a lock-less filesystem a sha256-verified body runs
unlocked with one warning; a checksum-less one (``unlocked_ok=False``) fails.
"""
def on_disk() -> int:
# A URL job's holder streams beside the staging path until it
# promotes; after that only dl_path is left
done = downloaded_bytes(dl_path, size)
if not done and stream_dest is not None:
done = downloaded_bytes(stream_dest, size)
return done
def run(tracker: Any) -> None:
from filelock import FileLock, Timeout
# fallback_to_soft would leave a stale marker on lock-less
# filesystems that blocks every later build (see git.py)
lock = FileLock(lock_path, fallback_to_soft=False)
deadline = time.monotonic() + _DOWNLOAD_LOCK_TIMEOUT
while True:
try:
lock.acquire(timeout=_URI_LOCK_POLL)
break
except Timeout:
tracker(0) # raises when the batch is cancelled
if time.monotonic() >= deadline:
# Another process is fetching this same file; its copy
# is what the build needs (a large framework archive
# can hold the lock far longer than this deadline)
_LOGGER.debug("Leaving %s to its current downloader", dl_path.name)
return
except OSError as err:
if not unlocked_ok:
# A body with no checksum to catch interleaved corruption
raise
lock = None
_LOGGER.warning(
"Could not lock %s (%s); downloading unlocked",
dl_path.name,
err,
)
break
try:
wait_for_download_lock(lock, tracker, on_disk, dl_path.name)
except Timeout:
# The holder's copy is what the build needs (a large
# framework archive can outlast this deadline)
_LOGGER.debug("Leaving %s to its current downloader", dl_path.name)
return
except DownloadLockUnavailable as err:
if not unlocked_ok:
# A body with no checksum to catch interleaved corruption
raise
lock = None
_LOGGER.warning(
"Could not lock %s (%s); downloading unlocked",
dl_path.name,
err,
)
try:
if dl_path.is_file():
return # another process finished it while we waited
tracker(size) # another process finished it while we waited
return
body(tracker)
finally:
if lock is not None:
@@ -540,6 +540,7 @@ def _registry_fetch_job(
dl_path,
f"{dl_path}.esphome.lock",
resume_fetch_job(url, dl_path, sha256=checksum, size=size),
size,
)
def run(tracker: Any) -> None:
@@ -571,9 +572,9 @@ def _uri_fetch_job(manager: Any, url: str, dl_path: Path, size: int) -> Any:
tmp.replace(dl_path)
def run(tracker: Any) -> None:
_serialized_fetch_job(dl_path, f"{tmp}.lock", promote, unlocked_ok=False)(
tracker
)
_serialized_fetch_job(
dl_path, f"{tmp}.lock", promote, size, tmp, unlocked_ok=False
)(tracker)
if dl_path.is_file():
# Won or lost, the race is over; staging files left behind
# are dead weight PlatformIO's cache never prunes
@@ -950,8 +951,10 @@ def main(argv: list[str]) -> int:
"""Subprocess entry point: ``prefetch <build_dir> <env_name>``."""
from esphome.core import CORE
from esphome.log import setup_log
from esphome.platformio.runner import patch_registry_private_packages
signal.signal(signal.SIGTERM, _sigterm)
patch_registry_private_packages()
raw_level = os.environ.get("ESPHOME_PREFETCH_LOG_LEVEL")
try:
level = int(raw_level) if raw_level is not None else logging.INFO
+45 -22
View File
@@ -17,8 +17,10 @@ from esphome.framework_helpers import (
archive_extract_all,
download_from_mirrors,
download_with_resume,
downloaded_bytes,
rmdir,
run_batch_downloads,
wait_for_download_lock,
)
from esphome.net_retry import fetch_with_retry, http_request
@@ -164,11 +166,17 @@ class _PendingArchive(NamedTuple):
name: str
version: str
dest: Path
archive: Path
url: str
sha256: str
size: int
def _archive_path(downloads_dir: Path, name: str, version: str) -> Path:
"""The one archive path the prefetch and the sequential install share."""
return downloads_dir / f"{name}-{version}"
def _already_installed(dest: Path) -> bool:
"""Whether ``dest`` holds a completed install (extraction marker)."""
return (dest / ".esphome_extracted").is_file()
@@ -187,18 +195,18 @@ def prefetch_packages(
lock as ``install_package``: the archive's ``.part`` file is shared, and
two concurrent writers would truncate each other's bytes.
"""
from filelock import FileLock
from filelock import FileLock, Timeout
pending: list[_PendingArchive] = []
seen: set[str] = set()
seen: set[Path] = set()
for name, version, dest, mirrors in packages:
if mirrors or (dest / ".esphome_extracted").is_file():
continue
archive_name = f"{name}-{version}"
if archive_name in seen:
archive = _archive_path(downloads_dir, name, version)
if archive in seen:
# A duplicate entry would race itself between two workers
continue
seen.add(archive_name)
seen.add(archive)
try:
url, sha256, size = registry_download(name, version)
except EsphomeError as err:
@@ -207,10 +215,9 @@ def prefetch_packages(
continue
if not size:
continue
archive = downloads_dir / archive_name
if archive.is_file() and archive.stat().st_size == size:
continue
pending.append(_PendingArchive(name, version, dest, url, sha256, size))
pending.append(_PendingArchive(name, version, dest, archive, url, sha256, size))
if len(pending) < 2:
return
downloads_dir.mkdir(parents=True, exist_ok=True)
@@ -222,20 +229,36 @@ def prefetch_packages(
def _fetch(entry: _PendingArchive, tracker: Callable[[int], None]) -> None:
entry.dest.parent.mkdir(parents=True, exist_ok=True)
with FileLock(f"{entry.dest}.lock", fallback_to_soft=False):
# Marker re-check: a concurrent build may have installed (and
# deleted the archive of) this package while we waited;
# re-downloading would orphan a fresh copy in downloads_dir
# no branch: the thread tracer misses the skip edge; both
# arms of _already_installed are pinned directly
if not _already_installed(entry.dest): # pragma: no branch
download_with_resume(
entry.url,
downloads_dir / f"{entry.name}-{entry.version}",
sha256=entry.sha256,
size=entry.size,
progress=tracker,
)
def on_disk() -> int:
if done := downloaded_bytes(entry.archive, entry.size):
return done
# The holder deletes the archive once it has installed it
return entry.size if _already_installed(entry.dest) else 0
lock = FileLock(f"{entry.dest}.lock", fallback_to_soft=False)
try:
wait_for_download_lock(lock, tracker, on_disk, entry.name)
except Timeout:
# install_package waits on this same lock and verifies the
# holder's copy
_LOGGER.debug("Leaving %s to its current downloader", entry.name)
return
try:
if _already_installed(entry.dest):
# A concurrent build installed it while we waited; a
# re-download would orphan a fresh copy in downloads_dir
tracker(entry.size)
return
download_with_resume(
entry.url,
entry.archive,
sha256=entry.sha256,
size=entry.size,
progress=tracker,
)
finally:
lock.release()
failures = run_batch_downloads(
"Downloading packages",
@@ -288,7 +311,7 @@ def install_package(
rmdir(dest, msg=f"Clean up incomplete {name} install")
# Persistent location so an interrupted download resumes across runs.
downloads_dir.mkdir(parents=True, exist_ok=True)
archive = downloads_dir / f"{name}-{version}"
archive = _archive_path(downloads_dir, name, version)
_LOGGER.info("Downloading %s %s ...", name, version)
if mirrors:
_LOGGER.warning(
+13 -1
View File
@@ -2,7 +2,8 @@
Invoked via ``python -m esphome.platformio.runner`` instead of
``python -m platformio`` so that the patches (incremental rebuild
preservation, download retries) apply inside the subprocess. Running
preservation, download retries, skipping the private-package probe) apply
inside the subprocess. Running
PlatformIO in a subprocess keeps its ``sys.path`` mutations and other
global state from leaking into the ESPHome process.
"""
@@ -105,6 +106,16 @@ def patch_file_downloader() -> None:
FileDownloader.__init__ = patched_init
def patch_registry_private_packages() -> None:
"""Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup.
ESPHome never uses private packages, so the answer is always False.
"""
from platformio.registry.client import RegistryClient
RegistryClient.allowed_private_packages = staticmethod(lambda: False) # type: ignore[method-assign]
_IGNORE_LIB_WARNINGS = "(?:Hash|Update)"
# Regex patterns matched against each line of PlatformIO output. Lines that
# match are dropped by RedirectText before they reach the parent process.
@@ -152,6 +163,7 @@ FILTER_PLATFORMIO_LINES = [
def main() -> int:
patch_structhash()
patch_file_downloader()
patch_registry_private_packages()
# Wrap stdout/stderr with RedirectText before PlatformIO runs:
#
+4 -14
View File
@@ -148,11 +148,13 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str:
if "api_encryption_key" in kwargs:
config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n'
# Configure OTA
# The api key also secures OTA; a password only serves older uploaders
config += "\nota:\n"
config += " - platform: esphome\n"
if "ota_password" in kwargs:
config += f' password: "{kwargs["ota_password"]}"'
elif "api_encryption_key" in kwargs:
config += " encryption:"
# Configuring wifi
config += "\n\nwifi:\n"
@@ -529,20 +531,9 @@ def wizard(path: Path) -> int:
safe_print()
safe_print("You'll need this key when adding the device to Home Assistant.")
sleep(1)
safe_print()
safe_print(
f"Do you want to set a {color(AnsiFore.GREEN, 'password')} for OTA updates? "
"This can be insecure if you do not trust the WiFi network."
)
safe_print()
sleep(0.25)
safe_print("Press ENTER for no password")
ota_password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): "))
else:
ssid, psk = "", ""
api_encryption_key = None
ota_password = ""
kwargs = {
"path": path,
@@ -553,10 +544,9 @@ def wizard(path: Path) -> int:
"psk": psk,
"type": "basic",
}
# The api key also secures OTA updates, so the wizard sets no OTA password
if api_encryption_key:
kwargs["api_encryption_key"] = api_encryption_key
if ota_password:
kwargs["ota_password"] = ota_password
if not wizard_write(**kwargs):
return 1
+3 -3
View File
@@ -45,7 +45,7 @@ lib_deps_base =
lib_deps =
${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
esphome/noise-c@0.1.21 ; noise (api, ota)
esphome/noise-c@0.1.24 ; noise (api, ota)
improv/Improv@1.2.7 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -244,7 +244,7 @@ lib_deps =
${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard
esphome/noise-c@0.1.21 ; noise (api, ota)
esphome/noise-c@0.1.24 ; noise (api, ota)
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -641,7 +641,7 @@ build_unflags =
extends = common
platform = platformio/native
lib_deps =
esphome/noise-c@0.1.21 ; used by noise (api, ota)
esphome/noise-c@0.1.24 ; used by noise (api, ota)
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
+2 -2
View File
@@ -1,4 +1,4 @@
# Useful stuff when working in a development environment
clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating
clang-format==13.0.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==22.1.8
yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating
yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
+5 -4
View File
@@ -1,8 +1,9 @@
pylint==4.0.8
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.16.5 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
prek==0.5.1 # also change in .github/workflows/ci.yml when updating
flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
ruff==0.16.6 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
prek==0.5.2 # .github/workflows/ci.yml reads this pin
yamlrocks==0.6.1 # used by script/sync_dependency_versions.py
# Unit tests
pytest==9.1.1
+16 -1
View File
@@ -294,6 +294,9 @@ def highlight(s):
"esphome/components/socket/headers.h",
"esphome/core/defines.h",
"esphome/components/http_request/httplib.h",
# Shared C wire header (byte-identical with the co-processor firmware);
# these are protocol constants and constexpr is C++-only.
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
],
)
def lint_no_defines(fname, match):
@@ -816,6 +819,10 @@ def lint_relative_py_import(fname: Path, line, col, content):
"esphome/components/host/helpers.cpp",
"esphome/components/zephyr/helpers.cpp",
"esphome/components/http_request/httplib.h",
# Global extern "C" esp_now_* linker symbols + shared C wire header;
# neither can live in a C++ namespace.
"esphome/components/esp32_hosted/esp_now_hosted.cpp",
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
],
)
def lint_namespace(fname: Path, content: str) -> str | None:
@@ -841,7 +848,15 @@ def lint_esphome_h(fname, line, col, content):
)
@lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"])
@lint_content_check(
include=["*.h"],
exclude=[
"esphome/core/entity_types.h",
# Shared C wire header; uses a classic #ifndef guard for portability
# across the co-processor firmware repo it stays byte-identical with.
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
],
)
def lint_pragma_once(fname, content):
if "#pragma once" not in content:
return (
+25 -1
View File
@@ -14,7 +14,31 @@ top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
[ -x "$top/venv/bin/python" ] && exit 0
[ -x "$top/script/setup" ] || exit 0
# Every worktree shares the hooks directory of the checkout it was created
# from, and the script/setup run below is the one from whichever branch was just
# checked out. Older branches install their own pre-commit hook without checking
# for a worktree: that moves the shared hook aside as pre-commit.legacy and
# replaces it with one tied to this worktree's virtual environment, so commits
# break in every checkout. To rule that out, the hooks directory is copied
# before script/setup runs and put back exactly as it was afterwards, including
# removing any file script/setup added.
hooks=$(git rev-parse --path-format=absolute --git-path hooks 2>/dev/null) || exit 0
snap=$(mktemp -d "$hooks/.post-checkout.XXXXXX") || exit 0
cp -p "$hooks"/* "$snap"/ 2>/dev/null
# Clear VIRTUAL_ENV so a checkout made from a shell with an environment already
# activated still gets its own, rather than having the active one repointed at
# this working tree.
exec env -u VIRTUAL_ENV "$top/script/setup"
env -u VIRTUAL_ENV "$top/script/setup"
status=$?
for f in "$hooks"/*; do
[ -e "$snap/${f##*/}" ] || rm -f "$f"
done
# Files are moved rather than copied so a hook that is still running, such as
# this one, is swapped out atomically instead of being rewritten in place.
for f in "$snap"/*; do
cmp -s "$f" "$hooks/${f##*/}" 2>/dev/null || mv -f "$f" "$hooks/${f##*/}"
done
rm -rf "$snap"
exit $status
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Keep pre-commit hook revs in sync with the requirements files.
Dependabot only bumps the ``package==version`` pins in ``requirements*.txt``.
Some of those tools are pinned a second time as hook ``rev`` values in
``.pre-commit-config.yaml``. This script treats the requirements files as
the source of truth and rewrites the revs to match, editing the config
through yamlrocks so comments and layout survive.
Run without arguments to apply the changes in place, or with ``--check`` to
only report drift (exit status 1 when anything is out of sync).
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
import re
import sys
from typing import Any
import yamlrocks
REPO_ROOT = Path(__file__).resolve().parent.parent
PRECOMMIT_CONFIG = ".pre-commit-config.yaml"
class SyncError(Exception):
"""A pin could not be located in a requirements file or the config."""
@dataclass(frozen=True)
class SyncTarget:
"""A requirements pin and the pre-commit repo whose rev mirrors it."""
package: str
requirements_file: str
repo: str
SYNC_TARGETS: tuple[SyncTarget, ...] = (
SyncTarget(
"ruff", "requirements_test.txt", "https://github.com/astral-sh/ruff-pre-commit"
),
SyncTarget("flake8", "requirements_test.txt", "https://github.com/PyCQA/flake8"),
SyncTarget(
"pyupgrade", "requirements_test.txt", "https://github.com/asottile/pyupgrade"
),
SyncTarget(
"clang-format",
"requirements_dev.txt",
"https://github.com/pre-commit/mirrors-clang-format",
),
SyncTarget(
"yamllint",
"requirements_dev.txt",
"https://github.com/adrienverge/yamllint.git",
),
)
def read_requirement_version(requirements: str, package: str) -> str | None:
"""Return the ``==`` pin for ``package`` or None when it is not pinned."""
pattern = re.compile(
rf"^{re.escape(package)}==(?P<version>[^\s#]+)",
re.MULTILINE | re.IGNORECASE,
)
match = pattern.search(requirements)
return match.group("version") if match else None
def find_repo_entry(doc: Any, repo: str) -> Any:
"""Return the single ``- repo:`` block for ``repo`` in a pre-commit doc."""
try:
entries = [entry for entry in doc["repos"] if entry["repo"] == repo]
except KeyError as err:
raise SyncError(f"malformed pre-commit config, missing key {err}") from None
if len(entries) != 1:
raise SyncError(
f"expected exactly one block for repo {repo}, found {len(entries)}"
)
return entries[0]
def current_rev(entry: Any, repo: str) -> tuple[str, str]:
"""Split the block's rev into its tag prefix (``v`` or empty) and version."""
if "rev" not in entry:
raise SyncError(f"repo {repo} has no rev")
rev = entry["rev"]
if not isinstance(rev, str):
# A rev such as ``1.0`` parses as a number and cannot be compared or
# rewritten safely; quote it in the config instead.
raise SyncError(f"rev of repo {repo} is not a string: {rev!r}")
prefix = "v" if rev.startswith("v") else ""
return prefix, rev.removeprefix("v")
def sync(root: Path, *, write: bool) -> list[str]:
"""Bring every hook rev in line with its requirements pin.
Returns one description per rev that was (or, when ``write`` is False,
would be) changed. Raises SyncError when a pin cannot be found, which
means SYNC_TARGETS has gone stale and needs updating by hand.
"""
config_path = root / PRECOMMIT_CONFIG
doc = yamlrocks.loads(config_path.read_bytes(), option=yamlrocks.OPT_ROUND_TRIP)
requirements: dict[str, str] = {}
changes: list[str] = []
for target in SYNC_TARGETS:
if target.requirements_file not in requirements:
requirements[target.requirements_file] = (
root / target.requirements_file
).read_text()
version = read_requirement_version(
requirements[target.requirements_file], target.package
)
if version is None:
raise SyncError(
f"{target.requirements_file}: no '{target.package}==' pin found"
)
entry = find_repo_entry(doc, target.repo)
prefix, current = current_rev(entry, target.repo)
if current == version:
continue
changes.append(f"{target.package}: {current} -> {version}")
entry["rev"] = f"{prefix}{version}"
if changes and write:
config_path.write_bytes(doc.to_yaml())
return changes
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--check",
action="store_true",
help="report drift without modifying any file; exit 1 if out of sync",
)
parser.add_argument(
"--root",
type=Path,
default=REPO_ROOT,
help="repository checkout to operate on (default: this checkout)",
)
args = parser.parse_args(argv)
try:
changes = sync(args.root, write=not args.check)
except SyncError as err:
print(f"error: {err}", file=sys.stderr)
return 1
for change in changes:
print(change)
if args.check and changes:
return 1
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
@@ -0,0 +1,32 @@
esphome:
name: test-keyboard-no-label
esp32:
board: esp32dev
framework:
type: esp-idf
spi:
- id: spi_bus
clk_pin: GPIO18
mosi_pin: GPIO23
display:
- platform: mipi_spi
spi_id: spi_bus
model: st7789v
id: tft_display
dimensions:
width: 240
height: 320
cs_pin: GPIO22
dc_pin: GPIO21
auto_clear_enabled: false
invert_colors: false
update_interval: never
lvgl:
displays: tft_display
widgets:
- keyboard:
id: keyboard_widget
@@ -0,0 +1,34 @@
esphome:
name: test-qrcode-no-label
esp32:
board: esp32dev
framework:
type: esp-idf
spi:
- id: spi_bus
clk_pin: GPIO18
mosi_pin: GPIO23
display:
- platform: mipi_spi
spi_id: spi_bus
model: st7789v
id: tft_display
dimensions:
width: 240
height: 320
cs_pin: GPIO22
dc_pin: GPIO21
auto_clear_enabled: false
invert_colors: false
update_interval: never
lvgl:
displays: tft_display
widgets:
- qrcode:
id: qr_widget
size: 100
text: "esphome.io"
@@ -0,0 +1,35 @@
esphome:
name: test-tabview-no-label
esp32:
board: esp32dev
framework:
type: esp-idf
spi:
- id: spi_bus
clk_pin: GPIO18
mosi_pin: GPIO23
display:
- platform: mipi_spi
spi_id: spi_bus
model: st7789v
id: tft_display
dimensions:
width: 240
height: 320
cs_pin: GPIO22
dc_pin: GPIO21
auto_clear_enabled: false
invert_colors: false
update_interval: never
lvgl:
displays: tft_display
widgets:
- tabview:
id: tabview_widget
tabs:
- name: "Tab 1"
id: tab_1
@@ -0,0 +1,32 @@
"""Widgets whose LVGL C implementation creates or references labels
internally (tab titles, key legends, the QR canvas fallback) must declare
the label dependency in ``get_uses()``. Otherwise a config that contains
no ``label`` widget of its own compiles LVGL without ``LV_USE_LABEL`` and
fails at C compile time with undefined ``lv_label_*`` symbols.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.lvgl import defines as df
@pytest.mark.parametrize(
"yaml_file",
[
"qrcode_no_label.yaml",
"keyboard_no_label.yaml",
"tabview_no_label.yaml",
],
)
def test_label_less_config_enables_lv_use_label(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
yaml_file: str,
) -> None:
generate_main(component_config_path(yaml_file))
assert "LV_USE_LABEL" in df.get_defines()
@@ -5,11 +5,7 @@ from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.components.noise import (
decode_encryption_key,
is_reserved_key,
validate_encryption_key,
)
from esphome.components.noise import decode_encryption_key, validate_encryption_key
KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
@@ -41,6 +37,8 @@ def test_decode_encryption_key_rejects_short_decode() -> None:
decode_encryption_key("AAECAw==")
def test_is_reserved_key() -> None:
assert is_reserved_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
assert not is_reserved_key(KEY)
def test_validate_encryption_key_rejects_all_zeros() -> None:
"""The all-zeros key is the provisioning sentinel the device treats as no
key, so it never reaches a build."""
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
validate_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
+189 -53
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
import logging
from typing import Any
@@ -14,6 +15,7 @@ from esphome.components.esphome.ota import (
_validate_no_password_with_encryption,
ota_esphome_final_validate,
)
from esphome.components.noise import static_encryption_key
from esphome.const import (
CONF_API,
CONF_ENCRYPTION,
@@ -115,7 +117,6 @@ def test_non_esphome_ota_unaffected() -> None:
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
OTHER_KEY = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
ZEROS_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
def test_encryption_key_inherited_from_api() -> None:
@@ -197,36 +198,6 @@ def test_encryption_without_any_key_rejected() -> None:
fv.full_config.reset(token)
def test_encryption_explicit_all_zeros_key_rejected() -> None:
"""The all-zeros key is the provisioning sentinel; the device would treat
it as no PSK and accept plaintext, so it must fail validation."""
full_conf = {
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}})
],
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
def test_encryption_inherited_all_zeros_key_rejected() -> None:
"""An all-zeros api key must not silently disable ota encryption either."""
full_conf = {
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}},
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
def test_encryption_key_mismatch_between_merged_configs_rejected() -> None:
"""Same-port configs with different encryption keys raise."""
full_conf = {
@@ -295,13 +266,14 @@ def test_encryption_explicit_key_with_runtime_provisioned_api_accepted() -> None
fv.full_config.reset(token)
@pytest.mark.parametrize("component", ["web_server", "prometheus"])
def test_encryption_with_web_server_ota_warns(
caplog: pytest.LogCaptureFixture,
caplog: pytest.LogCaptureFixture, component: str
) -> None:
"""With the web_server component the plaintext /update endpoint is always
on; the combination validates with a warning."""
"""web_server and prometheus keep the shared listener up, so the
plaintext /update endpoint is always on and the combination warns."""
full_conf = {
"web_server": {},
component: {},
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
@@ -316,12 +288,12 @@ def test_encryption_with_web_server_ota_warns(
fv.full_config.reset(token)
def test_encryption_with_captive_portal_web_server_ota_warns(
def test_encryption_with_captive_portal_does_not_warn(
caplog: pytest.LogCaptureFixture,
) -> None:
"""captive_portal auto-loads the web_server ota platform without the
web_server component; encryption stays usable and only warns, so the
fallback AP recovery path is not lost."""
web_server component; its endpoint only exists while the fallback AP is
active and is the intended recovery path, so there is no warning."""
full_conf = {
"captive_portal": {},
CONF_OTA: [
@@ -333,7 +305,10 @@ def test_encryption_with_captive_portal_web_server_ota_warns(
try:
with caplog.at_level(logging.WARNING):
ota_esphome_final_validate({})
assert any("captive_portal" in record.message for record in caplog.records)
assert not any(
"OTA encryption does not cover" in record.message
for record in caplog.records
)
esphome_conf = next(
conf
for conf in fv.full_config.get()[CONF_OTA]
@@ -344,6 +319,100 @@ def test_encryption_with_captive_portal_web_server_ota_warns(
fv.full_config.reset(token)
def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None:
"""A static api key makes the device offer encryption and the CLI take
it, so the password is dead weight; the config validates with a warning."""
full_conf = {
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}},
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
}
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
ota_esphome_final_validate({})
assert any("wastes significant flash" in r.message for r in caplog.records)
finally:
fv.full_config.reset(token)
def test_password_with_runtime_api_key_warns_differently(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The CLI still needs the password, but the provisioned key also
authenticates uploads; the warning says so without the flash advice."""
full_conf = {
CONF_API: {CONF_ENCRYPTION: {}},
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
}
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
ota_esphome_final_validate({})
messages = [r.message for r in caplog.records]
assert any("provisioned at runtime also authenticates" in m for m in messages)
assert not any("wastes significant flash" in m for m in messages)
finally:
fv.full_config.reset(token)
def test_password_without_api_key_no_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Without an api key there is no offer, so nothing to warn about."""
full_conf = {
CONF_API: {},
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
}
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
ota_esphome_final_validate({})
assert not any("authenticates" in r.message for r in caplog.records)
finally:
fv.full_config.reset(token)
def test_web_server_component_without_ota_platform_does_not_warn(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The web_server component alone has no /update endpoint."""
full_conf = {
"web_server": {},
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
],
}
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
ota_esphome_final_validate({})
assert not any(
"OTA encryption does not cover" in r.message for r in caplog.records
)
finally:
fv.full_config.reset(token)
def test_web_server_ota_platform_alone_does_not_warn(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Only the web_server component starts the shared listener, so the ota
platform on its own never exposes /update."""
full_conf = {
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
],
}
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
ota_esphome_final_validate({})
assert not any("plaintext /update" in r.message for r in caplog.records)
finally:
fv.full_config.reset(token)
def test_web_server_ota_without_encryption_unaffected() -> None:
"""web_server ota stays valid alongside an unencrypted esphome entry."""
full_conf = {
@@ -370,20 +439,87 @@ def test_auto_load_pulls_noise_only_for_encryption() -> None:
assert "noise" in AUTO_LOAD({})
def test_filter_source_files_excludes_noise_without_encryption() -> None:
"""The noise transport source compiles only for encrypted builds."""
old_config = CORE.config
try:
CORE.config = {CONF_OTA: [_make_ota_config(port=3232)]}
assert FILTER_SOURCE_FILES() == ["ota_esphome_noise.cpp"]
CORE.config = {
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}})
]
}
assert FILTER_SOURCE_FILES() == []
finally:
CORE.config = old_config
def test_static_encryption_key() -> None:
"""Only a build-time key counts; a runtime provisioned one does not."""
assert static_encryption_key({}) is None
assert static_encryption_key({CONF_ENCRYPTION: {}}) is None
assert static_encryption_key({CONF_ENCRYPTION: {CONF_KEY: API_KEY}}) == API_KEY
@pytest.mark.parametrize(
("yaml_name", "defines_present", "defines_absent"),
[
# An api key alone compiles the transport in without requiring it;
# the device uses the api server's key, not a copy
(
"api_key_offer",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"},
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
),
# A password still guards plaintext uploads on an offering device
(
"api_key_offer_password",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"},
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
),
# The ota encryption block is what makes the device refuse plaintext
(
"encryption_required",
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_REQUIRED",
"USE_OTA_ENCRYPTION_FROM_API",
},
{"USE_OTA_ENCRYPTION_PROVISIONED"},
),
# Without api encryption the ota key is the device's own
(
"own_key",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
{"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"},
),
# A key provisioned at runtime lives in the api server; the device
# offers with it once provisioned and never requires it
(
"runtime_api_key",
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_FROM_API",
"USE_OTA_ENCRYPTION_PROVISIONED",
},
{"USE_OTA_ENCRYPTION_REQUIRED"},
),
# No api encryption at all keeps the noise glue out of the build
(
"plain",
set(),
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_REQUIRED",
"USE_OTA_ENCRYPTION_FROM_API",
"USE_OTA_ENCRYPTION_PROVISIONED",
},
),
],
)
def test_encryption_offer_codegen(
generate_main: Callable[[str], str],
yaml_name: str,
defines_present: set[str],
defines_absent: set[str],
) -> None:
main_cpp = generate_main(
f"tests/component_tests/ota/test_esphome_ota_{yaml_name}.yaml"
)
defines = {define.name for define in CORE.defines}
assert defines_present <= defines
assert not (defines_absent & defines)
encrypted = "USE_OTA_ENCRYPTION" in defines_present
own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present
assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key
assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present)
# The noise transport source compiles only when the define is set
assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"])
def test_password_with_encryption_rejected() -> None:
@@ -0,0 +1,11 @@
esphome:
name: ota-offer
host:
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
@@ -0,0 +1,12 @@
esphome:
name: ota-offer-password
host:
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
password: "superlongpasswordthatnoonewillknow"
@@ -0,0 +1,12 @@
esphome:
name: ota-encryption-required
host:
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
encryption:
@@ -0,0 +1,11 @@
esphome:
name: ota-own-key
host:
api:
ota:
- platform: esphome
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
@@ -0,0 +1,9 @@
esphome:
name: ota-plain
host:
api:
ota:
- platform: esphome
@@ -0,0 +1,10 @@
esphome:
name: ota-runtime-key
host:
api:
encryption:
ota:
- platform: esphome
@@ -0,0 +1,145 @@
"""Tests for template climate config validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.template.climate import (
CONF_SET_TARGET_HUMIDITY_ACTION,
CONF_SET_TARGET_TEMPERATURE_ACTION,
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
CONF_SUPPORTS_CURRENT_HUMIDITY,
CONF_SUPPORTS_CURRENT_TEMPERATURE,
CONF_SUPPORTS_TARGET_HUMIDITY,
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
CONF_TARGET_HUMIDITY,
_resolve_supports,
_validate_initial_state,
_validate_set_actions,
)
from esphome.const import (
CONF_HUMIDITY_SENSOR,
CONF_INITIAL_STATE,
CONF_SENSOR,
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_HIGH,
CONF_TARGET_TEMPERATURE_LOW,
)
from esphome.types import ConfigType
def test_supports_current_temperature_derived_from_sensor() -> None:
config: ConfigType = {CONF_SENSOR: "some_sensor"}
assert _resolve_supports(config)[CONF_SUPPORTS_CURRENT_TEMPERATURE] is True
def test_supports_current_temperature_false_without_sensor() -> None:
assert _resolve_supports({})[CONF_SUPPORTS_CURRENT_TEMPERATURE] is False
def test_supports_current_temperature_explicit_true_without_sensor_allowed() -> None:
# The value can still be reported with climate.template.publish.
config: ConfigType = {CONF_SUPPORTS_CURRENT_TEMPERATURE: True}
assert _resolve_supports(config)[CONF_SUPPORTS_CURRENT_TEMPERATURE] is True
def test_supports_current_temperature_false_with_sensor_rejected() -> None:
config: ConfigType = {
CONF_SENSOR: "some_sensor",
CONF_SUPPORTS_CURRENT_TEMPERATURE: False,
}
with pytest.raises(cv.Invalid, match="cannot be false"):
_resolve_supports(config)
def test_supports_current_humidity_false_with_sensor_rejected() -> None:
config: ConfigType = {
CONF_HUMIDITY_SENSOR: "some_sensor",
CONF_SUPPORTS_CURRENT_HUMIDITY: False,
}
with pytest.raises(cv.Invalid, match="cannot be false"):
_resolve_supports(config)
def test_two_point_derived_from_set_actions() -> None:
config: ConfigType = {
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION: [{}],
}
assert _resolve_supports(config)[CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE] is True
def test_two_point_false_with_set_action_rejected() -> None:
config: ConfigType = {
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
}
with pytest.raises(cv.Invalid, match="cannot be false"):
_resolve_supports(config)
def test_target_humidity_derived_from_set_action() -> None:
config: ConfigType = {CONF_SET_TARGET_HUMIDITY_ACTION: [{}]}
assert _resolve_supports(config)[CONF_SUPPORTS_TARGET_HUMIDITY] is True
def test_set_target_temperature_low_requires_high() -> None:
config: ConfigType = {CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}]}
with pytest.raises(cv.Invalid, match="must be used together"):
_validate_set_actions(config)
def test_set_target_temperature_conflicts_with_two_point_actions() -> None:
config: ConfigType = {
CONF_SET_TARGET_TEMPERATURE_ACTION: [{}],
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION: [{}],
}
with pytest.raises(cv.Invalid, match="cannot be used together"):
_validate_set_actions(config)
def test_initial_state_target_temperature_rejected_with_two_point() -> None:
config: ConfigType = {
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: True,
CONF_SUPPORTS_TARGET_HUMIDITY: False,
CONF_INITIAL_STATE: {CONF_TARGET_TEMPERATURE: 21.0},
}
with pytest.raises(cv.Invalid, match="is not available"):
_validate_initial_state(config)
def test_initial_state_two_point_values_rejected_without_two_point() -> None:
config: ConfigType = {
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
CONF_SUPPORTS_TARGET_HUMIDITY: False,
CONF_INITIAL_STATE: {
CONF_TARGET_TEMPERATURE_LOW: 18.0,
CONF_TARGET_TEMPERATURE_HIGH: 24.0,
},
}
with pytest.raises(cv.Invalid, match="requires"):
_validate_initial_state(config)
def test_initial_state_target_humidity_rejected_without_support() -> None:
config: ConfigType = {
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
CONF_SUPPORTS_TARGET_HUMIDITY: False,
CONF_INITIAL_STATE: {CONF_TARGET_HUMIDITY: 50},
}
with pytest.raises(cv.Invalid, match="requires"):
_validate_initial_state(config)
def test_initial_state_matching_two_point_accepted() -> None:
config: ConfigType = {
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: True,
CONF_SUPPORTS_TARGET_HUMIDITY: True,
CONF_INITIAL_STATE: {
CONF_TARGET_TEMPERATURE_LOW: 18.0,
CONF_TARGET_TEMPERATURE_HIGH: 24.0,
CONF_TARGET_HUMIDITY: 50,
},
}
assert _validate_initial_state(config) is config
+5
View File
@@ -0,0 +1,5 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.dependencies = manifest.dependencies + ["sensor", "spi"]
@@ -0,0 +1,62 @@
#include <gtest/gtest.h>
#include "esphome/components/atm90e32/atm90e32.h"
namespace esphome::atm90e32::testing {
TEST(ATM90E32OffsetRegisterVerification, AcceptsExactSignedReadback) {
EXPECT_TRUE(offset_register_value_matches(0x007B, 123));
EXPECT_TRUE(offset_register_value_matches(0xFF85, -123));
}
TEST(ATM90E32OffsetRegisterVerification, RejectsMismatchedReadback) {
EXPECT_FALSE(offset_register_value_matches(0x007C, 123));
EXPECT_FALSE(offset_register_value_matches(0xFF84, -123));
}
TEST(ATM90E32OffsetRestoreState, ReportsVerifiedStoredValuesAsRestored) {
const auto state = resolve_offset_restore_state(true, true, false);
EXPECT_TRUE(state.restored);
EXPECT_TRUE(state.values_verified);
}
TEST(ATM90E32OffsetRestoreState, ReportsVerifiedConfigFallbackAsNotRestored) {
const auto state = resolve_offset_restore_state(true, false, true);
EXPECT_FALSE(state.restored);
EXPECT_TRUE(state.values_verified);
}
TEST(ATM90E32OffsetRestoreState, ReportsFailedConfigFallbackAsUnverified) {
const auto state = resolve_offset_restore_state(true, false, false);
EXPECT_FALSE(state.restored);
EXPECT_FALSE(state.values_verified);
}
TEST(ATM90E32OffsetRestoreState, ReportsConfigWithoutStoredValuesAsNotRestored) {
const auto state = resolve_offset_restore_state(false, true, false);
EXPECT_FALSE(state.restored);
EXPECT_TRUE(state.values_verified);
}
TEST(ATM90E32OffsetPersistence, RollsBackStoredValuesOrZeroSentinel) {
const OffsetCalibration previous[3]{{1, -1}, {2, -2}, {3, -3}};
OffsetCalibration rollback[3]{};
prepare_offset_rollback(previous, true, rollback);
for (uint8_t phase = 0; phase < 3; phase++) {
EXPECT_EQ(rollback[phase].first_offset, previous[phase].first_offset);
EXPECT_EQ(rollback[phase].second_offset, previous[phase].second_offset);
}
prepare_offset_rollback(previous, false, rollback);
for (const auto &phase : rollback) {
EXPECT_EQ(phase.first_offset, 0);
EXPECT_EQ(phase.second_offset, 0);
}
}
} // namespace esphome::atm90e32::testing
+1 -2
View File
@@ -30,8 +30,7 @@ climate:
- switch.turn_on: climate_heater_switch
- switch.turn_off: climate_cooler_switch
# Thermostat-based climate so climate.control: action variants get build
# coverage (bang_bang doesn't support fan modes, presets, etc.). Climate
# has no template platform, so thermostat is the right vehicle.
# coverage (bang_bang doesn't support fan modes, presets, etc.).
- platform: thermostat
id: climate_test_thermostat
name: Test Thermostat
@@ -0,0 +1,5 @@
# Exercises the ESP-NOW-over-hosted shim: on the ESP32-P4 host, esp32_hosted
# supplies the esp_now_* symbols that the espnow component links against.
packages:
esp32_hosted: !include common.yaml
espnow: !include ../espnow/common.yaml
@@ -68,6 +68,14 @@ class Initiator {
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
// The context only points at the key and init() copies it before returning,
// so a temporary context over a temporary key is safe within one call
static NoiseContext ctx_for(const psk_t &psk) {
NoiseContext ctx;
ctx.set_psk(psk.data());
return ctx;
}
static psk_t make_psk(uint8_t seed) {
psk_t psk;
for (size_t i = 0; i < psk.size(); i++) {
@@ -102,7 +110,7 @@ TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) {
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
@@ -155,8 +163,8 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
// proves the restart took effect; the old state surviving would fail the
// MAC here.
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(ctx_for(make_psk(9)), PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE));
@@ -168,7 +176,7 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0);
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
@@ -185,7 +193,7 @@ TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) {
// tampered preamble must fail even with the right key.
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
static const uint8_t TAMPERED[] = {'x'};
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
@@ -17,12 +17,17 @@ TEST(NoiseContextTest, AllZerosPskIsReserved) {
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
NoiseContext ctx;
psk_t loaded;
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(zeros);
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(psk);
ctx.load_psk(loaded);
EXPECT_EQ(loaded, zeros);
ctx.set_psk(psk.data());
EXPECT_TRUE(ctx.has_psk());
EXPECT_EQ(ctx.get_psk(), psk);
ctx.load_psk(loaded);
EXPECT_EQ(loaded, psk);
// Callers map the reserved key to nullptr; the context just stores what it is given
ctx.set_psk(nullptr);
EXPECT_FALSE(ctx.has_psk());
}
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
+12
View File
@@ -0,0 +1,12 @@
wifi:
ssid: MySSID
password: password1
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
port: 3290
password: "superlongpasswordthatnoonewillknow"
+10
View File
@@ -0,0 +1,10 @@
wifi:
ssid: MySSID
password: password1
api:
encryption:
ota:
- platform: esphome
port: 3291
@@ -0,0 +1,2 @@
packages:
ota: !include api_key_offer.yaml

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