Compare commits

...
Author SHA1 Message Date
J. Nick Koston f9861753fb Add a host integration test for deflate OTA uploads 2026-09-08 23:58:56 +02:00
J. Nick Koston fbb29a2e76 Ack after every flush and report the sizes on an inflate mismatch 2026-09-08 23:45:54 +02:00
J. Nick Koston e050d58875 Merge remote-tracking branch 'origin/dev' into ota-deflate
# Conflicts:
#	esphome/components/esphome/ota/ota_esphome.cpp
2026-09-08 23:27:14 +02:00
J. Nick Koston 6c5ab89d5f [esphome][core] Give a lost OTA chunk ack time to be retransmitted (#19041) 2026-09-09 09:08:16 +12:00
J. Nick Koston 8f511a365a [noise] Bump noise-c to 0.1.26 and libsodium to 1.10021.8 (#19030) 2026-09-09 09:07:06 +12:00
Kevin Ahrendt 006f31af93 [i2s_audio] Fix spurious driver failure (#19045) 2026-09-09 09:05:02 +12:00
Kevin Ahrendt 5bb112f407 [audio][i2s_audio][micro_wake_word][microphone][mixer][resampler][speaker] Replace use_count() checks with lock and null test (#19046) 2026-09-09 09:04:34 +12:00
dependabot[bot] 4ab9298ab3 Bump esptool from 5.3.1 to 5.4.0 (#19023) 2026-09-08 22:11:36 +02:00
Kevin AhrendtandCopilot Autofix powered by AI 3926612281 [core] Fix use-after-free when deleting a running StaticTask (#19048)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-09 07:46:17 +12:00
J. Nick Koston 1ce0bed3f6 [core] Share compiled binaries across modbus integration tests (#18945) 2026-09-08 18:10:45 +02:00
J. Nick Koston 472b5de30e Zero the inflate window so a corrupt back reference copies zeros, not stale memory 2026-09-08 18:00:28 +02:00
J. Nick Koston bee0dd2926 Reserve the noise session before the inflate buffer so encryption is not starved
(cherry picked from commit 5a0742c1b171feb89492e842e61f1d888c76adbc)
2026-09-08 16:41:56 +02:00
esphome[bot] f91486305f Bump bundled esphome-device-builder to 1.14.5 (#19040) 2026-09-08 15:21:48 +02:00
J. Nick Koston 157294a99c Expose enable_deflate() so the inflater can be opted out of later 2026-09-08 14:14:42 +02:00
J. Nick Koston 6146e625dd Gate RP2 gzip on the core version, log an inflate overrun, document the window aliasing 2026-09-08 14:12:09 +02:00
J. Nick Koston 8e9e6bbdd7 Trim comments 2026-09-08 13:55:58 +02:00
J. Nick Koston 773050d7c9 Simplify pass: one error channel in the inflate loop, whole-window flushes where acks go out on receipt, shared log helpers, one backend alias, leaner tests 2026-09-08 13:48:58 +02:00
J. Nick Koston 87dc5014a8 Size the distance tree to its alphabet 2026-09-08 13:43:50 +02:00
J. Nick Koston 49f9eaacb1 Honour a deflate offer over gzip on the client and record how the test vectors are made 2026-09-08 13:28:12 +02:00
J. Nick Koston cd4e07f2d0 Report every inflate failure from one site and build the fixed trees through the tree builder 2026-09-08 13:14:15 +02:00
J. Nick Koston 7e1216fe01 Note that the deflate offer is binding on both sides 2026-09-08 13:09:49 +02:00
J. Nick Koston 8027a37d61 Do not mark the ack sender inline, the header wrappers use it from every unit 2026-09-08 13:00:30 +02:00
J. Nick Koston 76e1df2c03 Ack chunks on receipt where the socket stack has its own task
With raw lwIP in the main loop the radio is deaf while a sector is written,
so the ESP8266 and RP2040 keep the client quiet until the block is in flash;
with BSD or lwIP sockets the next block can arrive while this one is written.
2026-09-08 12:54:30 +02:00
J. Nick Koston 66a0485a5c Merge branch 'dev' into ota-deflate 2026-09-08 12:24:06 +02:00
J. Nick Koston 3a83323c75 Express the constant expression requirement without a redundant literal 2026-09-08 11:31:41 +02:00
J. Nick Koston 817fcb7881 Require the backend compression capability to be a constant expression in the contract 2026-09-08 11:27:40 +02:00
J. Nick Koston 08c7e3e9aa Unit test the decoder under the sanitizers with generated vectors and corrupted streams 2026-09-08 11:23:16 +02:00
J. Nick Koston 5b5b5e3cc1 Reject a negative literal symbol, stop on eof in stored blocks, compile the decoder on LibreTiny in CI 2026-09-08 11:20:05 +02:00
J. Nick Koston eefddf85f1 Call the constexpr backend capability through the type 2026-09-08 11:09:29 +02:00
J. Nick Koston d1665c423d Ack chunks after the flash write on the inflate path and harden the decoder
Chunk acks again mean the block is in flash on both paths: the read callback
writes and acks everything decoded so far before it waits for more input.
The decoder rejects a negative distance symbol, initialises its whole state,
and the flush feeds the watchdog once per window. The static_assert on the
backend skips the static analysis build, which sets every define at once.
2026-09-08 10:54:18 +02:00
J. Nick Koston db55c1d43f Trim the inflate glue and tie the deflate gate to the backend
Measured on ESP32: the decoder is 1472 B at -Os and cannot shrink without
dropping dynamic Huffman; the glue loses its extra log sites and strings,
the flash-write log is shared, and the single-call helpers inline on the
platforms without the decoder so the ESP8266 and RP2040 images do not grow.
supports_compression() is constexpr so the deflate build asserts that its
backend cannot store gzip.
2026-09-08 10:27:20 +02:00
J. Nick Koston 27a8768986 Use clang-tidy style names in the decoder header and name the wire constants 2026-09-08 10:16:11 +02:00
J. Nick Koston a0398d120c [ota] Let the RP2040 stage a gzip image and inflate it at reboot
The Arduino Pico OTA stub already detects a gzip firmware.bin on LittleFS,
reads the inflated length from the trailer and inflates it into the app
region at reboot, so the RP2040 takes the ESP8266 path and no longer needs
the on-the-fly decoder.
2026-09-08 10:11:21 +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
J. Nick Koston deb63ea092 [esphome.ota] Compress OTA uploads with deflate on platforms without gzip support
ESP32, RP2040, LibreTiny and host could not receive a compressed image;
only the ESP8266 can, because its bootloader inflates a gzip file at reboot.
This inflates a raw deflate stream on the fly through a 4 KB ring window that
also serves as the output buffer, so the device never holds the whole image.

The CLI offers a new client feature bit; a device whose backend has no gzip
support and has the inflater compiled answers with a new server bit once the
session memory is in hand, then the CLI sends a 4 KB window deflate stream and
the MD5 of the inflated image. On allocation failure the device declines the
bit and the upload stays uncompressed. Old CLIs and old devices never set the
bits, so both directions stay compatible; the ESP8266 keeps its gzip path and
the CLI prefers gzip when a device offers both.

The decoder is uzlib's tinflate.c (zlib licence) trimmed to raw deflate.
2026-09-08 09:59:04 +02: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
107 changed files with 3703 additions and 1590 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)$
+1 -1
View File
@@ -840,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:
+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.4
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5
RUN \
platformio settings set enable_telemetry No \
+3 -4
View File
@@ -1289,10 +1289,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
The native API uses challenge-response auth with MD5/SHA256 hashing of a
server-issued nonce, so the password is never sent over the wire; the
``web_server`` path uses HTTP Basic auth which transmits credentials in
cleartext over the LAN. (The native path also supports gzip compression
on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the
backend reports ``supports_compression() == false`` and the firmware is
sent uncompressed regardless of which platform is used.) Falls back to
cleartext over the LAN. (The native path also compresses the upload:
gzip on ESP8266 and RP2040, which inflate it at reboot, and a deflate
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
``web_server`` only when that is the only available platform.
"""
# Use a dict (insertion-ordered) instead of a list so error messages and
+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
+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_ =
@@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
if (current_audio_file_ != nullptr) {
// A transfer buffer isn't ncessary for a local file
this->file_ring_buffer_ = output_ring_buffer.lock();
if (this->file_ring_buffer_ == nullptr) {
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
}
@@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le
void AudioTransferBuffer::clear_buffered_data() {
this->buffer_length_ = 0;
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
this->ring_buffer_->reset();
}
}
void AudioSinkTransferBuffer::clear_buffered_data() {
this->buffer_length_ = 0;
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
this->ring_buffer_->reset();
}
#ifdef USE_SPEAKER
@@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() {
}
bool AudioTransferBuffer::has_buffered_data() const {
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
}
return (this->available() > 0);
@@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_
size_t bytes_to_read = AudioTransferBuffer::free();
size_t bytes_read = 0;
if (bytes_to_read > 0) {
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
}
@@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait,
bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait);
} else
#endif
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
bytes_written =
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
} else if (this->sink_callback_ != nullptr) {
@@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const {
return (this->speaker_->has_buffered_data() || (this->available() > 0));
}
#endif
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
}
return (this->available() > 0);
+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) {
@@ -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"
+13 -1
View File
@@ -283,10 +283,18 @@ FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"}
{
"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION",
"ota_esphome_inflate.c": "USE_OTA_DEFLATE",
}
)
def enable_deflate() -> None:
"""Compile the on-the-fly inflater for compressed uploads."""
cg.add_define("USE_OTA_DEFLATE")
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
@@ -305,6 +313,10 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_ALLOW_PARTITION_ACCESS):
cg.add_define("USE_OTA_PARTITIONS")
# ESP8266 and RP2040 inflate gzip at reboot; the rest inflate on the fly
if not (CORE.is_esp8266 or CORE.is_rp2):
enable_deflate()
# One key per device: an api encryption block supplies it (static or
# runtime) and offers; the ota block only adds the requirement
api_conf = CORE.config.get(CONF_API) or {}
+228 -81
View File
@@ -24,6 +24,8 @@
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <new>
#include <sys/time.h>
namespace esphome {
@@ -41,7 +43,12 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
#endif
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
// Milliseconds for data transfer. Covers the lwIP retransmit run seen in
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
static constexpr uint32_t OTA_PROGRESS_INTERVAL_MS = 1000;
static constexpr size_t OTA_SIZE_FIELD_BYTES = 4; // sizes on the wire are 4 bytes MSB first
// Single-instance pointer — multi-port configs are rejected in final_validate.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
@@ -179,12 +186,16 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10;
// Noise needs the extended protocol: the prologue binds the 2-byte feature ack
static constexpr uint8_t CLIENT_NOISE_FEATURES =
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
// Raw deflate, window <= OTA_INFLATE_WINDOW_SIZE. Binding once offered: the
// client must then send the image size frame and a deflate stream.
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08;
inline bool ESPHomeOTAComponent::extended_proto_() const {
#ifdef USE_OTA_ENCRYPTION_REQUIRED
@@ -290,7 +301,7 @@ void ESPHomeOTAComponent::handle_handshake_() {
this->transition_ota_state_(OTAState::FEATURE_ACK);
const bool supports_compression =
(this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression();
(this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && ota::OTABackend::supports_compression();
// Compose the feature-ack response. When the client negotiates the extended protocol we emit
// a 2-byte response (marker + server feature flags); otherwise we emit the single-byte
@@ -310,6 +321,27 @@ void ESPHomeOTAComponent::handle_handshake_() {
#elif defined(USE_OTA_ENCRYPTION)
// A yaml key always exists: validation rejects the all-zeros key
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
#endif
#ifdef USE_OTA_ENCRYPTION
// Reserve the noise session before the optional inflate buffer, so the
// required allocation is not starved by the compression window. Gated
// on the same condition that starts the session in FEATURE_ACK.
if ((this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 &&
(this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession);
}
#endif
#ifdef USE_OTA_DEFLATE
// Offered only once the session memory is in hand; else uncompressed
if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_DEFLATE) != 0) {
this->inflate_.reset(new (std::nothrow) InflateSession());
if (this->inflate_ != nullptr) {
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_DEFLATE;
} else {
ESP_LOGW(TAG, "No memory to inflate");
}
}
#endif
} else {
this->handshake_buf_[0] =
@@ -427,16 +459,11 @@ void ESPHomeOTAComponent::handle_data_() {
// Backend calls overwrite this with OK; reset to UNKNOWN before any
// goto error that follows a successful begin()/write()
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
size_t total = 0;
uint32_t last_progress = 0;
uint32_t last_data_ms = 0;
DataTransfer xfer;
uint8_t buf[OTA_BUFFER_SIZE];
char *sbuf = reinterpret_cast<char *>(buf);
size_t ota_size;
size_t image_size;
ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP;
#if USE_OTA_VERSION == 2
size_t size_acknowledged = 0;
#endif
// Set socket timeouts and blocking mode (see strategy table above)
struct timeval tv;
@@ -459,14 +486,13 @@ void ESPHomeOTAComponent::handle_data_() {
}
ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type);
// Read size, 4 bytes MSB first
if (!this->data_readall_(buf, 4)) {
this->log_read_error_(LOG_STR("size"));
if (!this->read_size_(buf, xfer.ota_size, LOG_STR("size")))
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
(static_cast<size_t>(buf[2]) << 8) | buf[3];
ESP_LOGV(TAG, "Size is %zu bytes", ota_size);
image_size = xfer.ota_size;
#ifdef USE_OTA_DEFLATE
if (this->inflate_ != nullptr && !this->read_size_(buf, image_size, LOG_STR("image size")))
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
#endif
#ifndef USE_OTA_PARTITIONS
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
@@ -486,7 +512,7 @@ void ESPHomeOTAComponent::handle_data_() {
#endif
// begin() returns quickly; flash sectors are erased incrementally during write().
error_code = this->backend_->begin(ota_size, ota_type);
error_code = this->backend_->begin(image_size, ota_type);
if (error_code != ota::OTA_RESPONSE_OK)
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
@@ -506,75 +532,25 @@ void ESPHomeOTAComponent::handle_data_() {
// Acknowledge MD5 OK - 1 byte
this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
// Track when we last received data so a silently-vanished peer (no FIN/RST
// delivered, e.g. uploader killed mid-transfer or NAT/router dropped state)
// can't wedge the device indefinitely. Without this, the loop only exits
// on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP
// TCP keepalive isn't enabled here.
last_data_ms = millis();
while (total < ota_size) {
if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) {
ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA);
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
xfer.last_data_ms = millis();
#ifdef USE_OTA_DEFLATE
if (this->inflate_ != nullptr) {
error_code = this->inflate_data_(buf, image_size, xfer);
if (error_code != ota::OTA_RESPONSE_OK)
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
size_t remaining = ota_size - total;
size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE;
ssize_t read;
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ != nullptr) {
// One frame per call; noise_read_data_ waits internally (readall_), so
// there is no would-block retry here and failures are already logged.
read = this->noise_read_data_(buf, requested);
if (read <= 0) {
} else
#endif
{
while (xfer.total < xfer.ota_size) {
ssize_t read = this->receive_data_(buf, xfer);
if (read < 0) {
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
} else
#endif
{
read = this->client_->read(buf, requested);
if (read == -1) {
const int err = errno;
if (this->would_block_(err)) {
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
App.feed_wdt();
continue;
}
ESP_LOGW(TAG, "Read err %d", err);
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
error_code = this->write_flash_(buf, read);
if (error_code != ota::OTA_RESPONSE_OK)
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
} else if (read == 0) {
ESP_LOGW(TAG, "Remote closed");
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
}
last_data_ms = millis();
error_code = this->backend_->write(buf, read);
if (error_code != ota::OTA_RESPONSE_OK) {
ESP_LOGW(TAG, "Flash write err %d", error_code);
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
total += read;
#if USE_OTA_VERSION == 2
while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) {
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
size_acknowledged += OTA_BLOCK_SIZE;
}
#endif
uint32_t now = millis();
if (now - last_progress > 1000) {
last_progress = now;
float percentage = (total * 100.0f) / ota_size;
ESP_LOGD(TAG, "Progress: %0.1f%%", percentage);
#ifdef USE_OTA_STATE_LISTENER
this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0);
#endif
// feed watchdog and give other tasks a chance to run
this->yield_and_feed_watchdog_();
this->ack_written_(xfer);
}
}
@@ -771,6 +747,174 @@ bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *desc) {
return this->handshake_buf_pos_ >= to_write;
}
bool ESPHomeOTAComponent::read_size_(uint8_t *buf, size_t &size, const LogString *desc) {
if (!this->data_readall_(buf, OTA_SIZE_FIELD_BYTES)) {
this->log_read_error_(desc);
return false;
}
size = encode_uint32(buf[0], buf[1], buf[2], buf[3]);
ESP_LOGV(TAG, "%s is %zu bytes", LOG_STR_ARG(desc), size);
return true;
}
ota::OTAResponseTypes ESPHomeOTAComponent::write_flash_(uint8_t *data, size_t len) {
ota::OTAResponseTypes result = this->backend_->write(data, len);
if (result != ota::OTA_RESPONSE_OK) {
ESP_LOGW(TAG, "Flash write err %d", result);
}
return result;
}
ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) {
const size_t remaining = xfer.ota_size - xfer.total;
const size_t requested = std::min(remaining, OTA_BUFFER_SIZE);
ssize_t read;
for (;;) {
// A silently-vanished peer (no FIN/RST delivered, e.g. uploader killed
// mid-transfer or NAT/router dropped state) must not wedge the device:
// read() only fails on EOF or a real error, and lwIP TCP keepalive isn't
// enabled here.
if (millis() - xfer.last_data_ms > OTA_SOCKET_TIMEOUT_DATA) {
ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA);
return -1;
}
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ != nullptr) {
// One frame per call; noise_read_data_ waits internally (readall_), so
// there is no would-block retry here and failures are already logged.
read = this->noise_read_data_(buf, requested);
if (read <= 0)
return -1;
break;
}
#endif
read = this->client_->read(buf, requested);
if (read > 0)
break;
if (read == 0) {
this->log_remote_closed_(LOG_STR("data"));
return -1;
}
if (!this->would_block_(errno)) {
this->log_socket_error_(LOG_STR("data"));
return -1;
}
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
App.feed_wdt();
}
const uint32_t now = millis();
xfer.last_data_ms = now;
xfer.total += read;
this->ack_received_(xfer);
if (now - xfer.last_progress > OTA_PROGRESS_INTERVAL_MS) {
xfer.last_progress = now;
float percentage = (xfer.total * 100.0f) / xfer.ota_size;
ESP_LOGD(TAG, "Progress: %0.1f%%", percentage);
#ifdef USE_OTA_STATE_LISTENER
this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0);
#endif
// feed watchdog and give other tasks a chance to run
this->yield_and_feed_watchdog_();
}
return read;
}
void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) {
#if USE_OTA_VERSION == 2
while (xfer.acknowledged + OTA_BLOCK_SIZE <= xfer.total ||
(xfer.total == xfer.ota_size && xfer.acknowledged < xfer.ota_size)) {
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
xfer.acknowledged += OTA_BLOCK_SIZE;
}
#endif
}
#ifdef USE_OTA_DEFLATE
// The window doubles as the output buffer; flushed bytes stay as back
// reference history for the next windowful.
ota::OTAResponseTypes ESPHomeOTAComponent::inflate_flush_(InflateSession &session) {
const size_t produced = session.dest - session.window;
const size_t pending = produced - session.flushed;
if (pending != 0) {
if (pending > session.image_size - session.written) {
ESP_LOGW(TAG, "Inflate overrun");
return ota::OTA_RESPONSE_ERROR_UNKNOWN;
}
ota::OTAResponseTypes result = this->write_flash_(session.window + session.flushed, pending);
if (result != ota::OTA_RESPONSE_OK)
return result;
session.flushed = produced;
session.written += pending;
// A compressible region yields many windows per socket read
App.feed_wdt();
}
// Even with nothing new written: a block boundary can fall inside a header
this->ack_written_(*session.xfer);
return ota::OTA_RESPONSE_OK;
}
ota::OTAResponseTypes ESPHomeOTAComponent::inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer) {
InflateSession &session = *this->inflate_;
session.self = this;
session.xfer = &xfer;
session.in = in;
session.image_size = image_size;
session.written = 0;
session.error = ota::OTA_RESPONSE_OK;
// A corrupt stream may back reference the window before it is filled; zero it
// so such a read copies zeros, never stale memory
std::memset(session.window, 0, sizeof(session.window));
ota_inflate_init(&session, session.window, OTA_INFLATE_WINDOW_SIZE);
// Where the ack must follow the write, flush and ack before waiting for
// input, or the client waits for an ack while the decoder waits for data
session.source_read_cb = [](OtaInflateState *d) -> int {
auto *s = static_cast<InflateSession *>(d);
if (ACK_AFTER_WRITE) {
s->error = s->self->inflate_flush_(*s);
if (s->error != ota::OTA_RESPONSE_OK)
return -1;
}
// More input than announced; reported by the size check below
if (s->xfer->total >= s->xfer->ota_size)
return -1;
ssize_t read = s->self->receive_data_(s->in, *s->xfer);
if (read <= 0) {
// Already logged by receive_data_
s->error = ota::OTA_RESPONSE_ERROR_UNKNOWN;
return -1;
}
d->source = s->in + 1;
d->source_limit = s->in + read;
return s->in[0];
};
int res;
do {
// The ring index wrapped to 0 exactly when the window filled
session.dest = session.window;
session.dest_limit = session.window + OTA_INFLATE_WINDOW_SIZE;
session.flushed = 0;
res = ota_inflate(&session);
// A stored block keeps emitting zeros after a failed read, hence eof
if (res < 0 || session.eof)
break;
session.error = this->inflate_flush_(session);
} while (res != OTA_INFLATE_DONE && session.error == ota::OTA_RESPONSE_OK);
// Transport and flash failures are logged where they happen
if (session.error != ota::OTA_RESPONSE_OK)
return session.error;
if (res != OTA_INFLATE_DONE || session.written != image_size || xfer.total != xfer.ota_size) {
ESP_LOGW(TAG, "Inflate err %d, %zu of %zu B from %zu of %zu", res, session.written, image_size, xfer.total,
xfer.ota_size);
return ota::OTA_RESPONSE_ERROR_UNKNOWN;
}
ESP_LOGD(TAG, "Inflated %zu bytes from %zu", session.written, xfer.total);
return ota::OTA_RESPONSE_OK;
}
#endif // USE_OTA_DEFLATE
void ESPHomeOTAComponent::cleanup_connection_() {
this->client_->close();
this->client_ = nullptr;
@@ -784,6 +928,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
#endif
#ifdef USE_OTA_ENCRYPTION
this->noise_ = nullptr;
#endif
#ifdef USE_OTA_DEFLATE
this->inflate_ = nullptr;
#endif
// Intentionally no disable_loop() — letting loop() run one more iteration catches
// any connection that queued on the listener mid-session (otherwise the wake flag,
@@ -7,6 +7,9 @@
#ifdef USE_OTA_ENCRYPTION
#include "esphome/components/noise/noise_handshake.h"
#endif
#ifdef USE_OTA_DEFLATE
#include "ota_esphome_inflate.h"
#endif
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
@@ -119,6 +122,38 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
return this->readall_(buf, len);
}
// Upload accounting shared by the data loop and the inflate read callback
struct DataTransfer {
size_t ota_size{0}; // bytes the client sends
size_t total{0}; // bytes received so far
#if USE_OTA_VERSION == 2
size_t acknowledged{0};
#endif
uint32_t last_data_ms{0};
uint32_t last_progress{0};
};
// Up to OTA_BUFFER_SIZE bytes into buf; returns bytes read, -1 on failure (logged)
inline ssize_t receive_data_(uint8_t *buf, DataTransfer &xfer);
// Raw lwIP cannot service the radio during a sector write, so the ack waits
// for the write there; a socket task lets the next block arrive meanwhile
#ifdef USE_SOCKET_IMPL_LWIP_TCP
static constexpr bool ACK_AFTER_WRITE = true;
#else
static constexpr bool ACK_AFTER_WRITE = false;
#endif
void send_chunk_acks_(DataTransfer &xfer);
inline void ack_received_(DataTransfer &xfer) {
if (!ACK_AFTER_WRITE)
this->send_chunk_acks_(xfer);
}
inline void ack_written_(DataTransfer &xfer) {
if (ACK_AFTER_WRITE)
this->send_chunk_acks_(xfer);
}
inline bool read_size_(uint8_t *buf, size_t &size, const LogString *desc);
// Writes to the backend and logs a failure
inline ota::OTAResponseTypes write_flash_(uint8_t *data, size_t len);
bool try_read_(size_t to_read, const LogString *desc);
bool try_write_(size_t to_write, const LogString *desc);
@@ -171,6 +206,31 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
static_assert(OTA_BUFFER_SIZE >= NOISE_CLIENT_MAX_PLAINTEXT + noise::MAC_SIZE,
"OTA_BUFFER_SIZE must fit a full encrypted data frame");
#endif
#ifdef USE_OTA_DEFLATE
// At least 1 << espota2.DEFLATE_WINDOW_BITS; also the inflate output buffer
static constexpr size_t OTA_INFLATE_WINDOW_SIZE = 4096;
// Heap-allocated only while a deflate upload is negotiated; the decoder
// state is the base so the read callback can recover the session
struct InflateSession : OtaInflateState {
ESPHomeOTAComponent *self;
DataTransfer *xfer;
uint8_t *in; // caller's buffer for the compressed input, valid during inflate_data_
size_t image_size;
size_t written; // inflated bytes in flash
size_t flushed; // bytes of the current window already in flash
ota::OTAResponseTypes error; // first failure inside the read callback
uint8_t window[OTA_INFLATE_WINDOW_SIZE];
};
#ifndef CLANG_TIDY // static analysis sets every define at once
static_assert(!ota::OTABackend::supports_compression(),
"USE_OTA_DEFLATE is for backends that cannot store a gzip image");
#endif
// Writes the decoded bytes not yet in flash without moving dest
ota::OTAResponseTypes inflate_flush_(InflateSession &session);
ota::OTAResponseTypes inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer);
std::unique_ptr<InflateSession> inflate_;
#endif
static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
// Derived from the feature byte; storing it would pad the trailing bytes
bool extended_proto_() const;
@@ -0,0 +1,498 @@
/*
* uzlib - tiny deflate/inflate library (deflate, gzip, zlib)
*
* Copyright (c) 2003 by Joergen Ibsen / Jibz
* All Rights Reserved
* http://www.ibsensoftware.com/
*
* Copyright (c) 2014-2018 by Paul Sokolovsky
*
* This software is provided 'as-is', without any express
* or implied warranty. In no event will the authors be
* held liable for any damages arising from the use of
* this software.
*
* Permission is granted to anyone to use this software
* for any purpose, including commercial applications,
* and to alter it and redistribute it freely, subject to
* the following restrictions:
*
* 1. The origin of this software must not be
* misrepresented; you must not claim that you
* wrote the original software. If you use this
* software in a product, an acknowledgment in
* the product documentation would be appreciated
* but is not required.
*
* 2. Altered source versions must be plainly marked
* as such, and must not be misrepresented as
* being the original software.
*
* 3. This notice may not be removed or altered from
* any source distribution.
*/
/*
* Altered for ESPHome: this is the raw deflate decoder from uzlib's
* tinflate.c (v2.9.5) with the gzip/zlib header parsers, checksums,
* runtime table builder and in-memory (non ring window) output path
* removed, and the public names prefixed with ota_inflate.
*/
#include "ota_esphome_inflate.h"
#include <stddef.h>
#define TINF_OK OTA_INFLATE_OK
#define TINF_DONE OTA_INFLATE_DONE
#define TINF_DATA_ERROR OTA_INFLATE_DATA_ERROR
#define TINF_DICT_ERROR OTA_INFLATE_DICT_ERROR
#define TINF_DATA struct OtaInflateState
#define TINF_TREE struct OtaInflateTree
#define TINF_ARRAY_SIZE(arr) (sizeof(arr) / sizeof(*(arr)))
/* every output byte also goes into the ring window */
#define TINF_PUT(d, c) \
{ \
*d->dest++ = c; \
d->dict_ring[d->dict_idx++] = c; \
if (d->dict_idx == d->dict_size) \
d->dict_idx = 0; \
}
/* --------------------------------------------------- *
* -- constant tables (upstream builds them at runtime) -- *
* --------------------------------------------------- */
static const unsigned char LENGTH_BITS[30] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2,
2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5};
static const unsigned short LENGTH_BASE[30] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27,
31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258};
static const unsigned char DIST_BITS[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6,
6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13};
static const unsigned short DIST_BASE[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25,
33, 49, 65, 97, 129, 193, 257, 385, 513, 769,
1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577};
/* special ordering of code length codes */
static const unsigned char CLCIDX[] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15};
/* ----------------------- *
* -- utility functions -- *
* ----------------------- */
/* given an array of code lengths, build a tree */
static void tinf_build_tree(TINF_TREE *t, const unsigned char *lengths, unsigned int num) {
unsigned short offs[16];
unsigned int i, sum;
/* clear code length count table */
for (i = 0; i < 16; ++i)
t->table[i] = 0;
/* scan symbol lengths, and sum code length counts */
for (i = 0; i < num; ++i)
t->table[lengths[i]]++;
/* In the lengths array, 0 means unused code. So, t->table[0] now contains
number of unused codes. But table's purpose is to contain # of codes of
particular length, and there're 0 codes of length 0. */
t->table[0] = 0;
/* compute offset table for distribution sort */
for (sum = 0, i = 0; i < 16; ++i) {
offs[i] = sum;
sum += t->table[i];
}
/* create code->symbol translation table (symbols sorted by code) */
for (i = 0; i < num; ++i) {
if (lengths[i])
t->trans[offs[lengths[i]]++] = i;
}
}
/* ---------------------- *
* -- decode functions -- *
* ---------------------- */
static unsigned char uzlib_get_byte(TINF_DATA *d) {
/* If end of source buffer is not reached, return next byte from source
buffer. */
if (d->source < d->source_limit) {
return *d->source++;
}
/* Otherwise if there's callback and we haven't seen EOF yet, try to
read next byte using it. (Note: the callback can also update ->source
and ->source_limit). */
if (!d->eof) {
int val = d->source_read_cb(d);
if (val >= 0) {
return (unsigned char) val;
}
}
/* Otherwise, we hit EOF (either from ->source_read_cb() or from exhaustion
of the buffer), and it will be "sticky", i.e. further calls to this
function will end up here too. */
d->eof = true;
return 0;
}
/* get one bit from source stream */
static int tinf_getbit(TINF_DATA *d) {
unsigned int bit;
/* check if tag is empty */
if (!d->bitcount--) {
/* load next tag */
d->tag = uzlib_get_byte(d);
d->bitcount = 7;
}
/* shift bit out of tag */
bit = d->tag & 0x01;
d->tag >>= 1;
return bit;
}
/* read a num bit value from a stream and add base */
static unsigned int tinf_read_bits(TINF_DATA *d, int num, int base) {
unsigned int val = 0;
/* read num bits */
if (num) {
unsigned int limit = 1 << (num);
unsigned int mask;
for (mask = 1; mask < limit; mask *= 2)
if (tinf_getbit(d))
val += mask;
}
return val + base;
}
/* given a data stream and a tree, decode a symbol */
static int tinf_decode_symbol(TINF_DATA *d, TINF_TREE *t) {
int sum = 0, cur = 0, len = 0;
/* get more bits while code value is above sum */
do {
cur = 2 * cur + tinf_getbit(d);
if (++len == TINF_ARRAY_SIZE(t->table)) {
return TINF_DATA_ERROR;
}
sum += t->table[len];
cur -= t->table[len];
} while (cur >= 0);
sum += cur;
if (sum < 0 || sum >= t->size) {
return TINF_DATA_ERROR;
}
return t->trans[sum];
}
/* given a data stream, decode dynamic trees from it */
static int tinf_decode_trees(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
/* code lengths for 288 literal/len symbols and 32 dist symbols */
unsigned char lengths[288 + 32];
unsigned int hlit, hdist, hclen, hlimit;
unsigned int i, num, length;
/* get 5 bits HLIT (257-286) */
hlit = tinf_read_bits(d, 5, 257);
/* get 5 bits HDIST (1-32) */
hdist = tinf_read_bits(d, 5, 1);
/* get 4 bits HCLEN (4-19) */
hclen = tinf_read_bits(d, 4, 4);
for (i = 0; i < 19; ++i)
lengths[i] = 0;
/* read code lengths for code length alphabet */
for (i = 0; i < hclen; ++i) {
/* get 3 bits code length (0-7) */
unsigned int clen = tinf_read_bits(d, 3, 0);
lengths[CLCIDX[i]] = clen;
}
/* build code length tree, temporarily use length tree */
tinf_build_tree(lt, lengths, 19);
/* decode code lengths for the dynamic trees */
hlimit = hlit + hdist;
for (num = 0; num < hlimit;) {
int sym = tinf_decode_symbol(d, lt);
unsigned char fill_value = 0;
int lbits, lbase = 3;
/* error decoding */
if (sym < 0)
return sym;
switch (sym) {
case 16:
/* copy previous code length 3-6 times (read 2 bits) */
if (num == 0)
return TINF_DATA_ERROR;
fill_value = lengths[num - 1];
lbits = 2;
break;
case 17:
/* repeat code length 0 for 3-10 times (read 3 bits) */
lbits = 3;
break;
case 18:
/* repeat code length 0 for 11-138 times (read 7 bits) */
lbits = 7;
lbase = 11;
break;
default:
/* values 0-15 represent the actual code lengths */
lengths[num++] = sym;
/* continue the for loop */
continue;
}
/* special code length 16-18 are handled here */
length = tinf_read_bits(d, lbits, lbase);
if (num + length > hlimit)
return TINF_DATA_ERROR;
for (; length; --length) {
lengths[num++] = fill_value;
}
}
/* Check that there's "end of block" symbol */
if (lengths[256] == 0) {
return TINF_DATA_ERROR;
}
/* build dynamic trees */
tinf_build_tree(lt, lengths, hlit);
tinf_build_tree(dt, lengths + hlit, hdist);
return TINF_OK;
}
/* build the fixed huffman trees (RFC 1951 3.2.6) through the generic tree
builder; altered from upstream, which unrolls them by hand */
static void tinf_build_fixed_trees(TINF_TREE *lt, TINF_TREE *dt) {
unsigned char lengths[288];
unsigned int i;
for (i = 0; i < 144; ++i)
lengths[i] = 8;
for (; i < 256; ++i)
lengths[i] = 9;
for (; i < 280; ++i)
lengths[i] = 7;
for (; i < 288; ++i)
lengths[i] = 8;
tinf_build_tree(lt, lengths, 288);
for (i = 0; i < 32; ++i)
lengths[i] = 5;
tinf_build_tree(dt, lengths, 32);
}
/* ----------------------------- *
* -- block inflate functions -- *
* ----------------------------- */
/* given a stream and two trees, inflate next chunk of output (a byte or more) */
static int tinf_inflate_block_data(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
if (d->curlen == 0) {
unsigned int offs;
int dist;
int sym = tinf_decode_symbol(d, lt);
if (d->eof) {
return TINF_DATA_ERROR;
}
if (sym < 0) {
return sym;
}
/* literal byte */
if (sym < 256) {
TINF_PUT(d, sym);
return TINF_OK;
}
/* end of block */
if (sym == 256) {
return TINF_DONE;
}
/* substring from sliding dictionary */
sym -= 257;
if (sym >= 29) {
return TINF_DATA_ERROR;
}
/* possibly get more bits from length code */
d->curlen = tinf_read_bits(d, LENGTH_BITS[sym], LENGTH_BASE[sym]);
dist = tinf_decode_symbol(d, dt);
if (dist < 0 || dist >= 30) {
return TINF_DATA_ERROR;
}
/* possibly get more bits from distance code */
offs = tinf_read_bits(d, DIST_BITS[dist], DIST_BASE[dist]);
/* calculate and validate actual LZ offset to use */
if (offs > d->dict_size) {
return TINF_DICT_ERROR;
}
/* Note: we don't try to catch offset which points to not yet filled
part of the dictionary here. Doing so would require keeping another
variable to track "filled in" size of the dictionary. Appearance of
such an offset cannot lead to accessing memory outside of the
dictionary buffer, and clients which don't want to leak unrelated
information, should explicitly initialize dictionary buffer passed
to uzlib. */
d->lz_off = d->dict_idx - offs;
if (d->lz_off < 0) {
d->lz_off += d->dict_size;
}
}
/* copy next byte from dict substring */
TINF_PUT(d, d->dict_ring[d->lz_off]);
if ((unsigned) ++d->lz_off == d->dict_size) {
d->lz_off = 0;
}
d->curlen--;
return TINF_OK;
}
/* inflate next byte from uncompressed block of data */
static int tinf_inflate_uncompressed_block(TINF_DATA *d) {
if (d->curlen == 0) {
unsigned int length, invlength;
/* get length */
length = uzlib_get_byte(d);
length += 256 * uzlib_get_byte(d);
/* get one's complement of length */
invlength = uzlib_get_byte(d);
invlength += 256 * uzlib_get_byte(d);
/* check length */
if (length != (~invlength & 0x0000ffff))
return TINF_DATA_ERROR;
/* increment length to properly return TINF_DONE below, without
producing data at the same time */
d->curlen = length + 1;
/* make sure we start next block on a byte boundary */
d->bitcount = 0;
}
if (--d->curlen == 0) {
return TINF_DONE;
}
unsigned char c = uzlib_get_byte(d);
TINF_PUT(d, c);
return TINF_OK;
}
/* ---------------------- *
* -- public functions -- *
* ---------------------- */
/* initialize decompression structure */
void ota_inflate_init(TINF_DATA *d, unsigned char *dict, unsigned int dict_len) {
d->source = NULL;
d->source_limit = NULL;
d->tag = 0;
d->eof = 0;
d->bitcount = 0;
d->lz_off = 0;
d->bfinal = 0;
d->btype = -1;
d->dict_size = dict_len;
d->dict_ring = dict;
d->dict_idx = 0;
d->curlen = 0;
d->ltree.trans = d->ltrans;
d->ltree.size = TINF_ARRAY_SIZE(d->ltrans);
d->dtree.trans = d->dtrans;
d->dtree.size = TINF_ARRAY_SIZE(d->dtrans);
}
/* inflate next output bytes from compressed stream */
int ota_inflate(TINF_DATA *d) {
do {
int res;
/* start a new block */
if (d->btype == -1) {
int old_btype;
next_blk:
old_btype = d->btype;
/* read final block flag */
d->bfinal = tinf_getbit(d);
/* read block type (2 bits) */
d->btype = tinf_read_bits(d, 2, 0);
if (d->btype == 1 && old_btype != 1) {
/* build fixed huffman trees */
tinf_build_fixed_trees(&d->ltree, &d->dtree);
} else if (d->btype == 2) {
/* decode trees from stream */
res = tinf_decode_trees(d, &d->ltree, &d->dtree);
if (res != TINF_OK) {
return res;
}
}
}
/* process current block */
switch (d->btype) {
case 0:
/* decompress uncompressed block */
res = tinf_inflate_uncompressed_block(d);
break;
case 1:
case 2:
/* decompress block with fixed/dynamic huffman trees */
/* trees were decoded previously, so it's the same routine for both */
res = tinf_inflate_block_data(d, &d->ltree, &d->dtree);
break;
default:
return TINF_DATA_ERROR;
}
if (res == TINF_DONE && !d->bfinal) {
/* the block has ended (without producing more data), but we
can't return without data, so start procesing next block */
goto next_blk;
}
if (res != TINF_OK) {
return res;
}
} while (d->dest < d->dest_limit);
return TINF_OK;
}
@@ -0,0 +1,66 @@
#pragma once
// Raw deflate decoder cut down from uzlib (https://github.com/pfalcon/uzlib,
// zlib licence, see the .c file); output goes through a ring window.
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
enum OtaInflateResult {
OTA_INFLATE_OK = 0, /* more data produced, call again */
OTA_INFLATE_DONE = 1, /* end of compressed stream reached */
OTA_INFLATE_DATA_ERROR = -3,
OTA_INFLATE_DICT_ERROR = -5,
};
struct OtaInflateTree {
uint16_t table[16]; /* table of code length counts */
uint16_t *trans; /* code -> symbol translation table, size entries */
uint16_t size;
};
struct OtaInflateState {
/* Next byte in the input buffer and one past its end */
const unsigned char *source;
const unsigned char *source_limit;
/* Called when source is exhausted; returns the next byte or -1 at EOF.
It may refill source/source_limit for buffered operation. */
int (*source_read_cb)(struct OtaInflateState *d);
unsigned int tag;
unsigned int bitcount;
/* Output cursor and one past the end of the output buffer */
unsigned char *dest;
unsigned char *dest_limit;
bool eof;
int btype;
int bfinal;
unsigned int curlen;
int lz_off;
/* Ring window holding the last dict_size output bytes for back references */
unsigned char *dict_ring;
unsigned int dict_size;
unsigned int dict_idx;
struct OtaInflateTree ltree; /* dynamic length/symbol tree */
struct OtaInflateTree dtree; /* dynamic distance tree */
uint16_t ltrans[288];
uint16_t dtrans[32]; /* the distance alphabet has 30 symbols, so the tree is kept small */
};
/* dict must cover the encoder's window (its largest back reference) */
void ota_inflate_init(struct OtaInflateState *d, unsigned char *dict, unsigned int dict_len);
/* Fills dest up to dest_limit (OK) or to the end of the stream (DONE). dest may
alias dict only if dest_limit - dest == dict_len and dest is reset to dict
exactly when a call returns OK, so the ring index and dest stay in lockstep */
int ota_inflate(struct OtaInflateState *d);
#ifdef __cplusplus
}
#endif
@@ -43,9 +43,12 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
// A provisioned key cleared between the offer and here is not guarded: the
// session runs on the zero key load_psk fills in and fails the client's MAC.
// Default-init: the frame buffer is written before it is read
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession);
// Reuse the session reserved at offer time, else allocate now. Default-init:
// the frame buffer is written before it is read
if (this->noise_ == nullptr) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
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
@@ -91,7 +91,14 @@ void I2SAudioSpeakerBase::loop() {
this->speaker_task_handle_ = nullptr;
this->stop_i2s_driver_();
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
// ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at
// the top of loop(): the audio source's task can raise a start at any point above, including
// during stop_i2s_driver_(), and nothing would ever re-issue it.
const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) {
ESP_LOGD(TAG, "Start requested while stopping; keeping the request");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START);
}
this->status_clear_error();
this->on_task_stopped();
@@ -111,21 +118,24 @@ void I2SAudioSpeakerBase::loop() {
break;
}
// Still starting up or winding down from a previous run
if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) {
break;
}
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
this->status_momentary_error("driver-failure", 1000);
break;
}
if (this->speaker_task_handle_ == nullptr) {
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
&this->speaker_task_handle_);
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
&this->speaker_task_handle_);
if (this->speaker_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
this->status_momentary_error("task-failure", 1000);
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
}
if (this->speaker_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
this->status_momentary_error("task-failure", 1000);
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
}
break;
case speaker::STATE_RUNNING: // Intentional fallthrough
@@ -211,8 +221,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t
}
bool I2SAudioSpeakerBase::has_buffered_data() const {
if (this->audio_ring_buffer_.use_count() > 0) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
if (temp_ring_buffer != nullptr) {
return temp_ring_buffer->available() > 0;
}
return false;
@@ -5,6 +5,7 @@
#include <esp_log.h>
#include <driver/uart.h>
#include <soc/soc_caps.h>
#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
#include <driver/usb_serial_jtag.h>
@@ -76,7 +77,11 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) {
uart_config.parity = UART_PARITY_DISABLE;
uart_config.stop_bits = UART_STOP_BITS_1;
uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE;
#if SOC_UART_SUPPORT_XTAL_CLK
uart_config.source_clk = UART_SCLK_XTAL;
#else
uart_config.source_clk = UART_SCLK_DEFAULT;
#endif
uart_param_config(uart_num, &uart_config);
// The logger only writes to UART, never reads, so use the minimum RX buffer.
// ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes).
+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(
@@ -129,7 +129,7 @@ void MicroWakeWord::setup() {
return;
}
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (this->ring_buffer_.use_count() > 1) {
if (temp_ring_buffer != nullptr) {
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
@@ -446,9 +446,9 @@ void MicroWakeWord::loop() {
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
}
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) {
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
this->inference_task_.deallocate();
xEventGroupClearBits(this->event_group_, ALL_BITS);
xQueueReset(this->detection_queue_);
this->set_state_(State::STOPPED);
@@ -48,7 +48,7 @@ class MicrophoneSource final {
template<typename F> void add_data_callback(F &&data_callback) {
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
if (this->enabled_ || this->passive_) {
if (this->processed_samples_.use_count() == 0) {
if (this->processed_samples_ == nullptr) {
// Create vector if its unused
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
}
@@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_
}
size_t bytes_written = 0;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer.use_count() > 0) {
if (temp_ring_buffer != nullptr) {
// Only write to the ring buffer if the reference is valid
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
if (bytes_written > 0) {
@@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() {
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
if (this->audio_source_.use_count() == 0) {
if (this->audio_source_ == nullptr) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (!temp_ring_buffer) {
if (temp_ring_buffer == nullptr) {
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
this->ring_buffer_ = temp_ring_buffer;
}
if (!temp_ring_buffer) {
if (temp_ring_buffer == nullptr) {
return ESP_ERR_NO_MEM;
}
@@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
bool SourceSpeaker::has_buffered_data() const {
return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data());
}
void SourceSpeaker::set_mute_state(bool mute_state) {
@@ -382,8 +382,8 @@ void MixerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
}
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
this->task_.deallocate();
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
this->all_stopped_since_ms_ = 0;
@@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
if (speaker->is_running() && !speaker->get_pause_state()) {
// Speaker is running and not paused, so it possibly can provide audio data
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
if (audio_source.use_count() == 0) {
if (audio_source == nullptr) {
// No audio source allocated, so skip processing this speaker
continue;
}
+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,
+2 -2
View File
@@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE")
cg.add_library("esphome/noise-c", "0.1.24")
cg.add_library("esphome/noise-c", "0.1.26")
# noise-c depends on libsodium, but declaring it here too lets the
# library manager see the full set up front instead of discovering
# libsodium only after noise-c has downloaded, so the two can download
# in parallel. The version must match noise-c's library.json.
cg.add_library("esphome/libsodium", "1.10021.6")
cg.add_library("esphome/libsodium", "1.10021.8")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
+6 -1
View File
@@ -7,6 +7,7 @@
#include <concepts>
#include <cstddef>
#include <cstdint>
#include <type_traits>
#ifdef USE_OTA_STATE_LISTENER
#include <vector>
@@ -102,6 +103,8 @@ enum OTAType : uint8_t {
// - set_update_md5: expected digest of the incoming image, hex string.
// - write: consume the next chunk; end: finalize and mark bootable.
// - abort: safe to call in any state, including after end().
// - supports_compression: constexpr, whether a gzip image is stored as is and
// inflated at reboot.
template<typename T>
concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *data, size_t len, const char *md5) {
{ backend.begin(image_size, OTA_TYPE_UPDATE_APP) } -> std::same_as<OTAResponseTypes>;
@@ -110,7 +113,9 @@ concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *dat
{ backend.write(data, len) } -> std::same_as<OTAResponseTypes>;
{ backend.end() } -> std::same_as<OTAResponseTypes>;
backend.abort();
{ backend.supports_compression() } -> std::same_as<bool>;
{ T::supports_compression() } -> std::same_as<bool>;
// The value must be a constant expression
typename std::bool_constant<T::supports_compression()>;
};
/** Listener interface for OTA state changes.
@@ -13,7 +13,7 @@ class ArduinoLibreTinyOTABackend final {
OTAResponseTypes write(uint8_t *data, size_t len);
OTAResponseTypes end();
void abort();
bool supports_compression() { return false; }
static constexpr bool supports_compression() { return false; }
private:
bool md5_set_{false};
@@ -6,6 +6,8 @@
#include "esphome/core/defines.h"
#include "esphome/core/macros.h"
#include <RP2040Version.h>
namespace esphome::ota {
class ArduinoRP2OTABackend final {
@@ -15,7 +17,12 @@ class ArduinoRP2OTABackend final {
OTAResponseTypes write(uint8_t *data, size_t len);
OTAResponseTypes end();
void abort();
bool supports_compression() { return false; }
// The core's OTA stub inflates a staged gzip image at reboot, on every chip
// from 4.0.3 (ESPHome pins 6.0.0). begin() only sees the gzip size; the
// inflated size is known when the stub reads the trailer.
static constexpr bool supports_compression() {
return VERSION_CODE(ARDUINO_PICO_MAJOR, ARDUINO_PICO_MINOR, ARDUINO_PICO_REVISION) >= VERSION_CODE(4, 0, 3);
}
private:
bool md5_set_{false};
+1 -1
View File
@@ -20,7 +20,7 @@ class ESP8266OTABackend final {
OTAResponseTypes end();
void abort();
// Compression supported in all ESP8266 Arduino versions ESPHome supports (>= 2.7.0)
bool supports_compression() { return true; }
static constexpr bool supports_compression() { return true; }
protected:
/// Erase flash sector if current address is at sector boundary
+1 -1
View File
@@ -33,7 +33,7 @@ class IDFOTABackend final {
OTAResponseTypes write(uint8_t *data, size_t len);
OTAResponseTypes end();
void abort();
bool supports_compression() { return false; }
static constexpr bool supports_compression() { return false; }
protected:
#ifdef USE_OTA_PARTITIONS
+3 -2
View File
@@ -25,7 +25,7 @@ struct StubOTABackend {
OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_UNKNOWN; }
OTAResponseTypes end() { return OTA_RESPONSE_ERROR_UNKNOWN; }
void abort() {}
bool supports_compression() { return false; }
static constexpr bool supports_compression() { return false; }
};
std::unique_ptr<StubOTABackend> make_ota_backend();
} // namespace esphome::ota
@@ -33,6 +33,7 @@ std::unique_ptr<StubOTABackend> make_ota_backend();
namespace esphome::ota {
using OTABackendPtr = decltype(make_ota_backend());
static_assert(OTABackendContract<OTABackendPtr::element_type>,
using OTABackend = OTABackendPtr::element_type;
static_assert(OTABackendContract<OTABackend>,
"The platform's OTA backend is missing part of the backend surface (ota_backend.h)");
} // namespace esphome::ota
+1 -1
View File
@@ -19,7 +19,7 @@ class HostOTABackend final {
OTAResponseTypes write(uint8_t *data, size_t len);
OTAResponseTypes end();
void abort();
bool supports_compression() { return false; }
static constexpr bool supports_compression() { return false; }
protected:
md5::MD5Digest md5_{};
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
}
if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) {
this->task_.deallocate();
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) {
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
}
@@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic
bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
} else {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer) {
if (temp_ring_buffer != nullptr) {
// Only write to the ring buffer if the reference is valid
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
} else {
@@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const {
bool has_ring_buffer_data = false;
if (this->requires_resampling_()) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer) {
if (temp_ring_buffer != nullptr) {
has_ring_buffer_data = (temp_ring_buffer->available() > 0);
}
}
@@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_));
if (!temp_ring_buffer) {
if (temp_ring_buffer == nullptr) {
err = ESP_ERR_NO_MEM;
} else {
this_resampler->ring_buffer_ = temp_ring_buffer;
+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_;
@@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() {
if (!this->is_playing_) {
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks
if (this->read_task_.is_created() || this->decode_task_.is_created()) {
this->read_task_.deallocate();
this->decode_task_.deallocate();
// Both are attempted every time; a task that is still running on the other core is freed by a
// subsequent call, and freeing an already freed task succeeds without doing anything
bool read_task_freed = this->read_task_.deallocate();
bool decode_task_freed = this->decode_task_.deallocate();
if (!read_task_freed || !decode_task_freed) {
// A task is still running on the other core, so keep the pipeline in its current state and try
// again on the next call
return AudioPipelineState::PLAYING;
}
if (this->hard_stop_) {
// Stop command was sent, so immediately end the playback
this->speaker_->stop();
@@ -315,17 +322,17 @@ void AudioPipeline::read_task(void *params) {
if (err == ESP_OK) {
size_t file_ring_buffer_size = this_pipeline->buffer_size_;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock();
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
if (temp_ring_buffer == nullptr) {
temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size);
this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer;
}
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
if (temp_ring_buffer == nullptr) {
err = ESP_ERR_NO_MEM;
} else {
reader->add_sink(this_pipeline->raw_file_ring_buffer_);
err = reader->add_sink(temp_ring_buffer);
}
}
@@ -396,7 +403,9 @@ void AudioPipeline::decode_task(void *params) {
make_unique<audio::AudioDecoder>(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_);
esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_);
decoder->add_source(this_pipeline->raw_file_ring_buffer_);
if (err == ESP_OK) {
err = decoder->add_source(this_pipeline->raw_file_ring_buffer_);
}
if (err != ESP_OK) {
// Send specific error message
+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,
+1
View File
@@ -244,6 +244,7 @@
#define USE_RUNTIME_IMAGE_QOI
#define USE_RUNTIME_STATS
#define USE_OTA
#define USE_OTA_DEFLATE
#define USE_OTA_ENCRYPTION
#define USE_OTA_ENCRYPTION_FROM_API
#define USE_OTA_ENCRYPTION_PROVISIONED
+23 -7
View File
@@ -40,16 +40,31 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size
return true;
}
void StaticTask::destroy() {
if (this->handle_ != nullptr) {
TaskHandle_t handle = this->handle_;
this->handle_ = nullptr;
vTaskDelete(handle);
bool StaticTask::destroy() {
if (this->handle_ == nullptr) {
return true;
}
// Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks
// the other core to yield though, so the task may still be running on it for a moment.
vTaskSuspend(this->handle_);
if (eTaskGetState(this->handle_) != eSuspended) {
// The task is still running on the other core and using its stack. Deleting it now would only put it on
// the termination list and return, so the caller has to try again once it has been swapped out.
return false;
}
// The task cannot run again, so the delete completes right away instead of being left to the idle task.
TaskHandle_t handle = this->handle_;
this->handle_ = nullptr;
vTaskDelete(handle);
return true;
}
void StaticTask::deallocate() {
this->destroy();
bool StaticTask::deallocate() {
if (!this->destroy()) {
return false;
}
if (this->stack_buffer_ != nullptr) {
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
: RAMAllocator<StackType_t>::ALLOC_INTERNAL);
@@ -57,6 +72,7 @@ void StaticTask::deallocate() {
this->stack_buffer_ = nullptr;
this->stack_size_ = 0;
}
return true;
}
} // namespace esphome
+12 -5
View File
@@ -11,6 +11,7 @@ namespace esphome {
/** Helper for FreeRTOS static task management.
* Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods.
* Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on.
*/
class StaticTask {
public:
@@ -23,7 +24,7 @@ class StaticTask {
/// @brief Allocate stack and create task.
/// @param fn Task function
/// @param name Task name (for debug)
/// @param stack_size Stack size in StackType_t words
/// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF)
/// @param param Parameter passed to task function
/// @param priority FreeRTOS task priority
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM
@@ -31,11 +32,17 @@ class StaticTask {
bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
bool use_psram);
/// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call.
void destroy();
/// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call.
/// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is
/// suspended here so that it cannot be scheduled again, and it is given no chance to clean up.
/// @return true if the task was deleted; false if it is still running on another core, in which case the
/// caller should try again later.
bool destroy();
/// @brief Delete the task (if running) and free the stack buffer.
void deallocate();
/// @brief Delete the task (if created) and free the stack buffer.
/// @return true if the stack buffer was freed; false if the task is still running on another core, in
/// which case the caller should try again later.
bool deallocate();
protected:
TaskHandle_t handle_{nullptr};
+33 -13
View File
@@ -11,6 +11,7 @@ import secrets
import socket
import time
from typing import Any
import zlib
from esphome.core import EsphomeError
from esphome.helpers import ProgressBar, resolve_ip_address
@@ -65,9 +66,15 @@ CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01
CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02
CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04
CLIENT_FEATURE_SUPPORTS_NOISE = 0x08
CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10
SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01
SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02
SERVER_FEATURE_SUPPORTS_NOISE = 0x04
# Binding once offered: the device then expects the image size and a deflate stream
SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08
# Wire constant: the deflate bit promises a 4 KB window (OTA_INFLATE_WINDOW_SIZE)
DEFLATE_WINDOW_BITS = 12
NOISE_FRAME_INDICATOR = 0x01
NOISE_HANDSHAKE_OK = 0x00
@@ -87,6 +94,9 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset(
)
UPLOAD_BLOCK_SIZE = 8192
# Sizes on the wire are 4 bytes MSB first
SIZE_FIELD_BYTES = 4
COMPRESS_LEVEL = 9
UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time
@@ -96,6 +106,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
# across the addresses on top of that.
EXTRA_UPLOAD_ATTEMPTS = 2
UPLOAD_RETRY_DELAY = 5.0
# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA
# (105 s) so a stalled session is gone before a retry, and long enough for lwIP
# to get a lost chunk ack through after the retransmit run seen in practice
DATA_PHASE_TIMEOUT = 160.0
_LOGGER = logging.getLogger(__name__)
@@ -547,6 +561,7 @@ def perform_ota(
CLIENT_FEATURE_SUPPORTS_COMPRESSION
| CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| CLIENT_FEATURE_SUPPORTS_DEFLATE
)
if noise_psk:
features_to_send |= CLIENT_FEATURE_SUPPORTS_NOISE
@@ -640,8 +655,16 @@ def perform_ota(
f"retry {flag_name}."
)
if features & SERVER_FEATURE_SUPPORTS_COMPRESSION:
upload_contents = gzip.compress(file_contents, compresslevel=9)
deflate = bool(extended_proto and features & SERVER_FEATURE_SUPPORTS_DEFLATE)
if deflate:
# The device inflates while receiving through a small ring window
upload_contents = zlib.compress(
file_contents, COMPRESS_LEVEL, wbits=-DEFLATE_WINDOW_BITS
)
_LOGGER.info("Compressed to %s bytes (deflate)", len(upload_contents))
elif features & SERVER_FEATURE_SUPPORTS_COMPRESSION:
# The device stores the gzip file and inflates it when it reboots
upload_contents = gzip.compress(file_contents, compresslevel=COMPRESS_LEVEL)
_LOGGER.info("Compressed to %s bytes", len(upload_contents))
else:
upload_contents = file_contents
@@ -694,29 +717,26 @@ def perform_ota(
_LOGGER.info("Handshake complete")
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
sock.settimeout(90.0)
sock.settimeout(DATA_PHASE_TIMEOUT)
if extended_proto:
send_check(sock, ota_type, "ota type")
upload_size = len(upload_contents)
upload_size_encoded = [
(upload_size >> 24) & 0xFF,
(upload_size >> 16) & 0xFF,
(upload_size >> 8) & 0xFF,
(upload_size >> 0) & 0xFF,
]
# The device erases flash between receiving the size and acking the
# prepare, so this window shows the erase cost (near zero when the
# device erases lazily during the upload)
prepare_start = time.perf_counter()
send_check(sock, upload_size_encoded, "binary size")
send_check(sock, upload_size.to_bytes(SIZE_FIELD_BYTES, "big"), "binary size")
if deflate:
# Own frame: an encrypted session carries one field per frame
send_check(sock, file_size.to_bytes(SIZE_FIELD_BYTES, "big"), "image size")
receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK)
prepare_duration = time.perf_counter() - prepare_start
_LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration)
upload_md5 = hashlib.md5(upload_contents).hexdigest()
# The device hashes what it writes: the inflated image, else the received bytes
upload_md5 = hashlib.md5(file_contents if deflate else upload_contents).hexdigest()
_LOGGER.debug("MD5 of upload is %s", upload_md5)
send_check(sock, upload_md5, "file checksum")
@@ -854,7 +874,7 @@ def run_ota_impl_(
# clean up a half-open connection (its handshake watchdog runs at 20s);
# moving on to the next address family stays immediate. Known limitation:
# a silent mid-transfer drop with no reset can wedge the device until its
# 90s data timeout, which outlasts this budget; the retries target the
# 105s data timeout, which outlasts this budget; the retries target the
# common failures where the device resets or closes the link promptly.
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
last_error = ""
+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:
+2
View File
@@ -951,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
+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:
#
+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.24 ; noise (api, ota)
esphome/noise-c@0.1.26 ; 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.24 ; noise (api, ota)
esphome/noise-c@0.1.26 ; 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.24 ; used by noise (api, ota)
esphome/noise-c@0.1.26 ; used by noise (api, ota)
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
+1 -1
View File
@@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time
tzdata>=2026.3 # from time
pyserial==3.5
platformio==6.1.19
esptool==5.3.1
esptool==5.4.0
click==8.3.3
aioesphomeapi==46.3.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
+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
+2
View File
@@ -823,6 +823,8 @@ def lint_relative_py_import(fname: Path, line, col, content):
# neither can live in a C++ namespace.
"esphome/components/esp32_hosted/esp_now_hosted.cpp",
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
# C header shared with the vendored decoder
"esphome/components/esphome/ota/ota_esphome_inflate.h",
],
)
def lint_namespace(fname: Path, content: str) -> str | None:
+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
+17
View File
@@ -1104,6 +1104,10 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]:
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
# Any usage form (decorator, pytestmark assignment or list element); only
# test_*.py files are scanned, so the marker docs elsewhere cannot false-hit
_SHARED_YAML_USE_RE = re.compile(r"\bmark\.shared_yaml")
_SHARED_YAML_ARG_RE = re.compile(r"\(\s*[\"'](\w+)[\"']\s*\)")
@cache
@@ -1123,6 +1127,19 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
for func in _TEST_FUNC_RE.findall(content):
base_name = func.replace("test_", "").partition("[")[0]
result.setdefault(base_name, set()).add(rel_path)
# Shared fixtures are named by marker, not by a test function; each
# decorator must carry a string literal or its fixture would silently
# map to no tests
for use in _SHARED_YAML_USE_RE.finditer(content):
arg = _SHARED_YAML_ARG_RE.match(content, use.end())
if arg is None:
line = content.count("\n", 0, use.start()) + 1
raise ValueError(
f"{rel_path}:{line}: shared_yaml marker must take a "
"single-line string literal so CI test selection can map "
"its fixture"
)
result.setdefault(arg.group(1), set()).add(rel_path)
return {k: frozenset(v) for k, v in result.items()}
+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
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
+12
View File
@@ -0,0 +1,12 @@
from esphome.loader import FileResource
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code emits the component count the application needs
manifest.enable_codegen()
# Only the decoder is under test; its ota platform is not in this build
manifest.resources = manifest.resources + [
FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.c"),
FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.h"),
]
@@ -0,0 +1,323 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <vector>
#include "esphome/components/esphome/ota/ota_esphome_inflate.h"
namespace esphome::testing {
// build_plain() compressed with the CLI's window (espota2.DEFLATE_WINDOW_BITS):
// DEFLATED = zlib.compress(plain, 9, wbits=-12)
// STORED = zlib.compress(plain[:300], 0, wbits=-12)
static const uint8_t DEFLATED[] = {
0xed, 0xc8, 0xf7, 0x3f, 0xd4, 0x0f, 0x03, 0x00, 0x70, 0x67, 0xaf, 0x4b, 0x67, 0x66, 0x9f, 0x90, 0x91, 0x11, 0xc2,
0x11, 0x91, 0xb8, 0xb3, 0xf7, 0x3a, 0xd9, 0x5f, 0x4e, 0x99, 0x67, 0x1e, 0xce, 0x8a, 0xac, 0xec, 0x59, 0xb8, 0xc2,
0x95, 0x5d, 0x56, 0x42, 0x67, 0x73, 0x46, 0xf6, 0xca, 0xce, 0xc8, 0xc8, 0xc8, 0x91, 0x8a, 0x7c, 0x2f, 0x7a, 0xfe,
0x86, 0xe7, 0x87, 0xe7, 0x87, 0xe7, 0xf5, 0xfa, 0xbc, 0x7f, 0x7c, 0xbb, 0x07, 0xa2, 0x1f, 0xfa, 0xf9, 0xb8, 0x43,
0xfd, 0x82, 0x5c, 0xa0, 0x6e, 0xee, 0x28, 0x6f, 0x97, 0x20, 0x77, 0xa8, 0x3b, 0x70, 0xc0, 0x01, 0x07, 0x1c, 0x70,
0xc0, 0x01, 0x07, 0x1c, 0x70, 0xc0, 0x01, 0x07, 0x1c, 0x70, 0xc0, 0x01, 0x07, 0x1c, 0x70, 0xc0, 0x01, 0x07, 0x1c,
0x70, 0xc0, 0x01, 0xf7, 0x5f, 0xdd, 0x40, 0xd4, 0x63, 0x2f, 0x03, 0xf2, 0x17, 0xb2, 0xe5, 0x69, 0xc7, 0x5a, 0x04,
0xdf, 0x46, 0x22, 0xa8, 0x4b, 0x6e, 0xd5, 0x3a, 0x80, 0x05, 0xe1, 0x81, 0x4a, 0x44, 0x56, 0x27, 0xa9, 0x21, 0xf7,
0xad, 0xd9, 0xb0, 0xd3, 0xf1, 0xa5, 0x0b, 0x6a, 0x96, 0x56, 0xb2, 0xca, 0xb5, 0xee, 0x0f, 0x96, 0x28, 0xc3, 0x9e,
0x8f, 0x6e, 0xf2, 0x84, 0xa4, 0x3b, 0x2d, 0x16, 0x64, 0x08, 0x6a, 0x28, 0x91, 0xee, 0x87, 0x1a, 0x81, 0xff, 0xed,
0x2c, 0x5f, 0xda, 0x4a, 0x48, 0x5f, 0x91, 0xf0, 0x31, 0xfe, 0x6b, 0xbd, 0x81, 0x86, 0x92, 0x33, 0x1a, 0x6c, 0x69,
0x32, 0xfb, 0x19, 0x56, 0x2c, 0xc6, 0xaa, 0xef, 0xe8, 0x16, 0x98, 0xcf, 0x98, 0xbf, 0xa0, 0x2d, 0xde, 0x7f, 0xdf,
0x4b, 0xcb, 0xf6, 0x5c, 0xaf, 0x11, 0x24, 0xf0, 0x46, 0x3e, 0x49, 0x0e, 0x67, 0x0e, 0xcf, 0xf3, 0x57, 0xca, 0xb0,
0x39, 0x55, 0xe1, 0xe7, 0xfc, 0x37, 0x29, 0xe8, 0xd7, 0xf3, 0x08, 0x2e, 0xfb, 0x7b, 0x6d, 0x3e, 0xfe, 0xe9, 0x2c,
0x68, 0xe4, 0x20, 0x88, 0x57, 0x56, 0x41, 0x3d, 0xab, 0x9b, 0xbf, 0x0c, 0x0c, 0xae, 0x51, 0x48, 0x8b, 0x72, 0xcc,
0xb8, 0xbb, 0xf3, 0x30, 0xa8, 0x5c, 0xb5, 0xa1, 0x2f, 0x07, 0x52, 0xe9, 0x36, 0xb8, 0x3c, 0xbc, 0xee, 0xbc, 0xf1,
0xa3, 0x8b, 0x81, 0xc2, 0x03, 0xbf, 0x29, 0x5a, 0x22, 0x24, 0x97, 0xf8, 0xc9, 0xb5, 0xbf, 0xd2, 0x24, 0x4a, 0x86,
0xc1, 0x2b, 0xb7, 0xb8, 0xde, 0xa7, 0xea, 0xa5, 0x1d, 0x13, 0x1c, 0x1d, 0xd3, 0x3c, 0x81, 0x60, 0x2e, 0x2f, 0x93,
0x5c, 0x78, 0x43, 0x2e, 0x39, 0x68, 0x09, 0x13, 0x09, 0x10, 0xce, 0xd6, 0x8d, 0xe9, 0x2f, 0xaf, 0x88, 0x6f, 0x99,
0x4f, 0xcd, 0xdc, 0xaa, 0xf9, 0x47, 0xdb, 0x1c, 0xb5, 0x89, 0x53, 0x10, 0x3d, 0x77, 0xac, 0x26, 0x04, 0x3a, 0x3b,
0x18, 0xf8, 0x47, 0x75, 0x56, 0xa5, 0xda, 0x70, 0xfb, 0xf7, 0x0d, 0x3b, 0x6e, 0x4b, 0x2a, 0xbc, 0x49, 0x32, 0x43,
0x95, 0x62, 0x83, 0x3d, 0xdc, 0x0a, 0x1f, 0x1d, 0xf9, 0x59, 0x6b, 0x95, 0xf0, 0x9b, 0xf5, 0x53, 0x9e, 0xb5, 0x65,
0xeb, 0x74, 0xfa, 0x81, 0x9b, 0x61, 0xa3, 0x57, 0x2f, 0xda, 0x2c, 0xcd, 0xf8, 0xb0, 0x74, 0xb9, 0x62, 0x39, 0x91,
0xd9, 0x7d, 0xb3, 0x03, 0x65, 0x2e, 0x66, 0x20, 0x18, 0xac, 0xa2, 0xeb, 0x3b, 0x35, 0x98, 0xa3, 0x28, 0x46, 0x30,
0xee, 0xd3, 0xeb, 0x47, 0x49, 0x11, 0xc5, 0xcd, 0xa0, 0xa5, 0x1c, 0x2e, 0x9d, 0x14, 0xd6, 0x46, 0x4a, 0x05, 0x7b,
0xd3, 0xb9, 0x0d, 0xeb, 0xd7, 0x6f, 0xb5, 0xf4, 0xed, 0x3f, 0x27, 0xb8, 0xec, 0x51, 0xb1, 0x6e, 0xb0, 0x14, 0xed,
0xdf, 0x90, 0x72, 0x59, 0x71, 0xd5, 0xf5, 0xf2, 0x61, 0x5f, 0xa7, 0x8a, 0xd7, 0x0f, 0x80, 0x2f, 0xe5, 0x0f, 0x45,
0xf2, 0x4d, 0xd1, 0xdc, 0xdd, 0xce, 0x46, 0xdf, 0x77, 0xf2, 0xa6, 0xa2, 0x79, 0x77, 0xf0, 0x0e, 0xaa, 0x68, 0x14,
0x85, 0x32, 0x05, 0x29, 0x75, 0x2b, 0x6c, 0xb0, 0x7c, 0x84, 0xc9, 0xec, 0x49, 0x70, 0x9b, 0x38, 0x36, 0x4c, 0xe9,
0xa5, 0xdc, 0xb1, 0xb8, 0x28, 0xd6, 0x20, 0x89, 0x8a, 0x2a, 0x62, 0xc1, 0x90, 0x3a, 0x43, 0x48, 0xaa, 0xfc, 0xec,
0x52, 0xf4, 0x02, 0xa7, 0xcd, 0x46, 0xf9, 0x78, 0x64, 0x4f, 0xad, 0x6b, 0x17, 0xdb, 0x56, 0xa2, 0xa1, 0x6a, 0xcf,
0x3b, 0x66, 0x64, 0x01, 0xc2, 0xd6, 0xae, 0x23, 0x34, 0x82, 0x60, 0x0a, 0x3a, 0xe4, 0xdd, 0x7f, 0xaa, 0x97, 0x44,
0x9a, 0x63, 0x8f, 0xaf, 0xa9, 0x97, 0x5a, 0x55, 0xcc, 0x81, 0x48, 0x83, 0xf8, 0xe6, 0xc5, 0xef, 0xdf, 0xfa, 0x5a,
0x4f, 0x7f, 0x18, 0x56, 0xc0, 0x66, 0x36, 0xad, 0x8a, 0x79, 0xab, 0xde, 0xf4, 0x7b, 0x70, 0x98, 0xea, 0xfa, 0xf4,
0xc7, 0x01, 0x31, 0x16, 0xea, 0xf1, 0x70, 0x8d, 0xee, 0x87, 0x52, 0x13, 0x01, 0x9e, 0x0c, 0xbd, 0x5a, 0x14, 0x01,
0x8e, 0xf7, 0x6e, 0xff, 0xce, 0x7f, 0xa4, 0xd1, 0xa1, 0x6a, 0x9b, 0x95, 0xb2, 0x31, 0x8a, 0x6f, 0xc8, 0xfe, 0x8c,
0x29, 0x55, 0xbc, 0xfc, 0x61, 0xd0, 0xde, 0xb0, 0xa9, 0x0c, 0x01, 0x0f, 0xba, 0x77, 0x7e, 0x77, 0xd9, 0x76, 0xa4,
0xfd, 0xab, 0x38, 0x18, 0x92, 0xec, 0xf0, 0xd3, 0x57, 0x7f, 0xf4, 0xbd, 0x65, 0xd4, 0x77, 0x8e, 0xa1, 0x92, 0x82,
0x0c, 0x7b, 0x34, 0xd3, 0x11, 0xfb, 0xc0, 0x36, 0x23, 0x8e, 0xbb, 0x89, 0xdf, 0x70, 0xdc, 0x9a, 0x76, 0x45, 0x0b,
0x22, 0xfa, 0x35, 0xdf, 0x8f, 0x45, 0x48, 0x7d, 0xed, 0xdc, 0x06, 0x01, 0x5e, 0xbb, 0xe9, 0xf9, 0x07, 0x93, 0xf6,
0x69, 0x6f, 0xf1, 0xfc, 0xfd, 0xfa, 0x41, 0xc0, 0x13, 0x9e, 0x74, 0x2d, 0x76, 0xfe, 0xa7, 0xbe, 0x4e, 0xd9, 0xaa,
0x64, 0xa6, 0xed, 0xd3, 0xad, 0xee, 0x62, 0x9d, 0x39, 0xc9, 0xb8, 0xae, 0x86, 0x31, 0x4f, 0x62, 0x57, 0xea, 0xea,
0x5a, 0xe3, 0x59, 0xd8, 0x99, 0xb3, 0xcd, 0x9f, 0x3b, 0xea, 0x58, 0x99, 0x11, 0xdc, 0x3d, 0xac, 0x58, 0xd9, 0xa6,
0xae, 0x2d, 0x07, 0x1d, 0xd7, 0xfa, 0x87, 0x78, 0xa7, 0x7f, 0x2a, 0x4f, 0x25, 0x58, 0xef, 0x53, 0x78, 0x2e, 0x93,
0xdc, 0x44, 0xcc, 0x53, 0x88, 0x77, 0x1c, 0xda, 0x14, 0xaf, 0xe1, 0x67, 0x92, 0xff, 0x36, 0x96, 0x20, 0x6f, 0x9d,
0x2c, 0x7f, 0xea, 0x31, 0xf2, 0x34, 0x50, 0xc2, 0x39, 0x84, 0xee, 0x4c, 0xbe, 0xca, 0x06, 0x6f, 0x67, 0x42, 0xea,
0x13, 0x58, 0xee, 0xdd, 0x8e, 0x29, 0x4f, 0xee, 0xd8, 0x93, 0x0d, 0x45, 0x80, 0x4d, 0xf3, 0x12, 0x79, 0xbb, 0x36,
0xa3, 0x73, 0x95, 0x95, 0xb6, 0xfc, 0x54, 0x60, 0xb2, 0xcc, 0x71, 0xaa, 0xf1, 0x6b, 0x66, 0xed, 0xba, 0x8b, 0xd6,
0x6d, 0x61, 0x79, 0x61, 0x1d, 0xb3, 0xba, 0xa6, 0x2f, 0xaa, 0xdc, 0x1d, 0xb8, 0x22, 0xd8, 0x98, 0x58, 0xed, 0x4d,
0x3c, 0xea, 0xa9, 0x37, 0x5e, 0x5e, 0x7b, 0x47, 0xa1, 0x7a, 0x39, 0x42, 0xe4, 0x3b, 0xbb, 0x69, 0x0a, 0x8b, 0x32,
0x6e, 0x63, 0xeb, 0x87, 0xf6, 0x5d, 0xaa, 0xbf, 0xbe, 0xc5, 0xb2, 0x85, 0x60, 0xdc, 0x32, 0x07, 0x85, 0x73, 0x3d,
0x96, 0x8b, 0x89, 0x71, 0x52, 0xb4, 0x93, 0xe6, 0x18, 0xad, 0xbf, 0xce, 0x21, 0x1d, 0x33, 0xb5, 0xb3, 0x35, 0x6a,
0x5b, 0xe7, 0x47, 0x13, 0x19, 0x8f, 0x53, 0xf4, 0x0c, 0x2a, 0x39, 0x16, 0x37, 0x39, 0x3b, 0x5f, 0x81, 0x51, 0xbc,
0x23, 0x92, 0x2c, 0x8d, 0xbf, 0x2f, 0xee, 0xbf, 0xed, 0x9d, 0x3f, 0xe0, 0x16, 0x21, 0x5a, 0x57, 0xa6, 0x8c, 0x58,
0x7a, 0xd5, 0xa1, 0x6d, 0xed, 0xe8, 0x90, 0x97, 0x14, 0xb4, 0x6b, 0xa5, 0x3b, 0xd7, 0x90, 0x84, 0x9e, 0x07, 0xc2,
0x7f, 0x1e, 0x08, 0xa0, 0x6f, 0x69, 0xf1, 0xcc, 0x4e, 0xca, 0x07, 0x64, 0xa2, 0xb4, 0xbc, 0x5f, 0xe0, 0xa7, 0x0e,
0x31, 0x77, 0x6f, 0x35, 0xd7, 0x66, 0x83, 0x5d, 0x64, 0x4e, 0xf1, 0x3c, 0x7a, 0xcb, 0xa5, 0xfc, 0xc9, 0x95, 0xab,
0x27, 0x30, 0x6b, 0x82, 0x57, 0xcd, 0xb0, 0x85, 0x9d, 0xc5, 0xa9, 0x0f, 0xdb, 0xe3, 0x5a, 0xc1, 0xb2, 0x3f, 0xfa,
0xea, 0xf3, 0x3a, 0xa0, 0xe4, 0xad, 0xd7, 0x14, 0xcf, 0x4f, 0xc9, 0x46, 0x09, 0x6e, 0x70, 0x27, 0xe4, 0x9b, 0x5f,
0x61, 0x4a, 0xb2, 0xfb, 0xc3, 0xf0, 0x3a, 0x53, 0x38, 0x2a, 0x59, 0x48, 0x2b, 0xab, 0x21, 0x64, 0x1a, 0x1c, 0x90,
0xb6, 0x69, 0xbb, 0x89, 0xe3, 0x9a, 0x12, 0xb7, 0xfd, 0x47, 0xcc, 0x57, 0xdb, 0x2b, 0xf6, 0x35, 0xe1, 0x79, 0xe0,
0xbf, 0x6d, 0x7f, 0x71, 0x3c, 0x21, 0x27, 0x82, 0x12, 0x05, 0xae, 0x43, 0x11, 0x0f, 0xc8, 0xe7, 0x3c, 0xf0, 0x0b,
0xe4, 0x69, 0xff, 0xb2, 0xda, 0x9e, 0x7f, 0xfe, 0xa4, 0x3f, 0xdf, 0x02, 0x58, 0xaa, 0x60, 0xd2, 0x8e, 0xd5, 0x6c,
0x92, 0x22, 0x38, 0xb1, 0xd2, 0x84, 0x80, 0xea, 0xce, 0xfe, 0x34, 0x79, 0xd6, 0x94, 0xb0, 0xad, 0x51, 0x67, 0x65,
0xf5, 0xab, 0xd5, 0x18, 0x05, 0x92, 0x8a, 0x24, 0x36, 0xc2, 0xc4, 0x99, 0xad, 0x0d, 0x26, 0xb8, 0x70, 0xea, 0x5b,
0x74, 0x35, 0x6a, 0xfa, 0xac, 0x97, 0xd8, 0x4c, 0x08, 0x6d, 0xc1, 0xfe, 0x60, 0xe4, 0xd4, 0x8f, 0x93, 0xd4, 0xaf,
0xa2, 0xec, 0xb8, 0x71, 0xf6, 0xcb, 0x3f, 0xdc, 0x3e, 0x42, 0xca, 0xe2, 0x8d, 0xb9, 0x0a, 0x2c, 0xef, 0xb6, 0x18,
0x75, 0x46, 0x8a, 0x2b, 0x21, 0xeb, 0xc5, 0x12, 0x4d, 0xd7, 0xb8, 0x8e, 0x6c, 0xec, 0x9d, 0x12, 0xde, 0xa5, 0x45,
0xab, 0xc4, 0x26, 0x47, 0xfa, 0x57, 0xad, 0x78, 0x4c, 0x5e, 0x87, 0x0b, 0x42, 0x6a, 0xbe, 0xef, 0xbd, 0xbc, 0xe5,
0xd7, 0x4c, 0x3a, 0x0f, 0x96, 0x29, 0xcb, 0x45, 0x46, 0x3f, 0x8a, 0x31, 0xda, 0x8d, 0x7f, 0x89, 0xa4, 0xd6, 0xb4,
0xe5, 0x99, 0x27, 0xa7, 0xab, 0x15, 0x82, 0x75, 0x3e, 0xc0, 0x9d, 0xae, 0x8c, 0x7f, 0x19, 0x80, 0xf5, 0xa6, 0x89,
0xbc, 0xb7, 0x36, 0x50, 0x1f, 0x63, 0xca, 0x40, 0x3e, 0xe1, 0x4c, 0x2d, 0x1e, 0x15, 0x51, 0x93, 0xf2, 0x1a, 0x97,
0x3e, 0xad, 0x5e, 0xba, 0xe2, 0xd6, 0xa7, 0x7c, 0x51, 0x45, 0xdf, 0x92, 0xbe, 0x35, 0xf3, 0xb4, 0xe8, 0x5c, 0x2e,
0x6a, 0x5c, 0xda, 0xe8, 0xf7, 0x51, 0xdc, 0xcb, 0x06, 0xd2, 0xd2, 0xa4, 0x7c, 0x7d, 0x5f, 0x20, 0xb8, 0x50, 0x04,
0xb5, 0xb1, 0x9a, 0x4e, 0xc9, 0x94, 0xe4, 0x40, 0x2b, 0x85, 0xc8, 0xd3, 0x71, 0xfa, 0x83, 0x81, 0x28, 0x53, 0xef,
0xa0, 0xb0, 0xf0, 0xc8, 0xb9, 0x52, 0xb2, 0x0f, 0x8c, 0x49, 0x93, 0xeb, 0x2a, 0x95, 0x83, 0x4f, 0x47, 0xe8, 0x0c,
0x83, 0x7b, 0x88, 0x18, 0x63, 0xa9, 0x87, 0x7f, 0xe3, 0x57, 0xa6, 0x57, 0xdd, 0x6d, 0x2a, 0x85, 0xe9, 0x79, 0x16,
0x3b, 0x07, 0x59, 0x4f, 0xed, 0x9a, 0x17, 0xe7, 0xcd, 0x35, 0xa6, 0x9b, 0x06, 0x1d, 0x18, 0x5e, 0xb2, 0x9b, 0xac,
0x18, 0xc6, 0x5b, 0xec, 0xbe, 0x29, 0xa3, 0x57, 0xc2, 0x6b, 0x3f, 0x11, 0x89, 0xd1, 0x57, 0xe4, 0xb8, 0xaf, 0x11,
0xba, 0x90, 0x93, 0x4f, 0x5e, 0x0c, 0x6b, 0x9f, 0x61, 0x5c, 0xdd, 0xb6, 0xcf, 0x18, 0xcc, 0x9a, 0x95, 0x32, 0x5f,
0x6f, 0xf1, 0x43, 0x17, 0x2d, 0xbc, 0x70, 0xbe, 0xd4, 0x76, 0x38, 0x42, 0xcc, 0xdd, 0xf0, 0xff, 0x5b, 0xe8, 0x19,
0xc9, 0xf4, 0x25, 0xda, 0xdb, 0x5f, 0xd2, 0x3c, 0xf3, 0xe0, 0xed, 0xb0, 0xa2, 0x4c, 0xdf, 0x05, 0xf2, 0x0c, 0xc4,
0x21, 0xb9, 0xc4, 0x02, 0xd2, 0x1e, 0x46, 0x2c, 0x57, 0x78, 0xb0, 0xf1, 0x85, 0x33, 0x51, 0xc5, 0x3e, 0xce, 0x69,
0x68, 0x77, 0x3e, 0xb4, 0x5a, 0x4e, 0x43, 0x89, 0x85, 0x71, 0x36, 0x84, 0x56, 0x5c, 0x6b, 0xce, 0x78, 0x4e, 0x86,
0x6a, 0x5f, 0xf1, 0x2d, 0x73, 0x4c, 0xba, 0xc1, 0x5e, 0x2b, 0x6e, 0x0d, 0xdd, 0x45, 0x1d, 0x92, 0xb4, 0x31, 0xd9,
0xce, 0x0b, 0x58, 0x67, 0xc1, 0xa6, 0x47, 0x2e, 0x5e, 0x6d, 0x6d, 0xa3, 0x78, 0xed, 0xc5, 0xcb, 0xac, 0x20, 0xb8,
0x6a, 0x14, 0x32, 0x55, 0xe8, 0xfb, 0xe0, 0xee, 0x31, 0xfc, 0x47, 0x33, 0xf6, 0xe7, 0x54, 0xd1, 0x61, 0x91, 0x74,
0x90, 0xfa, 0x44, 0x0a, 0xc8, 0xaf, 0xa2, 0xdb, 0x2c, 0x83, 0xda, 0xe8, 0xd0, 0xb7, 0x34, 0x8a, 0xc8, 0xbb, 0x86,
0x58, 0x82, 0x1a, 0x2f, 0xd6, 0xc5, 0x92, 0xe9, 0xd0, 0x21, 0x11, 0xf6, 0xeb, 0x9b, 0xed, 0xbf, 0xa9, 0x71, 0x89,
0xb2, 0x59, 0x76, 0xb3, 0xeb, 0x9c, 0xe1, 0x73, 0x79, 0x7b, 0x11, 0x3d, 0x4e, 0x33, 0xca, 0xf6, 0x7e, 0xd8, 0xcf,
0x06, 0x41, 0x39, 0xef, 0x78, 0x5a, 0x89, 0x69, 0x0c, 0xff, 0xda, 0x31, 0x16, 0x32, 0xd4, 0xe5, 0x2d, 0x8b, 0x22,
0xb9, 0x82, 0x38, 0xd8, 0x22, 0xfc, 0xe8, 0xbd, 0xbf, 0x0d, 0x4b, 0xc9, 0x0d, 0x74, 0x5e, 0x2d, 0xb2, 0x1f, 0x23,
0xa8, 0xf8, 0xa7, 0x27, 0xe1, 0x02, 0x61, 0x83, 0xc4, 0xd0, 0x63, 0x0e, 0x23, 0xf2, 0x34, 0x06, 0x69, 0xd1, 0xa3,
0xa2, 0x76, 0x76, 0x94, 0xbc, 0x1f, 0x78, 0x73, 0x5a, 0x48, 0x82, 0xb4, 0x68, 0x3d, 0x24, 0x52, 0xe8, 0x92, 0x4e,
0x58, 0xdf, 0x55, 0x3f, 0xa8, 0x48, 0x59, 0x35, 0x90, 0x9b, 0x55, 0xc9, 0xdc, 0xff, 0xcc, 0x22, 0x7c, 0xd1, 0x29,
0xe0, 0xd5, 0xd8, 0x31, 0x3d, 0x9b, 0xae, 0x38, 0x52, 0x2a, 0xa1, 0xc5, 0x86, 0x19, 0x93, 0xe7, 0x2d, 0x9b, 0xa9,
0x09, 0xbf, 0xdd, 0x89, 0x43, 0x35, 0xb2, 0x6f, 0x91, 0xba, 0x28, 0xfa, 0x2f, 0x2c, 0xab, 0xe8, 0x58, 0xe6, 0x4c,
0xbf, 0xd5, 0xaf, 0x49, 0xcd, 0x7f, 0x18, 0xa4, 0x69, 0xd3, 0xff, 0x99, 0xad, 0x9d, 0xb0, 0x93, 0x3b, 0x04, 0x01,
0xd5, 0x7d, 0x33, 0xe0, 0x66, 0xe2, 0xd4, 0xeb, 0xae, 0xcd, 0x7f, 0x66, 0xf2, 0x2c, 0xa7, 0xfe, 0x7d, 0x51, 0x8b,
0x2a, 0x25, 0xa8, 0x90, 0xb2, 0x71, 0xe5, 0x98, 0x40, 0xb8, 0xe9, 0x1f, 0x64, 0xd9, 0x90, 0xf2, 0x39, 0x25, 0x6f,
0x58, 0x35, 0x9e, 0x40, 0x25, 0x92, 0xbb, 0xa2, 0x4d, 0x45, 0xf6, 0xc3, 0x1a, 0xc6, 0xc0, 0xf6, 0xc2, 0x9b, 0x2b,
0xb5, 0xc7, 0xe9, 0xc8, 0x75, 0x03, 0x71, 0x1c, 0x76, 0x0e, 0xde, 0x5c, 0xe7, 0xf4, 0x91, 0xc4, 0x1d, 0x9f, 0xcb,
0x67, 0x1c, 0x1b, 0xc9, 0x25, 0x6d, 0x95, 0x05, 0xd7, 0x3b, 0xbf, 0x2d, 0x90, 0x96, 0xc3, 0x44, 0xa3, 0x0a, 0xee,
0x7a, 0x4c, 0x7e, 0x7f, 0xc2, 0x4e, 0x4f, 0x38, 0x88, 0xe9, 0x9f, 0x93, 0xd8, 0xd1, 0x10, 0x8c, 0x85, 0x89, 0xb2,
0xd0, 0x95, 0xc3, 0xe4, 0xd2, 0x8f, 0x2b, 0x0c, 0x82, 0xbd, 0x25, 0x74, 0x39, 0xe7, 0x3e, 0xef, 0xd2, 0x2f, 0x6f,
0xdc, 0xe0, 0x75, 0x6b, 0xf2, 0x66, 0x7d, 0x65, 0x18, 0x26, 0xb0, 0x7a, 0x7a, 0x45, 0xf0, 0xf2, 0xc4, 0xbe, 0x70,
0x6e, 0x58, 0x3a, 0x54, 0x31, 0x64, 0xf6, 0x49, 0xb5, 0xdc, 0x1c, 0x9e, 0x87, 0xdb, 0x6a, 0x92, 0x2a, 0x59, 0x73,
0x35, 0x4d, 0x59, 0xed, 0xd0, 0x8a, 0xd9, 0xac, 0xea, 0x7c, 0xc0, 0x0a, 0xfe, 0x8f, 0x29, 0xb7, 0xda, 0xdc, 0x8a,
0x55, 0x0f, 0x61, 0xc0, 0x82, 0x49, 0x4c, 0xc2, 0x51, 0x4b, 0x41, 0x7d, 0x1f, 0xf1, 0x07, 0x13, 0x23, 0x16, 0xe4,
0x88, 0x76, 0x38, 0x92, 0xbd, 0x3d, 0xe2, 0x7b, 0xab, 0x1c, 0x53, 0x35, 0xfb, 0x85, 0x76, 0x35, 0xf7, 0xfc, 0xdc,
0x3e, 0xc9, 0xae, 0x6a, 0x7c, 0x10, 0x44, 0xa5, 0xfb, 0xc4, 0x81, 0x9a, 0x6d, 0x9b, 0xf2, 0x87, 0x0a, 0x9e, 0xcd,
0x75, 0xc2, 0xd5, 0xe7, 0x0b, 0xcd, 0x5c, 0x6e, 0xf2, 0x51, 0x96, 0xa3, 0xe8, 0xd5, 0x5a, 0x17, 0xf5, 0x1a, 0x28,
0xff, 0xc8, 0xf0, 0xfc, 0x7e, 0x3b, 0xd3, 0xf3, 0x12, 0x61, 0xe9, 0x02, 0xfa, 0x7b, 0x8e, 0x6b, 0x34, 0x4a, 0x5a,
0xb6, 0xb1, 0x71, 0x7d, 0xad, 0xbc, 0x88, 0x22, 0xf6, 0x14, 0xc5, 0x57, 0x1a, 0x0b, 0xb6, 0x8b, 0xbb, 0x9a, 0x09,
0xf2, 0xe8, 0x8d, 0x32, 0xd1, 0x54, 0xa9, 0xd2, 0x9f, 0xc5, 0xf4, 0x12, 0xad, 0xb3, 0xfd, 0x52, 0xb7, 0xff, 0x0e,
0x72, 0x59, 0xcb, 0x1d, 0x8d, 0xa4, 0xf2, 0x0f, 0x39, 0x8f, 0xc9, 0x84, 0xe0, 0x5c, 0xdd, 0x9c, 0xe4, 0x42, 0x4f,
0xf2, 0x09, 0x1e, 0x05, 0xcc, 0x41, 0xdd, 0xbd, 0xc1, 0xed, 0xdc, 0x8e, 0x64, 0xc3, 0x67, 0x51, 0xe5, 0x8a, 0x9a,
0x0d, 0x09, 0xb8, 0x4b, 0xa1, 0xb2, 0xdb, 0x01, 0xde, 0xf5, 0xd1, 0x51, 0xf7, 0x9d, 0x88, 0x59, 0xfd, 0xd5, 0x13,
0x0d, 0x05, 0xd5, 0x5e, 0xfa, 0x63, 0x26, 0x25, 0xad, 0x01, 0x83, 0x6f, 0xf3, 0x7f, 0x56, 0x11, 0x89, 0xaa, 0xdb,
0x49, 0xaa, 0x8f, 0x7d, 0x10, 0x99, 0xbf, 0x8b, 0xcf, 0xf4, 0xf8, 0xc2, 0x4e, 0x36, 0xa7, 0xb1, 0xfe, 0xc5, 0xf0,
0x07, 0xc3, 0x57, 0xe3, 0xbd, 0x1e, 0xa1, 0x56, 0x46, 0x32, 0x43, 0x8f, 0x8d, 0xc7, 0x73, 0x0a, 0xa5, 0x35, 0xa9,
0x93, 0x7c, 0x53, 0x3c, 0xf2, 0x58, 0xf3, 0x6a, 0x37, 0x41, 0xb4, 0xc7, 0x9d, 0xcd, 0xb1, 0x67, 0xb7, 0xaa, 0x5f,
0x25, 0xb9, 0xc5, 0xb6, 0xa2, 0xdb, 0xce, 0x34, 0x2c, 0xea, 0x93, 0x45, 0x6e, 0x7b, 0x6f, 0xb6, 0xf6, 0x89, 0x83,
0xd1, 0x9e, 0x08, 0x4b, 0x57, 0x4e, 0xd9, 0xf3, 0xe8, 0x81, 0x03, 0x5c, 0xd6, 0x6c, 0x2c, 0xea, 0x27, 0x2d, 0xfb,
0x39, 0xbb, 0x25, 0x81, 0xbb, 0x1a, 0xc3, 0xcb, 0xf5, 0x5e, 0x69, 0x1c, 0xa4, 0x8a, 0x0e, 0x4a, 0x69, 0xdf, 0xcd,
0x6c, 0xc7, 0xce, 0x48, 0xdb, 0x7a, 0x0b, 0xcb, 0x40, 0xc8, 0x59, 0x81, 0xde, 0x71, 0x3e, 0x0d, 0x41, 0xc9, 0xa2,
0x57, 0xae, 0xb7, 0x3f, 0x8b, 0x4e, 0x7c, 0xcb, 0xf9, 0xa5, 0xcb, 0xf8, 0xee, 0x52, 0xbe, 0x54, 0xa8, 0xef, 0x99,
0xb7, 0xa1, 0x0b, 0x77, 0xa2, 0xa7, 0x89, 0x47, 0x7f, 0x48, 0xaa, 0x02, 0x36, 0xbf, 0x9d, 0x97, 0xba, 0x43, 0x5e,
0x39, 0xa6, 0x99, 0x81, 0xde, 0xe4, 0x98, 0x06, 0x6a, 0xc8, 0x2c, 0x96, 0xf5, 0x51, 0x8c, 0x95, 0xd3, 0xe4, 0x2b,
0x94, 0x9a, 0x15, 0x71, 0x9f, 0xa4, 0x24, 0xfc, 0x92, 0x97, 0x18, 0xb7, 0x56, 0xe5, 0x59, 0xe1, 0x1a, 0x79, 0x3a,
0x47, 0x77, 0x92, 0xf5, 0x75, 0x42, 0xbd, 0x2d, 0x55, 0x05, 0xcf, 0xe8, 0xea, 0x64, 0x8b, 0xff, 0x30, 0xa1, 0x1e,
0x28, 0x2c, 0xa9, 0xaa, 0xd4, 0x19, 0x1d, 0x24, 0x7a, 0xcf, 0x5d, 0x39, 0xf2, 0xe3, 0xd2, 0xab, 0x26, 0x97, 0x35,
0xf6, 0x70, 0x8b, 0x09, 0x8b, 0x00, 0xb9, 0xb8, 0xa8, 0xb7, 0x78, 0xf1, 0x96, 0x03, 0xd2, 0x72, 0x46, 0x26, 0x82,
0x41, 0xce, 0xe3, 0x73, 0xb2, 0x31, 0x4f, 0x49, 0xc8, 0xa8, 0xcf, 0xcc, 0xb8, 0xcc, 0x5d, 0xab, 0xd1, 0x1e, 0xf5,
0x5a, 0x9a, 0x54, 0xaf, 0x15, 0xed, 0x5f, 0x96, 0x2b, 0x62, 0x0a, 0x4c, 0x67, 0x21, 0x98, 0xfd, 0x29, 0xf8, 0x12,
0x9d, 0xc2, 0x34, 0x4d, 0x70, 0x0e, 0x3b, 0x5e, 0xe3, 0x91, 0x58, 0x82, 0xa8, 0x9c, 0x7d, 0x11, 0xfb, 0x5d, 0x81,
0x0c, 0x0f, 0x9d, 0x84, 0xbb, 0xeb, 0x65, 0x1d, 0xeb, 0x6e, 0x18, 0x8e, 0x93, 0x70, 0xbb, 0x9d, 0xed, 0x58, 0xb2,
0x64, 0xf2, 0x7b, 0x8f, 0x05, 0xd8, 0xfa, 0x55, 0x50, 0xef, 0x7a, 0x49, 0x0d, 0xdf, 0xca, 0x63, 0xff, 0x41, 0x1d,
0x89, 0x79, 0xca, 0x8c, 0x1b, 0xbe, 0x75, 0x6f, 0x0e, 0x6b, 0x68, 0x8c, 0x5c, 0x93, 0xc8, 0xf3, 0x62, 0x0b, 0x68,
0xb1, 0x32, 0x2f, 0x1e, 0xda, 0xe5, 0x6d, 0x45, 0xaf, 0x3d, 0xa7, 0xaf, 0x81, 0x0f, 0xda, 0x82, 0x85, 0x94, 0x06,
0xc5, 0x3f, 0xf5, 0x73, 0x5f, 0xa4, 0x43, 0x5f, 0xb3, 0xac, 0x3d, 0x16, 0xe8, 0x6f, 0x84, 0xb3, 0xb6, 0x75, 0xf7,
0x13, 0x03, 0x46, 0x67, 0x87, 0x9a, 0xf1, 0x6c, 0x2e, 0x33, 0x33, 0x14, 0x85, 0x18, 0xe9, 0xd6, 0x1b, 0xee, 0x17,
0xe9, 0xe7, 0x6b, 0xd4, 0x46, 0xaf, 0x26, 0x55, 0x8d, 0xfc, 0x3a, 0xb4, 0x61, 0x1a, 0x9e, 0x2d, 0x85, 0xa2, 0x86,
0x8c, 0x92, 0xbd, 0x8d, 0x0e, 0x4b, 0x4e, 0x0d, 0xdf, 0x4a, 0xc6, 0x66, 0xcd, 0x28, 0xff, 0xc4, 0x44, 0xba, 0x39,
0x4a, 0x04, 0x43, 0xce, 0x95, 0x47, 0x99, 0x8c, 0x9b, 0xb7, 0x72, 0x48, 0xdd, 0x9b, 0x98, 0x4f, 0xd5, 0x18, 0x81,
0xb4, 0xef, 0xfa, 0x96, 0x54, 0x9b, 0xda, 0x73, 0xe2, 0xe0, 0x27, 0x2f, 0x10, 0x0f, 0xf9, 0x0e, 0x24, 0xb4, 0xd2,
0x39, 0xf7, 0x49, 0x8c, 0x5c, 0x05, 0x37, 0xdd, 0xa7, 0x73, 0xac, 0x16, 0x85, 0x71, 0x46, 0xd1, 0x6f, 0x7a, 0x25,
0xd2, 0x5c, 0x4e, 0x70, 0x26, 0x93, 0x36, 0x22, 0x5f, 0xe5, 0x42, 0x4e, 0x89, 0xf2, 0x4c, 0x55, 0x9b, 0xd1, 0xa5,
0xaf, 0x87, 0xd8, 0x7e, 0xe8, 0xdd, 0xb9, 0x47, 0xb3, 0xda, 0xf7, 0x98, 0x52, 0x73, 0x8b, 0x3c, 0x0b, 0xe7, 0xfa,
0x14, 0x7f, 0x9a, 0xa6, 0x8e, 0xf4, 0x7c, 0xad, 0x58, 0x11, 0x73, 0xe3, 0x3a, 0x56, 0x47, 0x9b, 0x95, 0x24, 0x68,
0x35, 0xa9, 0xd9, 0x6e, 0x5c, 0x4d, 0xe8, 0xa4, 0xb8, 0x80, 0x0b, 0x73, 0xc4, 0x0f, 0x87, 0x0c, 0xa1, 0xf4, 0x5a,
0x3c, 0xce, 0x3f, 0x30, 0x3d, 0xb5, 0xc1, 0x0b, 0x43, 0x2a, 0xb1, 0xbe, 0x6b, 0x12, 0xc4, 0x4b, 0xdb, 0xcb, 0x2f,
0x77, 0x8a, 0x3a, 0xba, 0xf4, 0xa3, 0x9b, 0x05, 0xee, 0x98, 0x50, 0x77, 0xfe, 0x82, 0x5b, 0x29, 0x6d, 0x7c, 0x0c,
0x4b, 0x41, 0xa2, 0x6a, 0xb2, 0x27, 0x32, 0xf1, 0x0c, 0x1d, 0xd7, 0x6c, 0x02, 0x4d, 0xbd, 0xf2, 0x5a, 0x66, 0xd3,
0xb5, 0x30, 0xab, 0x4b, 0x7e, 0x72, 0x77, 0x30, 0x42, 0xd7, 0x70, 0x05, 0x9f, 0xeb, 0x2f, 0x22, 0x7f, 0x49, 0x2d,
0x5d, 0xcf, 0x4b, 0x84, 0x0c, 0xa5, 0xb9, 0xe5, 0x49, 0xae, 0x54, 0x2c, 0x86, 0x53, 0x4c, 0x9c, 0x42, 0x29, 0x78,
0xf0, 0x0a, 0x8a, 0x36, 0x53, 0x4d, 0x2b, 0xc9, 0xe2, 0x69, 0x0e, 0x9a, 0x37, 0x0c, 0x91, 0x2d, 0xac, 0x2f, 0x43,
0x6a, 0xf1, 0xe9, 0xfb, 0xb4, 0x68, 0xec, 0x90, 0x00, 0xff, 0x32, 0xf8, 0x58, 0xab, 0xd1, 0x73, 0x9f, 0x77, 0x72,
0x22, 0xed, 0x64, 0xac, 0x7d, 0xd0, 0xa8, 0x1f, 0x01, 0x99, 0x9f, 0xfd, 0xa6, 0xcb, 0x62, 0xca, 0x65, 0xd8, 0xf8,
0x31, 0xf0, 0xc1, 0x5e, 0xef, 0xf1, 0x4d, 0x79, 0xa9, 0xfc, 0x8f, 0x57, 0xcb, 0xc1, 0xb7, 0x03, 0x30, 0xbb, 0xad,
0xdb, 0x88, 0xf7, 0xcc, 0xa1, 0x25, 0xfd, 0x37, 0x03, 0xc5, 0xdb, 0x7f, 0xb4, 0xe0, 0xe6, 0xa3, 0x7e, 0xa4, 0x52,
0x68, 0xa7, 0x2e, 0xe6, 0x1c, 0xea, 0x91, 0x48, 0x67, 0xbd, 0x3d, 0x6d, 0xcc, 0x09, 0x49, 0x17, 0x50, 0x16, 0xb5,
0x65, 0x63, 0xd4, 0x84, 0x2c, 0x3a, 0x5f, 0x6a, 0x3c, 0x66, 0xfa, 0x24, 0xaf, 0xd1, 0xfd, 0x18, 0x66, 0xe4, 0xe8,
0x3c, 0x6c, 0x74, 0xb6, 0xc5, 0x4c, 0xf8, 0xa8, 0x15, 0x3c, 0xd0, 0xb8, 0x4a, 0x53, 0x15, 0x4b, 0x1e, 0x56, 0x7f,
0xd8, 0x40, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xfe, 0xe7, 0xdc, 0x03, 0xd1, 0x0f, 0xfd, 0x7c, 0xdc, 0xa1, 0x7e, 0x41, 0x2e, 0x50, 0x37, 0x77, 0x94, 0xb7, 0x4b,
0x90, 0x3b, 0x14, 0x38, 0xe0, 0x80, 0x03, 0x0e, 0x38, 0xe0, 0x80, 0xfb, 0xff, 0xba, 0xff, 0x00,
};
static const uint8_t STORED[] = {
0x01, 0x2c, 0x01, 0xd3, 0xfe, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64,
0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61,
0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f,
0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65,
0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f,
0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70,
0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65,
0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65,
0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61,
0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66,
0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64,
0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61,
0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f,
0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65,
0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f,
0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70,
0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20,
};
static constexpr size_t WINDOW = 4096;
static constexpr size_t PLAIN_SIZE = 16000;
static uint8_t lcg_next(uint32_t &x) {
x = (x * 1103515245u + 12345u) & 0x7fffffffu;
return (x >> 16) & 0xff;
}
static std::vector<uint8_t> build_plain() {
std::vector<uint8_t> plain;
const char *text = "esphome ota deflate ";
for (int i = 0; i < 300; i++)
plain.insert(plain.end(), text, text + strlen(text));
uint32_t x = 1;
for (int i = 0; i < 3000; i++)
plain.push_back(lcg_next(x));
plain.insert(plain.end(), 5000, 0);
for (int i = 0; i < 100; i++)
plain.insert(plain.end(), text, text + strlen(text));
return plain;
}
// Mirrors the OTA session: chunked input through the read callback, window as output
struct Session : OtaInflateState {
const uint8_t *in;
size_t in_len;
size_t in_pos;
size_t chunk;
std::vector<uint8_t> out;
uint8_t window[WINDOW];
};
static int read_cb(OtaInflateState *d) {
auto *s = static_cast<Session *>(d);
if (s->in_pos >= s->in_len)
return -1;
size_t n = std::min(s->chunk, s->in_len - s->in_pos);
d->source = s->in + s->in_pos + 1;
d->source_limit = s->in + s->in_pos + n;
s->in_pos += n;
return s->in[s->in_pos - n];
}
// Inflates the whole input; returns the decoder result and fills s.out
static int inflate_all(Session &s, const uint8_t *in, size_t in_len, size_t chunk) {
s.in = in;
s.in_len = in_len;
s.in_pos = 0;
s.chunk = chunk;
s.out.clear();
memset(s.window, 0, sizeof(s.window));
ota_inflate_init(&s, s.window, WINDOW);
s.source_read_cb = read_cb;
int res;
do {
s.dest = s.window;
s.dest_limit = s.window + WINDOW;
res = ota_inflate(&s);
if (res < 0 || s.eof)
return res < 0 ? res : OTA_INFLATE_DATA_ERROR;
s.out.insert(s.out.end(), s.window, s.dest);
if (s.out.size() > PLAIN_SIZE)
return OTA_INFLATE_DATA_ERROR;
} while (res != OTA_INFLATE_DONE);
return res;
}
TEST(OtaInflate, RoundTripThroughWindow) {
auto s = std::make_unique<Session>();
ASSERT_EQ(inflate_all(*s, DEFLATED, sizeof(DEFLATED), 1040), OTA_INFLATE_DONE);
EXPECT_EQ(s->out, build_plain());
EXPECT_EQ(s->in_pos, sizeof(DEFLATED));
}
TEST(OtaInflate, SmallReadChunks) {
auto s = std::make_unique<Session>();
ASSERT_EQ(inflate_all(*s, DEFLATED, sizeof(DEFLATED), 7), OTA_INFLATE_DONE);
EXPECT_EQ(s->out, build_plain());
}
TEST(OtaInflate, StoredBlock) {
auto s = std::make_unique<Session>();
ASSERT_EQ(inflate_all(*s, STORED, sizeof(STORED), 64), OTA_INFLATE_DONE);
auto plain = build_plain();
plain.resize(300);
EXPECT_EQ(s->out, plain);
}
TEST(OtaInflate, TruncatedStreamFails) {
auto s = std::make_unique<Session>();
for (size_t cut : {size_t{1}, size_t{100}, size_t{1000}, sizeof(DEFLATED) - 1}) {
EXPECT_LT(inflate_all(*s, DEFLATED, cut, 1040), 0) << "cut at " << cut;
EXPECT_LE(s->out.size(), PLAIN_SIZE);
}
}
TEST(OtaInflate, TruncatedStoredBlockFails) {
auto s = std::make_unique<Session>();
EXPECT_LT(inflate_all(*s, STORED, sizeof(STORED) - 50, 64), 0);
}
TEST(OtaInflate, CorruptStreamsNeverEscapeTheWindow) {
// Flipped bytes and garbage; the sanitizers check the decoder stays in bounds
auto s = std::make_unique<Session>();
std::vector<uint8_t> bad(DEFLATED, DEFLATED + sizeof(DEFLATED));
for (size_t i = 0; i < bad.size(); i += 3) {
bad[i] ^= 0x5a;
inflate_all(*s, bad.data(), bad.size(), 1040);
bad[i] ^= 0x5a;
}
uint32_t x = 99;
std::vector<uint8_t> garbage(2000);
for (int round = 0; round < 50; round++) {
for (auto &b : garbage)
b = lcg_next(x);
inflate_all(*s, garbage.data(), garbage.size(), 1040);
}
}
} // namespace esphome::testing
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -14,7 +14,7 @@ struct MinimalBackend {
OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_OK; }
OTAResponseTypes end() { return OTA_RESPONSE_OK; }
void abort() {}
bool supports_compression() { return false; }
static constexpr bool supports_compression() { return false; }
};
static_assert(OTABackendContract<MinimalBackend>);
@@ -0,0 +1,29 @@
# Tuya without any network component (no wifi/ethernet/api), as used on
# serial-only or BLE-only Tuya MCU boards. Regression test for
# https://github.com/esphome/esphome/issues/18942
substitutions:
status_pin: P6
packages:
uart: !include ../../test_build_components/common/uart/bk72xx-ard.yaml
tuya:
status_pin: ${status_pin}
binary_sensor:
- platform: tuya
id: tuya_presence
sensor_datapoint: 101
sensor:
- platform: tuya
id: tuya_light_intensity
sensor_datapoint: 103
number:
- platform: tuya
id: tuya_far_detection
number_datapoint: 109
min_value: 0
max_value: 600
step: 1
+7
View File
@@ -21,6 +21,13 @@ The `yaml_config` fixture automatically loads YAML configurations based on the t
- The fixture file must exist or the test will fail with a clear error message
- The fixture automatically injects a dynamic port number into the API configuration
Tests marked `@pytest.mark.shared_yaml("name")` load `fixtures/name.yaml` instead
of the test-named file and compile it in a shared, hash-keyed build directory, so
the whole group pays one full compile and each test only a relink. The marker
argument must be a single-line string literal (CI test selection maps fixtures to
test files by scanning for it), and marked tests must hand the `yaml_config`
content to `run_compiled` unmodified.
### Key Fixtures
- `run_compiled` - Combines write, compile, and run operations into a single context manager
+335 -81
View File
@@ -4,17 +4,22 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
import fcntl
from functools import cache
import hashlib
import logging
import os
from pathlib import Path
import platform
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
from typing import TextIO
from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic
@@ -23,7 +28,13 @@ import pytest_asyncio
import esphome.config
from esphome.core import CORE
from esphome.helpers import get_usable_cpu_count
from esphome.helpers import (
get_usable_cpu_count,
read_file,
rmtree,
write_file,
write_file_if_changed,
)
from esphome.platformio.toolchain import get_idedata
from .const import (
@@ -56,6 +67,21 @@ import pty # not available on Windows
pytest.register_assert_rewrite("tests.integration.entity_utils")
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers",
"shared_yaml(name): load fixtures/<name>.yaml and compile it in a shared, "
"hash-keyed incremental build directory",
)
FIXTURES_DIR = Path(__file__).parent / "fixtures"
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
"""Get environment variables for PlatformIO with shared cache."""
env = os.environ.copy()
@@ -78,7 +104,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
)
# Compile with THIS tree's esphome sources, not wherever the venv's editable
# install points (which may be a different git worktree or checkout).
repo_root = str(Path(__file__).resolve().parent.parent.parent)
repo_root = str(REPO_ROOT)
existing = env.get("PYTHONPATH")
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root
return env
@@ -88,8 +114,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
def shared_platformio_cache() -> Generator[Path]:
"""Initialize a shared PlatformIO cache for all integration tests."""
# Use a dedicated directory for integration tests to avoid conflicts.
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
test_cache_dir = Path.home() / ".esphome-integration-tests"
test_cache_dir = INTEGRATION_TESTS_ROOT
cache_dir = test_cache_dir / "platformio"
# Use a lock file in the home directory to ensure only one process initializes the cache
@@ -112,7 +137,9 @@ def shared_platformio_cache() -> Generator[Path]:
init_dir = Path(tmpdir)
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
config_path = init_dir / "cache_init.yaml"
config_path.write_text(fixture_path.read_text())
config_path.write_text(
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
)
# Run compilation to populate the cache
# We must succeed here to avoid race conditions where multiple
@@ -162,13 +189,6 @@ def integration_test_dir() -> Generator[Path]:
yield Path(tmpdir)
@pytest.fixture
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Host preferences persist per device name; give the test its own so a
provisioned key never leaks into another run."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
@pytest.fixture
def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]:
"""Reserve an unused TCP port by holding the socket open."""
@@ -188,21 +208,29 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int:
return reserved_tcp_port[0]
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Give every test its own host prefs dir; prefs are keyed only by device
name, which tests sharing a fixture also share."""
prefdir = tmp_path / "prefs"
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
return prefdir
@pytest_asyncio.fixture
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
"""Load YAML configuration based on test name."""
# Get the test function name
test_name: str = request.node.name
# Extract the base test name (remove test_ prefix and any parametrization)
base_name = test_name.replace("test_", "").partition("[")[0]
shared_name = _shared_yaml_name(request)
# Base test name: test_ prefix and any parametrization stripped
base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0]
# Load the fixture file
fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml"
fixture_path = FIXTURES_DIR / f"{base_name}.yaml"
if not fixture_path.exists():
raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
loop = asyncio.get_running_loop()
content = await loop.run_in_executor(None, fixture_path.read_text)
content = await loop.run_in_executor(None, read_file, fixture_path)
# Replace the port in the config if it contains api section
if "api:" in content:
@@ -226,11 +254,13 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
# Replace external component path placeholder if present
if "EXTERNAL_COMPONENT_PATH" in content:
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
external_components_path = str(FIXTURES_DIR / "external_components")
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
if shared_name is not None:
# _compile verifies the marked test compiles this content unmodified
request.node._shared_yaml_content = content
return content
@@ -240,24 +270,218 @@ async def write_yaml_config(
) -> AsyncGenerator[ConfigWriter]:
"""Write YAML configuration to a file."""
# Get the test name for default filename
test_name = request.node.name
base_name = test_name.replace("test_", "").split("[")[0]
base_name = request.node.name.replace("test_", "").partition("[")[0]
async def _write_config(content: str, filename: str | None = None) -> Path:
if filename is None:
filename = f"{base_name}.yaml"
config_path = integration_test_dir / filename
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, config_path.write_text, content)
await loop.run_in_executor(None, write_file, config_path, content)
return config_path
yield _write_config
# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale
# dirs for a fixture are pruned when its content hash changes.
SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds"
# In the dir name (not just the hash) so pruning stays inside this checkout
_REPO_KEY = hashlib.sha256(str(REPO_ROOT).encode()).hexdigest()[:8]
# Give a contended shared build lock time for a full cold compile ahead of us
_SHARED_LOCK_TIMEOUT_S = 900
_SHARED_LOCK_POLL_S = 0.1
_SHARED_LOCK_REPORT_S = 30
# Reclaims dirs orphaned by fixture renames or deleted checkouts
_STALE_BUILD_MAX_AGE_S = 30 * 24 * 3600
# ELF path per shared build dir; constant once compiled, so resolve it only once
_shared_elf_paths: dict[Path, Path] = {}
# Dirs this process already swept; pruning is session-scoped work
_pruned_dirs: set[Path] = set()
def _shared_yaml_name(request: pytest.FixtureRequest) -> str | None:
"""Name passed to the shared_yaml marker, or None when unmarked."""
marker = request.node.get_closest_marker("shared_yaml")
if marker is None:
return None
# Exactly one \w+ positional arg: the name doubles as a build dir
# component, and CI test selection (script/helpers.py) parses the same shape
if (
len(marker.args) != 1
or marker.kwargs
or not re.fullmatch(r"\w+", str(marker.args[0]))
):
raise ValueError(
"shared_yaml marker requires exactly one \\w+ fixture name literal"
)
return marker.args[0]
def _shared_build_prefix(name: str) -> str:
return f"{name}-{_REPO_KEY}-"
@cache
def _shared_build_dir(name: str) -> Path:
"""Dir keyed by checkout and fixture source, before per-test injections."""
key = hashlib.sha256((FIXTURES_DIR / f"{name}.yaml").read_bytes()).hexdigest()[:16]
return SHARED_BUILDS_ROOT / (_shared_build_prefix(name) + key)
def _read_stamp(stamp: Path, shared_dir: Path) -> Path | None:
"""ELF path recorded by the last completed compile, or None."""
try:
text = stamp.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return None
except OSError as err:
print(f"Cannot read {stamp}: {err}")
return None
if not text:
print(f"Ignoring empty stamp {stamp}")
return None
built = Path(text)
# Never trust a stamp pointing outside its own build dir as an unlink target
if shared_dir.resolve() in built.resolve().parents:
return built
print(f"Ignoring stamp {stamp} pointing outside {shared_dir}")
return None
def _unused_since(stale: Path, cutoff: float) -> bool:
"""Whether a build dir looks untouched since cutoff; unknown counts as used."""
# Newest of the .built stamp (rewritten by every completed compile) and the
# dir itself (freshened by a worker claiming the dir before locking)
newest: float | None = None
for probe in (stale / ".built", stale):
try:
mtime = probe.stat().st_mtime
except FileNotFoundError:
continue
except NotADirectoryError:
return True # a stray file where a dir should be; reclaimable
except OSError as err:
print(f"Cannot age-probe {stale}: {err}")
return False # unknown never authorizes deletion
newest = mtime if newest is None else max(newest, mtime)
return newest is not None and newest < cutoff
def _prune_stale_builds(name: str, keep: Path) -> None:
"""Remove outdated build dirs (blocking, run in executor): this checkout's
other dirs for the fixture, plus anything untouched for 30 days. Tolerates
other workers pruning the same dirs concurrently."""
cutoff = time.time() - _STALE_BUILD_MAX_AGE_S
prefix = _shared_build_prefix(name)
for stale in SHARED_BUILDS_ROOT.iterdir():
if stale == keep:
continue
same_fixture = stale.name.startswith(prefix)
if not same_fixture and not _unused_since(stale, cutoff):
continue
# Creating .lock bumps the dir mtime, so remember whether the re-probe
# under the lock can trust it
lock_preexisting = (stale / ".lock").exists()
try:
lock_file = (stale / ".lock").open("w")
except FileNotFoundError:
continue # pruned by another worker meanwhile
except NotADirectoryError:
print(f"Removing stray file {stale}")
stale.unlink(missing_ok=True)
continue
except OSError as err:
print(f"Cannot prune {stale}: {err}")
continue
with lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
continue # still in use by another run
# Re-probe under the lock: a worker freshens its dir before
# locking, so a just-claimed dir no longer looks unused. A dir
# whose .lock we just created cannot be held by anyone, and our
# own open bumped its mtime, so its pre-open probe stands
if (
lock_preexisting
and not same_fixture
and not _unused_since(stale, cutoff)
):
continue
# rmtree tolerates races; a leftover partial tree only costs a
# rebuild, since the ELF is deleted before every compile
try:
rmtree(stale)
except OSError as err:
print(f"Failed to prune {stale}: {err}")
async def _run_esphome_compile(
config_path: Path, cwd: Path, env: dict[str, str]
) -> None:
"""Run `esphome compile`, retrying up to 3 times on a segfault."""
max_retries = 3
for attempt in range(max_retries):
# Compile using subprocess, inheriting stdout/stderr to show progress
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"esphome",
"compile",
str(config_path),
cwd=cwd,
stdout=None, # Inherit stdout
stderr=None, # Inherit stderr
stdin=asyncio.subprocess.DEVNULL,
# Start in a new process group to isolate signal handling
start_new_session=True,
env=env,
close_fds=False,
)
await proc.wait()
if proc.returncode == 0:
break
if proc.returncode == -11 and attempt < max_retries - 1:
# Segfault (-11 = SIGSEGV), retry
print(
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
)
await asyncio.sleep(1) # Brief pause before retry
continue
raise RuntimeError(
f"Failed to compile {config_path}, return code: {proc.returncode}. "
f"Run with 'pytest -s' to see compilation output."
)
def _resolve_compiled_binary(config_path: Path) -> Path:
"""Load the config to learn the compiled ELF path (blocking, run in executor)."""
CORE.reset() # Reset CORE state between test runs
CORE.config_path = config_path
config = esphome.config.read_config(
{"command": "compile", "config": str(config_path)}
)
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
idedata = get_idedata(config)
binary_path = Path(idedata.firmware_elf_path)
if not binary_path.exists():
raise RuntimeError(f"Compiled binary not found at {binary_path}")
return binary_path
@pytest_asyncio.fixture
async def compile_esphome(
integration_test_dir: Path,
shared_platformio_cache: Path,
request: pytest.FixtureRequest,
) -> AsyncGenerator[CompileFunction]:
"""Compile an ESPHome configuration and return the binary path."""
@@ -265,66 +489,96 @@ async def compile_esphome(
# Use the shared PlatformIO cache for faster compilation
# This avoids re-downloading dependencies for each test
env = _get_platformio_env(shared_platformio_cache)
# Retry compilation up to 3 times if we get a segfault
max_retries = 3
for attempt in range(max_retries):
# Compile using subprocess, inheriting stdout/stderr to show progress
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"esphome",
"compile",
str(config_path),
cwd=integration_test_dir,
stdout=None, # Inherit stdout
stderr=None, # Inherit stderr
stdin=asyncio.subprocess.DEVNULL,
# Start in a new process group to isolate signal handling
start_new_session=True,
env=env,
close_fds=False,
)
await proc.wait()
if proc.returncode == 0:
# Success!
break
if proc.returncode == -11 and attempt < max_retries - 1:
# Segfault (-11 = SIGSEGV), retry
print(
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
)
await asyncio.sleep(1) # Brief pause before retry
continue
# Other error or final retry
raise RuntimeError(
f"Failed to compile {config_path}, return code: {proc.returncode}. "
f"Run with 'pytest -s' to see compilation output."
)
# Load the config to get idedata (blocking call, must use executor)
loop = asyncio.get_running_loop()
def _read_config_and_get_binary():
CORE.reset() # Reset CORE state between test runs
CORE.config_path = config_path
config = esphome.config.read_config(
{"command": "compile", "config": str(config_path)}
name = _shared_yaml_name(request)
if name is None:
await _run_esphome_compile(config_path, integration_test_dir, env)
return await loop.run_in_executor(
None, _resolve_compiled_binary, config_path
)
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
# Get the compiled binary path
idedata = get_idedata(config)
return Path(idedata.firmware_elf_path)
binary_path = await loop.run_in_executor(None, _read_config_and_get_binary)
if not binary_path.exists():
raise RuntimeError(f"Compiled binary not found at {binary_path}")
return binary_path
# Shared fixture: build in a hash-keyed dir so tests sharing a config
# pay one full compile and later only a main.cpp (port) rebuild + relink
shared_dir = _shared_build_dir(name)
shared_dir.mkdir(parents=True, exist_ok=True)
# Freshen the dir before locking so a concurrent age sweep, which
# re-probes under the lock, never reaps a dir a worker just claimed;
# if a peer reaped it already, the guarded lock open recreates it
with suppress(FileNotFoundError):
os.utime(shared_dir)
if shared_dir not in _pruned_dirs:
_pruned_dirs.add(shared_dir)
await loop.run_in_executor(None, _prune_stale_builds, name, shared_dir)
shared_config = shared_dir / f"{name}.yaml"
private_binary = integration_test_dir / f"{name}.elf"
content = await loop.run_in_executor(None, read_file, config_path)
if content != getattr(request.node, "_shared_yaml_content", None):
# The dir is keyed by the fixture source; a mutated config would be
# cached under a hash that does not describe it
raise RuntimeError(
"shared_yaml tests must compile the yaml_config content unmodified"
)
# flock serializes concurrent xdist workers; closing the fd releases it.
# Hand-rolled rather than filelock.FileLock: non-blocking retries keep
# the wait cancellable, while a blocking acquire in an executor thread
# would survive test cancellation holding the fd
try:
lock_file = (shared_dir / ".lock").open("w")
except FileNotFoundError:
# A peer run pruning divergent hashes reaped the dir between our
# mkdir and this open; recreate it and pay a full rebuild
shared_dir.mkdir(parents=True, exist_ok=True)
lock_file = (shared_dir / ".lock").open("w")
with lock_file:
start = time.monotonic()
last_report = start
while True:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
now = time.monotonic()
if now - start > _SHARED_LOCK_TIMEOUT_S:
raise RuntimeError(
f"Timed out waiting for the {shared_dir} lock"
) from None
if now - last_report >= _SHARED_LOCK_REPORT_S:
last_report = now
print(
f"Waited {now - start:.0f}s for another worker's "
f"build of {shared_dir.name}"
)
await asyncio.sleep(_SHARED_LOCK_POLL_S)
# .built carries the ELF path of the last completed compile, so
# later workers skip the config re-read in _resolve_compiled_binary
stamp = shared_dir / ".built"
if (built := _shared_elf_paths.get(shared_dir)) is None:
built = await loop.run_in_executor(None, _read_stamp, stamp, shared_dir)
# Delete the ELF before compiling: whatever exists afterwards is
# this compile's output, so no staleness check is ever needed.
# With no usable stamp, sweep any leftover at the known layout
if built is not None:
built.unlink(missing_ok=True)
else:
# Layout-agnostic: ESPHOME_BUILD_PATH can move the build tree
for leftover in shared_dir.rglob("program"):
if leftover.is_file():
leftover.unlink()
await loop.run_in_executor(
None, write_file_if_changed, shared_config, content
)
await _run_esphome_compile(shared_config, shared_dir, env)
if built is None or not built.exists():
built = await loop.run_in_executor(
None, _resolve_compiled_binary, shared_config
)
_shared_elf_paths[shared_dir] = built
await loop.run_in_executor(None, write_file, stamp, str(built))
# Copy out before unlocking: another worker may relink firmware.elf
# while this test is still running its private copy
await loop.run_in_executor(None, shutil.copy2, built, private_binary)
return private_binary
yield _compile
@@ -0,0 +1,9 @@
esphome:
name: host-ota-test
host:
api:
ota:
- platform: esphome
port: __OTA_PORT__
logger:
level: DEBUG
@@ -1,58 +0,0 @@
esphome:
name: test-batch-window-filters
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Template sensor that we'll use to publish values
sensor:
- platform: template
name: "Source Sensor"
id: source_sensor
accuracy_decimals: 2
# Batch window filters (window_size == send_every) - use streaming filters
- platform: copy
source_id: source_sensor
name: "Min Sensor"
id: min_sensor
filters:
- min:
window_size: 5
send_every: 5
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Max Sensor"
id: max_sensor
filters:
- max:
window_size: 5
send_every: 5
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Moving Avg Sensor"
id: moving_avg_sensor
filters:
- sliding_window_moving_average:
window_size: 5
send_every: 5
send_first_at: 1
# Button to trigger publishing test values
button:
- platform: template
name: "Publish Values Button"
id: publish_button
on_press:
- lambda: |-
// Publish 10 values: 1.0, 2.0, ..., 10.0
for (int i = 1; i <= 10; i++) {
id(source_sensor).publish_state(float(i));
}
@@ -1,111 +0,0 @@
esphome:
name: uart-mock-modbus-cli-rw
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Two virtual buses looped back to each other: the client's transmissions reach the server and the
# server's replies reach the client. auto_start so forwarding is active before the button fires.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_client
data: !lambda return data;
- id: virtual_uart_client
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: stored_1
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_client
id: virtual_modbus_client
role: client
turnaround_time: 10ms
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
# Writable + readable register: the read publishes what it returns, so the test can confirm the
# write half of the 0x17 ran before the read half (Modbus 6.17).
- address: 0x01
value_type: U_WORD
read_lambda: |-
id(srv_read_1).publish_state(id(stored_1));
return id(stored_1);
write_lambda: |-
id(stored_1) = x;
id(srv_write_1).publish_state(x);
return true;
# Read-only register, returned together with 0x01 by the 2-register read half.
- address: 0x02
value_type: U_WORD
read_lambda: return 0x00AA;
sensor:
# Server-side observations.
- platform: template
name: "srv_write_1"
id: srv_write_1
- platform: template
name: "srv_read_1"
id: srv_read_1
# Client-side read-back: the values the client's on_response received.
- platform: template
name: "client_read_0"
id: client_read_0
- platform: template
name: "client_read_1"
id: client_read_1
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
- modbus_client.read_write_multiple_registers:
address: 0x01
read_address: 0x0001
read_count: 2
write_address: 0x0001
values: [0x1234]
on_response:
then:
- lambda: |-
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
if (values.size() >= 2) {
id(client_read_0).publish_state(values[0]);
id(client_read_1).publish_state(values[1]);
}
@@ -1,88 +0,0 @@
esphome:
name: uart-mock-modbus-custom-pdu
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 259;
sensor:
# Plain read to confirm the controller <-> server link is up.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "plain_read"
address: 0x01
register_type: holding
value_type: U_WORD
# Custom PDU: read holding register 0x0001, count 1. The PDU is
# {function code, address hi, address lo, count hi, count lo}; the device
# address and CRC are added by the hub. The lambda parses the response payload
# (the register value, big-endian).
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "custom_read"
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
lambda: |-
if (data.size() < 2) return {};
return (float) ((data[0] << 8) | data[1]);
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -1,106 +0,0 @@
esphome:
name: uart-mock-modbus-dep-buffer
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: |-
id(reg10) = x;
return true;
# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw
# frame as words: device address + function code + data) instead of the new item->write_* API. The write
# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per
# entity no matter how many writes happen.
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "buf_number"
id: buf_number
address: 0x10
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 1000
step: 1
write_lambda: |-
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value.
payload.push_back(0x0106);
payload.push_back(0x0010);
payload.push_back((uint16_t) x);
return {};
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
sensor:
- platform: template
name: "written_value"
id: written_value
update_interval: 0.5s
lambda: "return id(reg10);"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# The test drives the writes via number_command; the mock is autostart.
@@ -1,95 +0,0 @@
esphome:
name: uart-mock-modbus-lambda-invert
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg40
type: uint16_t
initial_value: "5"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x40
value_type: U_WORD
read_lambda: return id(reg40);
write_lambda: id(reg40) = x; return true;
# An active-low holding switch: the write_lambda inverts the wire value, but the entity must still
# report the REQUESTED state. assumed_state keeps the register unpolled, so the published state comes
# only from write_state() - turning ON writes 0x0000 yet the switch shows ON.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "invert_switch"
register_type: holding
address: 0x40
assumed_state: true
write_lambda: |-
return !x;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_40"
address: 0x40
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -1,97 +0,0 @@
esphome:
name: uart-mock-modbus-lambda-write
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg30
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x30
value_type: U_WORD
read_lambda: return id(reg30);
write_lambda: id(reg30) = x; return true;
# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead
# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so
# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing
# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "cross_switch"
register_type: coil
address: 0x00
assumed_state: true
write_lambda: |-
item->write_single_register(0x30, x ? 1234 : 0);
return {};
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_30"
address: 0x30
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,233 @@
esphome:
name: uart-mock-modbus-loopback
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Shared loopback fixture (see the shared_yaml markers in the test file);
# register spaces are disjoint so each test only observes its own entities.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "100"
- id: reg11
type: uint16_t
initial_value: "200"
- id: reg12
type: uint16_t
initial_value: "300"
- id: reg13
type: uint16_t
initial_value: "0xABCD"
- id: reg30
type: uint16_t
initial_value: "0"
- id: reg40
type: uint16_t
initial_value: "5"
- id: reg50
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 259;
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: id(reg10) = x; return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: id(reg11) = x; return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: id(reg12) = x; return true;
- address: 0x13
value_type: U_WORD
read_lambda: return id(reg13);
- address: 0x30
value_type: U_WORD
read_lambda: return id(reg30);
write_lambda: id(reg30) = x; return true;
- address: 0x40
value_type: U_WORD
read_lambda: return id(reg40);
write_lambda: id(reg40) = x; return true;
- address: 0x50
value_type: U_WORD
read_lambda: return id(reg50);
write_lambda: id(reg50) = x; return true;
# Byte-based offset: 2 bytes -> register 0x11 (the old code folded it in as a
# register count, hitting 0x12). assumed_state keeps the switch write-only.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "offset_switch"
register_type: holding
address: 0x10
offset: 2
assumed_state: true
# Reading switch, byte offset 6 -> register 0x13; the pre-fix resolution (0x16)
# would draw ILLEGAL_DATA_ADDRESS and never publish.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "read_offset_switch"
register_type: holding
address: 0x10
offset: 6
bitmask: 0x1
# Coil switch whose write_lambda dispatches a holding-register write via `item`;
# returning an empty optional suppresses the default coil write.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "cross_switch"
register_type: coil
address: 0x00
assumed_state: true
write_lambda: |-
item->write_single_register(0x30, x ? 1234 : 0);
return {};
# Active-low: the write_lambda inverts the wire value but the entity must still
# report the requested state (assumed_state keeps the register unpolled).
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "invert_switch"
register_type: holding
address: 0x40
assumed_state: true
write_lambda: |-
return !x;
# Uses the deprecated buffer parameter (legacy raw frame as words); the write
# must land and the deprecation warning must fire only once per entity.
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "buf_number"
id: buf_number
address: 0x50
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 1000
step: 1
write_lambda: |-
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0050, value.
payload.push_back(0x0106);
payload.push_back(0x0050);
payload.push_back((uint16_t) x);
return {};
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "plain_read"
address: 0x01
register_type: holding
value_type: U_WORD
# Custom PDU: read holding register 0x0001; device address and CRC are added
# by the hub. The lambda parses the big-endian register value.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "custom_read"
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
lambda: |-
if (data.size() < 2) return {};
return (float) ((data[0] << 8) | data[1]);
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_10"
address: 0x10
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_11"
address: 0x11
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_12"
address: 0x12
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_30"
address: 0x30
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_40"
address: 0x40
register_type: holding
value_type: U_WORD
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
- platform: template
name: "written_value"
id: written_value
update_interval: 0.5s
lambda: "return id(reg50);"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# Nothing to start (mock is autostart); tests drive entities directly
@@ -1,5 +1,5 @@
esphome:
name: uart-mock-modbus-server-contro
name: uart-mock-modbus-mesh
host:
api:
@@ -17,13 +17,14 @@ uart:
baud_rate: 115200
port: /dev/null
# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only
# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second
# server hub. auto_start everywhere: the controller polls at boot, so the
# forwarding must already be live or early requests generate warnings.
# Every test presses Start Scenario, so all merged actions fire in every test.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
# auto_start must be true for loopback fixtures: the modbus controller
# polls on its update_interval immediately at boot, so the uart_mock
# forwarding must already be active or early requests are lost and
# generate modbus warnings.
auto_start: true
debug:
on_tx:
@@ -31,35 +32,68 @@ uart_mock:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
- id: virtual_uart_server_2
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
globals:
- id: stored_1
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_server_2
id: virtual_modbus_server_2
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
id: virtual_modbus_client
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
modbus_id: virtual_modbus_client
id: modbus_controller_1
update_interval: 1s
- address: 2
modbus_id: virtual_modbus_client
id: modbus_controller_2
update_interval: 1s
- address: 3
modbus_id: virtual_modbus_client
id: modbus_controller_3
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x01
value_type: U_WORD
@@ -103,6 +137,34 @@ modbus_server:
- address: 0x28
value_type: FP32_R
read_lambda: return 3.14;
- address: 5
modbus_id: virtual_modbus_server
registers:
# Writable + readable register: srv_write_1 plus the client's read-back
# confirm the write half of the 0x17 ran before the read half (Modbus 6.17).
- address: 0x01
value_type: U_WORD
read_lambda: return id(stored_1);
write_lambda: |-
id(stored_1) = x;
id(srv_write_1).publish_state(x);
return true;
# Read-only register, returned together with 0x01 by the 2-register read half.
- address: 0x02
value_type: U_WORD
read_lambda: return 0x00AA;
- address: 2
modbus_id: virtual_modbus_server_2
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 919;
- address: 3
modbus_id: virtual_modbus_server_2
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 929;
sensor:
- platform: modbus_controller
@@ -195,9 +257,46 @@ sensor:
address: 0x28
register_type: holding
value_type: FP32_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_2
name: "multi_reg_a"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_3
name: "multi_reg_b"
address: 0x01
register_type: holding
value_type: U_WORD
# client_read_write observations, server- and client-side.
- platform: template
name: "srv_write_1"
id: srv_write_1
- platform: template
name: "client_read_0"
id: client_read_0
- platform: template
name: "client_read_1"
id: client_read_1
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
on_press:
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
- modbus_client.read_write_multiple_registers:
address: 5
read_address: 0x0001
read_count: 2
write_address: 0x0001
values: [0x1234]
on_response:
then:
- lambda: |-
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
if (values.size() >= 2) {
id(client_read_0).publish_state(values[0]);
id(client_read_1).publish_state(values[1]);
}
@@ -1,138 +0,0 @@
esphome:
name: uart-mock-modbus-reg-offset
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "100"
- id: reg11
type: uint16_t
initial_value: "200"
- id: reg12
type: uint16_t
initial_value: "300"
- id: reg13
type: uint16_t
initial_value: "0xABCD"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: id(reg10) = x; return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: id(reg11) = x; return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: id(reg12) = x; return true;
- address: 0x13
value_type: U_WORD
read_lambda: return id(reg13);
write_lambda: id(reg13) = x; return true;
# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target
# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register
# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "offset_switch"
register_type: holding
address: 0x10
offset: 2
assumed_state: true
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
# the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "read_offset_switch"
register_type: holding
address: 0x10
offset: 6
bitmask: 0x1
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_10"
address: 0x10
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_11"
address: 0x11
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_12"
address: 0x12
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -1,124 +0,0 @@
esphome:
name: uart-mock-modbus-server-test
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_dev
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
auto_start: false
debug:
injections:
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
- delay: 100ms
# Read holding register 7 on device 2
# Reply from device 2
# Read holding register 5 on device 1 (read_after_peer_response)
inject_rx:
[
0x02,
0x03,
0x00,
0x07,
0x00,
0x01,
0x35,
0xF8,
0x02,
0x03,
0x02,
0x00,
0xF0,
0xFC,
0x00,
0x01,
0x03,
0x00,
0x05,
0x00,
0x01,
0x94,
0x0B,
]
- delay: 100ms
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
- delay: 100ms
# Read holding register 7 on device 2, with no response
# Read holding register A on device 1 (read_after_peer_timeout)
inject_rx:
[
0x02,
0x03,
0x00,
0x07,
0x00,
0x01,
0x35,
0xF8,
0x01,
0x03,
0x00,
0x0A,
0x00,
0x01,
0xA4,
0x08,
]
modbus:
uart_id: virtual_uart_dev
role: server
modbus_server:
- address: 1
registers:
- address: 0x03
value_type: U_WORD
read_lambda: |-
id(basic_read).publish_state(1);
return 1;
- address: 0x05
value_type: U_WORD
read_lambda: |-
id(read_after_peer_response).publish_state(1);
return 1;
- address: 0x0A
value_type: U_WORD
read_lambda: |-
id(read_after_peer_timeout).publish_state(1);
return 1;
sensor:
- platform: template
name: "basic_read"
id: basic_read
- platform: template
name: "read_after_peer_response"
id: read_after_peer_response
- platform: template
name: "read_after_peer_timeout"
id: read_after_peer_timeout
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_dev).start_scenario();"
@@ -1,116 +0,0 @@
esphome:
name: uart-mock-modbus-server-mult
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
# auto_start must be true for loopback fixtures: the modbus controller
# polls on its update_interval immediately at boot, so the uart_mock
# forwarding must already be active or early requests are lost and
# generate modbus warnings.
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
- id: virtual_uart_server_2
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_server_2
id: virtual_modbus_server_2
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_client
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_client
update_interval: 1s
id: modbus_controller_1
- address: 2
modbus_id: virtual_modbus_client
update_interval: 1s
id: modbus_controller_2
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 919;
- address: 2
modbus_id: virtual_modbus_server_2
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 929;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_2
name: "reg_u_word_2"
address: 0x01
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -1,5 +1,5 @@
esphome:
name: uart-mock-modbus-srv-rw
name: uart-mock-modbus-srv-injected
host:
api:
@@ -17,6 +17,8 @@ uart:
baud_rate: 115200
port: /dev/null
# Shared server-role fixture (see the shared_yaml markers in the test file);
# the injections concatenate and each test waits only on its own sensors.
uart_mock:
- id: virtual_uart_dev
baud_rate: 9600
@@ -25,18 +27,31 @@ uart_mock:
auto_start: false
debug:
injections:
# FC 0x17 Read/Write Multiple Registers on device 1:
# write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2).
# Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must
# read back the just-written 0x1234 in the same request.
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
- delay: 100ms
# Read holding register 7 on device 2, its reply, then read holding
# register 5 on device 1 (read_after_peer_response)
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
0x02, 0x03, 0x02, 0x00, 0xF0, 0xFC,
0x00, 0x01, 0x03, 0x00, 0x05, 0x00, 0x01, 0x94, 0x0B]
- delay: 100ms
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
- delay: 100ms
# Read holding register 7 on device 2 with no response, then read
# holding register A on device 1 (read_after_peer_timeout)
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08]
# FC 0x17 on device 1: write reg 0x0001 = 0x1234 then read 0x0001..0x0002;
# per Modbus 6.17 the write runs first, so 0x0001 must read back 0x1234.
- delay: 100ms
inject_rx:
[0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8]
# FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) -
# FC 0x17: write reg 0x0006 = 0x5678 (qty 1), then read reg 0x0006 (qty 1) -
# a write and read targeting a different register block.
- delay: 100ms
inject_rx:
[0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10]
[0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55]
globals:
- id: stored_1
@@ -70,8 +85,18 @@ modbus_server:
read_lambda: |-
id(rw_read_2).publish_state(0x00AA);
return 0x00AA;
# Second writable + readable register, targeted by the second request.
- address: 0x03
value_type: U_WORD
read_lambda: |-
id(basic_read).publish_state(1);
return 1;
- address: 0x05
value_type: U_WORD
read_lambda: |-
id(read_after_peer_response).publish_state(1);
return 1;
# Second writable + readable register, targeted by the second FC 0x17 request.
- address: 0x06
value_type: U_WORD
read_lambda: |-
id(rw_read_3).publish_state(id(stored_3));
@@ -80,8 +105,22 @@ modbus_server:
id(stored_3) = x;
id(rw_write_3).publish_state(x);
return true;
- address: 0x0A
value_type: U_WORD
read_lambda: |-
id(read_after_peer_timeout).publish_state(1);
return 1;
sensor:
- platform: template
name: "basic_read"
id: basic_read
- platform: template
name: "read_after_peer_response"
id: read_after_peer_response
- platform: template
name: "read_after_peer_timeout"
id: read_after_peer_timeout
- platform: template
name: "rw_write_1"
id: rw_write_1
+11 -3
View File
@@ -1,7 +1,7 @@
"""Helpers for manipulating the host platform's preferences file.
ESPHome's host platform stores preferences in
``~/.esphome/prefs/<app_name>.prefs`` using a simple binary layout that
``$ESPHOME_PREFDIR/<app_name>.prefs`` using a simple binary layout that
mirrors ``HostPreferences::sync()``:
``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry.
@@ -11,13 +11,21 @@ boot (e.g. forcing safe mode) or to clear stale state between runs.
from __future__ import annotations
import os
from pathlib import Path
import struct
def host_prefs_path(device_name: str) -> Path:
"""Return the on-disk prefs file path for a host-platform device."""
return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs"
"""Return the on-disk prefs file path for a host-platform device.
Requires ESPHOME_PREFDIR, which the autouse isolated_preferences fixture
sets; refusing the ~/.esphome/prefs fallback keeps tests off real user
data if the fixture is ever bypassed."""
prefdir = os.environ.get("ESPHOME_PREFDIR")
if not prefdir:
raise RuntimeError("ESPHOME_PREFDIR is not set; refusing the real prefs dir")
return Path(prefdir) / f"{device_name}.prefs"
def clear_host_prefs(device_name: str) -> None:
@@ -24,7 +24,6 @@ from .types import (
RunCompiledFunction,
)
pytestmark = pytest.mark.usefixtures("isolated_preferences")
NEW_KEY = PROVISIONING_PSK
+63
View File
@@ -180,10 +180,14 @@ async def test_host_ota_self_update(
)
)
staged = asyncio.Event()
inflated = asyncio.Event()
def on_log(line: str) -> None:
if "OTA staged at" in line:
staged.set()
# The host backend cannot store gzip, so the upload negotiates deflate
if "Inflated " in line and " bytes from " in line:
inflated.set()
dev.on_log(line)
async with run_binary(dev.binary_path, line_callback=on_log) as (proc, _lines):
@@ -195,6 +199,7 @@ async def test_host_ota_self_update(
await dev.ota(None, None, "espota2 reported failure")
assert staged.is_set()
assert inflated.is_set(), "upload was not deflate compressed"
async with wait_and_connect_api_client(port=dev.api_port) as client:
info_after = await client.device_info()
@@ -204,6 +209,64 @@ async def test_host_ota_self_update(
await dev.ota(None, None, "second OTA failed -- listener leaked across execv")
@pytest.mark.asyncio
async def test_host_ota_deflate(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Deflate is negotiated by default, an old client gets an uncompressed
upload, and a corrupt stream is rejected without taking the device down."""
dev = _Device(
*await _build(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
)
)
inflated: list[str] = []
errors: list[str] = []
def on_log(line: str) -> None:
if "Inflated " in line and " bytes from " in line:
inflated.append(line)
if "Inflate err" in line or "End update err" in line:
errors.append(line)
dev.on_log(line)
real_compress = espota2.zlib.compress
def corrupt_compress(data: bytes, *args: object, **kwargs: object) -> bytes:
out = bytearray(real_compress(data, *args, **kwargs))
out[len(out) // 2] ^= 0x55
return bytes(out)
async with run_binary(dev.binary_path, line_callback=on_log) as (proc, _lines):
dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
# Default: the host backend cannot store gzip, so the CLI sends deflate
await dev.ota(None, None, "deflate upload failed")
assert len(inflated) == 1, "device did not inflate the upload"
# A client that does not offer deflate is served uncompressed
monkeypatch.setattr(espota2, "CLIENT_FEATURE_SUPPORTS_DEFLATE", 0)
await dev.ota(None, None, "uncompressed upload failed")
assert len(inflated) == 1, "device inflated without a client offer"
monkeypatch.undo()
# A corrupt stream fails the upload and leaves the device running
monkeypatch.setattr(espota2.zlib, "compress", corrupt_compress)
await dev.refused_ota(None, None, "corrupt deflate stream was accepted")
monkeypatch.undo()
assert errors, "device did not report the corrupt stream"
# and it still takes a good upload afterwards
inflated_before = len(inflated)
await dev.ota(None, None, "upload after a rejected stream failed")
assert len(inflated) == inflated_before + 1
@pytest.mark.asyncio
async def test_host_ota_encrypted(
yaml_config: str,
@@ -41,15 +41,6 @@ async def _poll_until_exists(path: Path) -> None:
await asyncio.sleep(0.05)
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Path:
"""Keep host preferences per-test so this test never touches the real
~/.esphome/prefs and never races other tests over ESPHOME_PREFDIR."""
prefdir = tmp_path / "prefs"
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
return prefdir / f"{DEVICE_NAME}.prefs"
@pytest.mark.asyncio
async def test_host_preferences_suspend_resume(
yaml_config: str,
@@ -58,7 +49,7 @@ async def test_host_preferences_suspend_resume(
isolated_preferences: Path,
) -> None:
"""Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing."""
pref_file = isolated_preferences
pref_file = isolated_preferences / f"{DEVICE_NAME}.prefs"
loop = asyncio.get_running_loop()
saved_in_memory = loop.create_future()
@@ -11,14 +11,6 @@ from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left
behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs,
keyed only by device name)."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
@pytest.mark.asyncio
async def test_light_initial_state(
yaml_config: str,
+16 -7
View File
@@ -173,6 +173,7 @@ async def test_uart_mock_modbus_no_threshold(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
@pytest.mark.asyncio
async def test_uart_mock_modbus_server(
yaml_config: str,
@@ -203,6 +204,7 @@ async def test_uart_mock_modbus_server(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_read_write(
yaml_config: str,
@@ -231,8 +233,8 @@ async def test_uart_mock_modbus_server_read_write(
"rw_write_1": 4660, # 0x1234 written to reg 0x0001
"rw_read_1": 4660, # reg 0x0001 reads back the just-written value
"rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request
"rw_write_3": 22136, # 0x5678 written to reg 0x0003
"rw_read_3": 22136, # reg 0x0003 reads back the just-written value
"rw_write_3": 22136, # 0x5678 written to reg 0x0006
"rw_read_3": 22136, # reg 0x0006 reads back the just-written value
}
)
@@ -241,7 +243,8 @@ async def test_uart_mock_modbus_server_read_write(
api_client_connected() as client,
):
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
# The FC 0x17 injections fire last, behind four earlier 100ms delays
await tracker.await_all(futures, timeout=4.0)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@@ -296,6 +299,7 @@ async def test_uart_mock_modbus_server_read_write_invalid(
)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller(
yaml_config: str,
@@ -485,6 +489,7 @@ async def test_uart_mock_modbus_server_controller_bits(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller_multiple(
yaml_config: str,
@@ -495,7 +500,7 @@ async def test_uart_mock_modbus_server_controller_multiple(
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
expected_values = {"reg_u_word": 919, "reg_u_word_2": 929}
expected_values = {"multi_reg_a": 919, "multi_reg_b": 929}
tracker = SensorTracker(list(expected_values.keys()))
futures = tracker.expect_all(expected_values)
@@ -706,6 +711,7 @@ async def test_uart_mock_modbus_shared_address(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio
async def test_uart_mock_modbus_custom_pdu(
yaml_config: str,
@@ -932,6 +938,7 @@ async def test_uart_mock_modbus_broadcast_write(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio
async def test_uart_mock_modbus_client_read_write(
yaml_config: str,
@@ -947,9 +954,7 @@ async def test_uart_mock_modbus_client_read_write(
"""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
tracker = SensorTracker(
["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"]
)
tracker = SensorTracker(["srv_write_1", "client_read_0", "client_read_1"])
futures = tracker.expect_all(
{
"srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001
@@ -967,6 +972,7 @@ async def test_uart_mock_modbus_client_read_write(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio
async def test_uart_mock_modbus_register_offset(
yaml_config: str,
@@ -1022,6 +1028,7 @@ async def test_uart_mock_modbus_register_offset(
)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio
async def test_uart_mock_modbus_lambda_write(
yaml_config: str,
@@ -1058,6 +1065,7 @@ async def test_uart_mock_modbus_lambda_write(
await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio
async def test_uart_mock_modbus_lambda_invert(
yaml_config: str,
@@ -1113,6 +1121,7 @@ async def test_uart_mock_modbus_lambda_invert(
)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio
async def test_uart_mock_modbus_deprecated_write_buffer(
yaml_config: str,
+28
View File
@@ -2122,6 +2122,34 @@ def test_get_cpp_changed_components_independent_of_cwd(
) == ["time"]
def test_fixture_map_includes_shared_yaml_markers() -> None:
"""Fixtures named only by shared_yaml markers must map to their test file."""
helpers.get_fixture_to_test_files.cache_clear()
mapping = helpers.get_fixture_to_test_files()
for fixture in (
"uart_mock_modbus_loopback",
"uart_mock_modbus_mesh",
"uart_mock_modbus_server_injected",
):
assert mapping[fixture] == frozenset(
{"tests/integration/test_uart_mock_modbus.py"}
)
def test_no_orphan_integration_fixtures() -> None:
"""Every fixture must reach CI test selection; an orphan selects nothing."""
helpers.get_fixture_to_test_files.cache_clear()
mapping = helpers.get_fixture_to_test_files()
fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve()
fixtures = list(fixtures_dir.glob("*.yaml"))
assert fixtures, f"no fixtures found under {fixtures_dir}"
# cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead
orphans = [
f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping
]
assert not orphans, f"fixtures invisible to CI test selection: {orphans}"
def test_lpt_partition_balances_skewed_weights() -> None:
"""Heavy items spread across groups instead of clustering."""
items = [f"i{n}" for n in range(6)]
+17 -17
View File
@@ -35,8 +35,8 @@ def _load_script():
def test_spec_key_collapses_destinations() -> None:
"""Two specs delivering one package share a directory and one key."""
mod = _load_script()
assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c"
assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c"
assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c"
assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c"
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
"esp32async/asynctcp @ 3.5.0"
)
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
"[env:a]\n"
"platform = fake/platform@1\n"
"lib_deps =\n"
" esphome/noise-c @ 0.1.24\n"
" esphome/noise-c @ 0.1.26\n"
" ${common.lib_deps}\n"
" internal_lib\n"
"[env:b]\n"
"lib_deps =\n"
" esphome/noise-c @ 0.1.24\n"
" esphome/noise-c @ 0.1.26\n"
)
mod = _load_script()
args = Namespace(libraries=True, platforms=True, tools=False)
libs, platforms, tools = mod.parse_specs(str(ini), args)
# exact-string duplicates collapse; distinct version pins survive
assert libs == ["esphome/noise-c @ 0.1.24"]
assert libs == ["esphome/noise-c @ 0.1.26"]
assert platforms == ["fake/platform@1"]
assert tools == []
assert mod.build_cli_args(libs, platforms, tools) == [
"-l",
"esphome/noise-c @ 0.1.24",
"esphome/noise-c @ 0.1.26",
"-p",
"fake/platform@1",
]
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
mod.parallel_install(
cls,
[
"esphome/noise-c @ 0.1.24",
"esphome/noise-c @ 0.1.24",
"esphome/noise-c @ 0.1.26",
"esphome/noise-c @ 0.1.26",
"esphome/already @ 1.0",
"https://x/framework.tar.xz",
],
)
assert cls.calls == ["esphome/noise-c @ 0.1.24"]
assert cls.calls == ["esphome/noise-c @ 0.1.26"]
assert cls.lock_events == ["lock", "unlock"]
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
mod = _load_script()
cls = _reset_fake(str(tmp_path))
cls.deps = {
"esphome/noise-c @ 0.1.24": [
"esphome/noise-c @ 0.1.26": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
{"name": "SPI"},
],
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"])
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"])
assert len(cls.calls) == 3 # the shared dep installs exactly once
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
# Wave-1 strings carry no compatibility; the dependency wave does
compats = dict(cls.compat_calls)
assert compats["esphome/noise-c @ 0.1.24"] is None
assert compats["esphome/noise-c @ 0.1.26"] is None
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
assert dep_compat is not None # mirrors pio's install_dependency
@@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None:
mod = _load_script()
cls = _reset_fake(str(tmp_path))
cls.deps = {
"esphome/noise-c @ 0.1.24": [
"esphome/noise-c @ 0.1.26": [
{"name": "vendored", "version": "https://github.com/x/y.git"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"])
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
@@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
"""Already-installed top-level packages still feed the dependency
wave; a warm store can be missing a transitive dep."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"})
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"})
cls.deps = {
"esphome/noise-c @ 0.1.24": [
"esphome/noise-c @ 0.1.26": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"])
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]

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