Compare commits

..
Author SHA1 Message Date
Jesse Hills 69f2a7d556 Merge pull request #19051 from esphome/bump-2026.9.0b3
2026.9.0b3
2026-09-09 12:18:36 +12:00
Jesse Hills 008677298a Bump version to 2026.9.0b3 2026-09-09 09:54:44 +12:00
Kevin AhrendtandJesse Hills d2bc056f0a [sendspin] Add codec preference list to the media source (#19047)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-09 09:54:42 +12:00
J. Nick Koston 42fffd16fe [esphome][core] Give a lost OTA chunk ack time to be retransmitted (#19041) 2026-09-09 09:54:42 +12:00
J. Nick Koston 9c16aba6f7 [noise] Bump noise-c to 0.1.26 and libsodium to 1.10021.8 (#19030) 2026-09-09 09:54:42 +12:00
Kevin Ahrendt ac79173f4a [i2s_audio] Fix spurious driver failure (#19045) 2026-09-09 09:54:42 +12:00
Kevin Ahrendt 0a1e2acbcb [audio][i2s_audio][micro_wake_word][microphone][mixer][resampler][speaker] Replace use_count() checks with lock and null test (#19046) 2026-09-09 09:54:42 +12:00
Kevin AhrendtandCopilot Autofix powered by AI 9b6facb20d [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 09:54:42 +12:00
esphome[bot] f89b9e704c Bump bundled esphome-device-builder to 1.14.5 (#19040) 2026-09-09 09:54:42 +12:00
Johnandpre-commit-ci-lite[bot] f8b2e53609 [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-09 09:54:42 +12:00
raykholo 7660dd7fa7 [anova] Re-assert temperature unit on every poll cycle (#17141) 2026-09-09 09:54:42 +12:00
c3ce07755f [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-09 09:54:42 +12:00
Gytis f8a4cfa945 [lvgl] Add missing label dependency to qrcode, keyboard and tabview (#18387) 2026-09-09 09:54:42 +12:00
J. Nick Koston 866ddb6e57 [core] Skip PlatformIO's private-package authorization probe (#18823) 2026-09-09 09:54:42 +12:00
Jonathan Swoboda ca864c22b4 [tuya] Build without a network component (#18948) 2026-09-09 09:54:42 +12:00
Jesse Hills c9729244af [udp] Use cv.invalid for relocated packet_transport options (#19032) 2026-09-09 09:54:42 +12:00
Davide D M 442e4a1ec2 [debug] Check reboot source pref on ESP_RST_WDT and guard against empty source (#17537) 2026-09-09 09:54:41 +12:00
Pieter ViljoenandJesse Hills 199acdf522 [ble_client] Report Established from nodes that never read services (#17920)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-09 09:54:41 +12:00
Samuel SiebandSamuel Sieb 9340863652 [dallas_temp] filter 85 temp from sensor reset (#17877)
Co-authored-by: Samuel Sieb <samuel@sieb.net>
2026-09-09 09:54:41 +12:00
AndreKR d5cff6e9df [logger] Fix garbled stack traces (#17939) 2026-09-09 09:54:41 +12:00
Ryan Ronnander e7f45a0d31 [mqtt] Restore brightness flag in light discovery (#18950) 2026-09-09 09:54:41 +12:00
mipa87andClaude 628ebe23ec [audio] Do not treat MP3_STREAM_INFO_CHANGED as a fatal decoder error (#19028)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-09 09:54:41 +12:00
823d79c948 [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-09 09:54:41 +12:00
68 changed files with 1830 additions and 1650 deletions
+2 -11
View File
@@ -244,20 +244,11 @@ jobs:
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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 - name: Run prek
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
with: with:
prek-version: ${{ steps.prek.outputs.version }} # Keep in sync with requirements_test.txt.
prek-version: "0.4.11"
# This job only runs on pull requests, so nothing ever populates # 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 # 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 # request copy, which is what the old seed-cache job existed to
@@ -1,94 +0,0 @@
# Keeps pre-commit hook revs in sync with the requirements files.
#
# Dependabot only bumps the pins in requirements*.txt. Some of those tools
# are pinned again as hook revs in .pre-commit-config.yaml. This workflow
# runs script/sync_dependency_versions.py against the pull request branch
# and pushes a commit with the revs updated.
name: Sync dependency versions
on:
# pull_request_target rather than pull_request so the App secret is
# available on Dependabot pull requests (pull_request runs opened by
# Dependabot only see Dependabot secrets). The job below only touches
# branches in this repository and only ever executes the script from the
# base branch checkout, so fork code never runs with the token.
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- requirements_dev.txt
- requirements_test.txt
- .pre-commit-config.yaml
- script/sync_dependency_versions.py
# The push to the pull request branch uses the App token minted below, so
# the workflow's GITHUB_TOKEN does not need any scopes.
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
sync:
name: Sync pinned versions
runs-on: ubuntu-latest
# Same-repository branches only: a push to a fork is not possible with
# this token, and it keeps untrusted heads out of a privileged job.
if: >-
github.repository == 'esphome/esphome'
&& github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
# A push made with the workflow's own GITHUB_TOKEN would not start
# CI on the new commit; a push with the App token does.
permission-contents: write # git push of the sync commit to the pull request branch
- name: Check out base branch
# Provides the script that runs below. Deliberately the base branch
# so the pull request cannot change what executes here.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Check out pull request branch
# No allow-unsafe-pr-checkout here on purpose: checkout v7 only
# refuses heads that live in a different repository, and the job
# condition above already limits runs to same-repository branches.
# Leaving it off keeps that refusal as a backstop for fork heads.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.ref }}
path: pull-request
token: ${{ steps.generate-token.outputs.token }}
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install yamlrocks
# The script edits YAML through yamlrocks. Take the pin from the
# base branch requirements so this workflow has no copy of its own.
run: pip install "$(grep -E '^yamlrocks==' requirements_test.txt | cut -d'#' -f1)"
- name: Sync pinned versions
run: python script/sync_dependency_versions.py --root pull-request
- name: Push changes
working-directory: pull-request
run: |
if git diff --quiet; then
echo "All pinned versions already match the requirements files."
exit 0
fi
git config user.name "esphome[bot]"
git config user.email "115708604+esphome[bot]@users.noreply.github.com"
git commit -am "Sync pinned tool versions with requirements files"
git push
+3 -2
View File
@@ -1,6 +1,7 @@
--- ---
# See https://pre-commit.com for more information # See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks # See https://pre-commit.com/hooks.html for more hooks
ci: ci:
autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_commit_msg: 'pre-commit: autoupdate'
autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit
@@ -10,7 +11,7 @@ ci:
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.16.6 rev: v0.16.3
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff
@@ -41,7 +42,7 @@ repos:
- id: pyupgrade - id: pyupgrade
args: [--py312-plus] args: [--py312-plus]
- repo: https://github.com/adrienverge/yamllint.git - repo: https://github.com/adrienverge/yamllint.git
rev: v1.38.0 rev: v1.37.1
hooks: hooks:
- id: yamllint - id: yamllint
exclude: ^(\.clang-format|\.clang-tidy)$ exclude: ^(\.clang-format|\.clang-tidy)$
+1 -2
View File
@@ -553,7 +553,6 @@ file does, and it is the authority when they disagree. The most useful starting
4. **Lint:** Run `prek` to ensure code is compliant. 4. **Lint:** Run `prek` to ensure code is compliant.
5. **Commit:** Commit your changes. There is no strict format for commit messages. 5. **Commit:** Commit your changes. There is no strict format for commit messages.
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
7. **Comments:** When commenting on GitHub PRs or issues, don't tag contributors, especially bots. Avoid referring to list items (e.g. from reviews) with the form #nn - this will be interpreted by GitHub as a reference to issue or PR nn. Keep comments short and exclude irrelevant details, backstories, restatement of previous comments and anything that is already obvious to the reader.
* **Documentation Contributions:** * **Documentation Contributions:**
* Documentation is hosted in the separate `esphome/esphome.io` repository. * Documentation is hosted in the separate `esphome/esphome.io` repository.
@@ -840,7 +839,7 @@ file does, and it is the authority when they disagree. The most useful starting
cv.rename_key( cv.rename_key(
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component" 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: For other deprecations, warn manually during validation:
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version # could be handy for archiving the generated documentation or if some version
# control system is used. # control system is used.
PROJECT_NUMBER = 2026.10.0-dev PROJECT_NUMBER = 2026.9.0b3
# Using the PROJECT_BRIEF tag one can provide an optional one line description # Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a # for a project that appears at the top of each page and should give viewer a
+3 -1
View File
@@ -23,7 +23,9 @@ from esphome.util import safe_print
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable 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__) _LOGGER = logging.getLogger(__name__)
@@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
if (current_audio_file_ != nullptr) { if (current_audio_file_ != nullptr) {
// A transfer buffer isn't ncessary for a local file // A transfer buffer isn't ncessary for a local file
this->file_ring_buffer_ = output_ring_buffer.lock(); this->file_ring_buffer_ = output_ring_buffer.lock();
if (this->file_ring_buffer_ == nullptr) {
return ESP_ERR_INVALID_STATE;
}
return ESP_OK; return ESP_OK;
} }
@@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le
void AudioTransferBuffer::clear_buffered_data() { void AudioTransferBuffer::clear_buffered_data() {
this->buffer_length_ = 0; this->buffer_length_ = 0;
if (this->ring_buffer_.use_count() > 0) { if (this->ring_buffer_ != nullptr) {
this->ring_buffer_->reset(); this->ring_buffer_->reset();
} }
} }
void AudioSinkTransferBuffer::clear_buffered_data() { void AudioSinkTransferBuffer::clear_buffered_data() {
this->buffer_length_ = 0; this->buffer_length_ = 0;
if (this->ring_buffer_.use_count() > 0) { if (this->ring_buffer_ != nullptr) {
this->ring_buffer_->reset(); this->ring_buffer_->reset();
} }
#ifdef USE_SPEAKER #ifdef USE_SPEAKER
@@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() {
} }
bool AudioTransferBuffer::has_buffered_data() const { 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->ring_buffer_->available() > 0) || (this->available() > 0));
} }
return (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_to_read = AudioTransferBuffer::free();
size_t bytes_read = 0; size_t bytes_read = 0;
if (bytes_to_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); 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); bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait);
} else } else
#endif #endif
if (this->ring_buffer_.use_count() > 0) { if (this->ring_buffer_ != nullptr) {
bytes_written = bytes_written =
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait); this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
} else if (this->sink_callback_ != nullptr) { } 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)); return (this->speaker_->has_buffered_data() || (this->available() > 0));
} }
#endif #endif
if (this->ring_buffer_.use_count() > 0) { if (this->ring_buffer_ != nullptr) {
return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
} }
return (this->available() > 0); return (this->available() > 0);
+5 -1
View File
@@ -23,7 +23,11 @@ from esphome.const import (
) )
from esphome.types import ConfigType from esphome.types import ConfigType
from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import from . import ( # noqa: F401 pylint: disable=unused-import
CONF_DEBUG_ID,
FILTER_SOURCE_FILES,
DebugComponent,
)
DEPENDENCIES = ["debug"] DEPENDENCIES = ["debug"]
+5 -1
View File
@@ -9,7 +9,11 @@ from esphome.const import (
) )
from esphome.types import ConfigType from esphome.types import ConfigType
from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import from . import ( # noqa: F401 pylint: disable=unused-import
CONF_DEBUG_ID,
FILTER_SOURCE_FILES,
DebugComponent,
)
DEPENDENCIES = ["debug"] DEPENDENCIES = ["debug"]
+9 -2
View File
@@ -3,11 +3,18 @@ import esphome.codegen as cg
# Re-exported for the many esp32-side users; defined in esphome.const # 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 # and esphome.espidf so the upload/logs fast path can use them without
# importing this package. # importing this package.
from esphome.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION, KEY_VARIANT # noqa: F401 # pylint: disable=unused-import from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
KEY_ESP32,
KEY_FLASH_SIZE,
KEY_IDF_VERSION,
KEY_VARIANT,
)
# Back compat for external components only; in-tree callers import it # Back compat for external components only; in-tree callers import it
# from esphome.espidf directly. # from esphome.espidf directly.
from esphome.espidf import variant_to_idf_target # noqa: F401 # pylint: disable=unused-import from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import
variant_to_idf_target,
)
KEY_BOARD = "board" KEY_BOARD = "board"
KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options"
@@ -41,7 +41,10 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
#endif #endif
static constexpr uint16_t OTA_BLOCK_SIZE = 8192; 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_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer // Milliseconds for data transfer. Covers the lwIP retransmit run seen in
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
// Single-instance pointer — multi-port configs are rejected in final_validate. // Single-instance pointer — multi-port configs are rejected in final_validate.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
@@ -118,21 +118,24 @@ void I2SAudioSpeakerBase::loop() {
break; 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) { if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
this->status_momentary_error("driver-failure", 1000); this->status_momentary_error("driver-failure", 1000);
break; break;
} }
if (this->speaker_task_handle_ == nullptr) { xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, &this->speaker_task_handle_);
&this->speaker_task_handle_);
if (this->speaker_task_handle_ == nullptr) { if (this->speaker_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
this->status_momentary_error("task-failure", 1000); this->status_momentary_error("task-failure", 1000);
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
}
} }
break; break;
case speaker::STATE_RUNNING: // Intentional fallthrough case speaker::STATE_RUNNING: // Intentional fallthrough
@@ -218,8 +221,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t
} }
bool I2SAudioSpeakerBase::has_buffered_data() const { 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 temp_ring_buffer->available() > 0;
} }
return false; return false;
@@ -129,7 +129,7 @@ void MicroWakeWord::setup() {
return; return;
} }
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock(); 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 // 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. // 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. // 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); 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"); ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
this->inference_task_.deallocate();
xEventGroupClearBits(this->event_group_, ALL_BITS); xEventGroupClearBits(this->event_group_, ALL_BITS);
xQueueReset(this->detection_queue_); xQueueReset(this->detection_queue_);
this->set_state_(State::STOPPED); this->set_state_(State::STOPPED);
@@ -48,7 +48,7 @@ class MicrophoneSource final {
template<typename F> void add_data_callback(F &&data_callback) { template<typename F> void add_data_callback(F &&data_callback) {
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) { this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
if (this->enabled_ || this->passive_) { if (this->enabled_ || this->passive_) {
if (this->processed_samples_.use_count() == 0) { if (this->processed_samples_ == nullptr) {
// Create vector if its unused // Create vector if its unused
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>(); this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
} }
+2 -2
View File
@@ -35,8 +35,8 @@ void MipiDsi::setup() {
.bus_id = 0, // index from 0, specify the DSI host to use .bus_id = 0, // index from 0, specify the DSI host to use
.num_data_lanes = .num_data_lanes =
this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability
// phy_clk_src left at 0 to enable runtime auto-select. .phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, // Clock source for the DPHY
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps .lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
}; };
auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_); auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_);
if (err != ESP_OK) { if (err != ESP_OK) {
@@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_
} }
size_t bytes_written = 0; size_t bytes_written = 0;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock(); 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 // Only write to the ring buffer if the reference is valid
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
if (bytes_written > 0) { if (bytes_written > 0) {
@@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() {
// avoids unnecessary single-frame splices. // avoids unnecessary single-frame splices.
const size_t ring_buffer_size = const size_t ring_buffer_size =
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; (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(); 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); temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
this->ring_buffer_ = temp_ring_buffer; this->ring_buffer_ = temp_ring_buffer;
} }
if (!temp_ring_buffer) { if (temp_ring_buffer == nullptr) {
return ESP_ERR_NO_MEM; 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); } void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
bool SourceSpeaker::has_buffered_data() const { 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) { void SourceSpeaker::set_mute_state(bool mute_state) {
@@ -382,8 +382,8 @@ void MixerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping"); ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING); xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
} }
if (event_group_bits & MIXER_TASK_STATE_STOPPED) { // Retries on a subsequent loop if the task is still running on the other core
this->task_.deallocate(); if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
ESP_LOGD(TAG, "Stopped"); ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
this->all_stopped_since_ms_ = 0; 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()) { if (speaker->is_running() && !speaker->get_pause_state()) {
// Speaker is running and not paused, so it possibly can provide audio data // 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(); 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 // No audio source allocated, so skip processing this speaker
continue; continue;
} }
+6 -1
View File
@@ -14,7 +14,12 @@ from esphome.const import (
) )
from esphome.core import CORE, TimePeriod from esphome.core import CORE, TimePeriod
from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import from . import ( # noqa: F401 pylint: disable=unused-import
FILTER_SOURCE_FILES,
Nextion,
nextion_ns,
nextion_ref,
)
from .base_component import ( from .base_component import (
CONF_AUTO_WAKE_ON_TOUCH, CONF_AUTO_WAKE_ON_TOUCH,
CONF_COMMAND_SPACING, 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: async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE") 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 # noise-c depends on libsodium, but declaring it here too lets the
# library manager see the full set up front instead of discovering # library manager see the full set up front instead of discovering
# libsodium only after noise-c has downloaded, so the two can download # libsodium only after noise-c has downloaded, so the two can download
# in parallel. The version must match noise-c's library.json. # 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 # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1")
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping"); ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
} }
if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) { // Retries on a subsequent loop if the task is still running on the other core
this->task_.deallocate(); if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) {
ESP_LOGD(TAG, "Stopped"); ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS); 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); bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
} else { } else {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock(); 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 // Only write to the ring buffer if the reference is valid
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
} else { } else {
@@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const {
bool has_ring_buffer_data = false; bool has_ring_buffer_data = false;
if (this->requires_resampling_()) { if (this->requires_resampling_()) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock(); 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); 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( 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_)); 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; err = ESP_ERR_NO_MEM;
} else { } else {
this_resampler->ring_buffer_ = temp_ring_buffer; this_resampler->ring_buffer_ = temp_ring_buffer;
+19 -7
View File
@@ -30,6 +30,7 @@ CONF_SENDSPIN_ID = "sendspin_id"
CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
CONF_FIXED_DELAY = "fixed_delay" CONF_FIXED_DELAY = "fixed_delay"
CONF_DECODE_MEMORY = "decode_memory" CONF_DECODE_MEMORY = "decode_memory"
CONF_CODECS = "codecs"
# Matches ARTWORK_MAX_SLOTS in sendspin-cpp. # Matches ARTWORK_MAX_SLOTS in sendspin-cpp.
MAX_ARTWORK_SLOTS = 4 MAX_ARTWORK_SLOTS = 4
@@ -44,6 +45,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
CODEC_FLAC = "flac"
CODEC_OPUS = "opus"
CODEC_PCM = "pcm"
CODECS = {
CODEC_FLAC: CODEC_FORMAT_FLAC,
CODEC_OPUS: CODEC_FORMAT_OPUS,
CODEC_PCM: CODEC_FORMAT_PCM,
}
# Opus only supports 48 kHz audio, so it is left out of the default list at other rates.
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
OPUS_SAMPLE_RATE = 48000
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
@@ -286,16 +301,13 @@ async def to_code(config: ConfigType) -> None:
if data.player_support: if data.player_support:
cg.add_define("USE_SENDSPIN_PLAYER", True) cg.add_define("USE_SENDSPIN_PLAYER", True)
# Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate # Configures the player role. Each configured codec is advertised for 16 bits per sample
# (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server # mono and stereo at the configured sample rate. The order is a preference order, both for
# the codecs themselves and for stereo over mono.
player_cfg = data.player_config player_cfg = data.player_config
sample_rate = player_cfg[CONF_SAMPLE_RATE] sample_rate = player_cfg[CONF_SAMPLE_RATE]
# OPUS only supports 48 kHz audio codecs = player_cfg[CONF_CODECS]
codecs = [CODEC_FORMAT_FLAC]
if sample_rate == 48000:
codecs.append(CODEC_FORMAT_OPUS)
codecs.append(CODEC_FORMAT_PCM)
def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer:
return cg.StructInitializer( return cg.StructInitializer(
@@ -13,11 +13,16 @@ from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType from esphome.types import ConfigType
from .. import ( from .. import (
CODEC_OPUS,
CODECS,
CONF_CODECS,
CONF_DECODE_MEMORY, CONF_DECODE_MEMORY,
CONF_FIXED_DELAY, CONF_FIXED_DELAY,
CONF_INITIAL_STATIC_DELAY, CONF_INITIAL_STATIC_DELAY,
CONF_SENDSPIN_ID, CONF_SENDSPIN_ID,
DEFAULT_CODECS,
MEMORY_LOCATIONS, MEMORY_LOCATIONS,
OPUS_SAMPLE_RATE,
SendspinHub, SendspinHub,
register_player_config, register_player_config,
request_controller_support, request_controller_support,
@@ -49,10 +54,32 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_(
) )
def _resolve_codecs(config: ConfigType) -> ConfigType:
"""Validate the codec preference list, filling in the default when it is not set."""
sample_rate = config[CONF_SAMPLE_RATE]
if (codecs := config.get(CONF_CODECS)) is None:
config[CONF_CODECS] = [
codec
for codec in DEFAULT_CODECS
if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE
]
return config
if len(set(codecs)) != len(codecs):
raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS])
if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}",
path=[CONF_CODECS],
)
return config
def _register(config: ConfigType) -> ConfigType: def _register(config: ConfigType) -> ConfigType:
request_controller_support() request_controller_support()
register_player_config( register_player_config(
{ {
CONF_CODECS: config[CONF_CODECS],
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
@@ -85,9 +112,13 @@ CONFIG_SCHEMA = cv.All(
min=16000, max=96000 min=16000, max=96000
), ),
cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True), cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True),
cv.Optional(CONF_CODECS): cv.All(
cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1)
),
} }
), ),
cv.only_on_esp32, cv.only_on_esp32,
_resolve_codecs,
_register, _register,
) )
@@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() {
if (!this->is_playing_) { if (!this->is_playing_) {
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks // 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()) { if (this->read_task_.is_created() || this->decode_task_.is_created()) {
this->read_task_.deallocate(); // Both are attempted every time; a task that is still running on the other core is freed by a
this->decode_task_.deallocate(); // 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_) { if (this->hard_stop_) {
// Stop command was sent, so immediately end the playback // Stop command was sent, so immediately end the playback
this->speaker_->stop(); this->speaker_->stop();
@@ -315,17 +322,17 @@ void AudioPipeline::read_task(void *params) {
if (err == ESP_OK) { if (err == ESP_OK) {
size_t file_ring_buffer_size = this_pipeline->buffer_size_; 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); temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size);
this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer; 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; err = ESP_ERR_NO_MEM;
} else { } 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_); 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_); 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) { if (err != ESP_OK) {
// Send specific error message // Send specific error message
+1 -15
View File
@@ -66,14 +66,13 @@ from esphome.const import (
) )
from esphome.core import ( from esphome.core import (
CORE, CORE,
ID,
CoroPriority, CoroPriority,
EsphomeError, EsphomeError,
HexInt, HexInt,
coroutine_with_priority, coroutine_with_priority,
) )
import esphome.final_validate as fv import esphome.final_validate as fv
from esphome.types import ConfigType, TemplateArgsType from esphome.types import ConfigType
from . import wpa2_eap from . import wpa2_eap
@@ -209,7 +208,6 @@ WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition)
WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition)
WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action) WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action)
WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action) WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action)
WiFiRoamAction = wifi_ns.class_("WiFiRoamAction", automation.Action)
WiFiConfigureAction = wifi_ns.class_( WiFiConfigureAction = wifi_ns.class_(
"WiFiConfigureAction", automation.Action, cg.Component "WiFiConfigureAction", automation.Action, cg.Component
) )
@@ -822,18 +820,6 @@ async def wifi_disable_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg) return cg.new_Pvariable(action_id, template_arg)
@automation.register_action(
"wifi.roam", WiFiRoamAction, cv.Schema({}), synchronous=True
)
async def wifi_roam_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> cg.MockObj:
return cg.new_Pvariable(action_id, template_arg)
KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results"
RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save"
RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression"
-5
View File
@@ -31,11 +31,6 @@ template<typename... Ts> class WiFiDisableAction final : public Action<Ts...> {
void play(const Ts &...x) override { global_wifi_component->disable(); } void play(const Ts &...x) override { global_wifi_component->disable(); }
}; };
template<typename... Ts> class WiFiRoamAction final : public Action<Ts...> {
public:
void play(const Ts &...x) override { global_wifi_component->force_roam_check(); }
};
template<typename... Ts> class WiFiConfigureAction final : public Action<Ts...>, public Component { template<typename... Ts> class WiFiConfigureAction final : public Action<Ts...>, public Component {
public: public:
TEMPLATABLE_VALUE(std::string, ssid) TEMPLATABLE_VALUE(std::string, ssid)
+11 -27
View File
@@ -846,18 +846,17 @@ void WiFiComponent::loop() {
this->notify_connect_state_listeners_(); this->notify_connect_state_listeners_();
#endif #endif
// Post-connect roaming: check for better AP. A scan may have been started by an // Post-connect roaming: check for better AP
// explicit force_roam_check() even when post_connect_roaming_ is disabled, so the if (this->post_connect_roaming_) {
// scan must always be consumed here to avoid leaving roaming_state_ stuck. if (this->is_roaming_scan_active()) {
if (this->is_roaming_scan_active()) { if (this->scan_done_) {
if (this->scan_done_) { this->process_roaming_scan_();
this->process_roaming_scan_(); }
// else: scan in progress, wait
} else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
this->check_roaming_(now);
} }
// else: scan in progress, wait
} else if (this->post_connect_roaming_ && this->roaming_state_ == RoamingState::IDLE &&
this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
this->check_roaming_(now);
} }
} }
break; break;
@@ -2464,17 +2463,6 @@ void WiFiComponent::notify_scan_results_listeners_() {
} }
#endif // USE_WIFI_SCAN_RESULTS_LISTENERS #endif // USE_WIFI_SCAN_RESULTS_LISTENERS
void WiFiComponent::force_roam_check() {
if (!this->is_connected() || this->roaming_state_ != RoamingState::IDLE || this->roaming_suppressed_()) {
ESP_LOGD(TAG, "Roam check requested, but not able to check now");
return;
}
// Reset the attempt counter so a prior run of failed roams doesn't block this explicit request
// Note that this re-arms automatic roaming if enabled.
this->roaming_attempts_ = 0;
this->check_roaming_(millis());
}
void WiFiComponent::check_roaming_(uint32_t now) { void WiFiComponent::check_roaming_(uint32_t now) {
// Guard: not for hidden networks (may not appear in scan) // Guard: not for hidden networks (may not appear in scan)
const WiFiAP *selected = this->get_selected_sta_(); const WiFiAP *selected = this->get_selected_sta_();
@@ -2496,11 +2484,7 @@ void WiFiComponent::check_roaming_(uint32_t now) {
ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::SCANNING; this->roaming_state_ = RoamingState::SCANNING;
if (!this->wifi_scan_start_(this->passive_scan_)) { this->wifi_scan_start_(this->passive_scan_);
// Scan failed to start (e.g. busy) - don't get stuck in SCANNING forever
ESP_LOGD(TAG, "Roam scan failed to start");
this->roaming_state_ = RoamingState::IDLE;
}
} }
void WiFiComponent::process_roaming_scan_() { void WiFiComponent::process_roaming_scan_() {
-6
View File
@@ -565,12 +565,6 @@ class WiFiComponent final : public Component {
void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; } void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; }
void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; } void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; }
/** Force an immediate post-connect roaming check, bypassing the periodic interval and the
* per-connection attempt limit. Does nothing (besides a debug log) if not connected, if a
* roam scan or connect is already in progress, or if roaming is currently suppressed.
*/
void force_roam_check();
#ifdef USE_WIFI_CONNECT_TRIGGER #ifdef USE_WIFI_CONNECT_TRIGGER
Trigger<> *get_connect_trigger() { return &this->connect_trigger_; } Trigger<> *get_connect_trigger() { return &this->connect_trigger_; }
#endif #endif
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum from esphome.enum import StrEnum
__version__ = "2026.10.0-dev" __version__ = "2026.9.0b3"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = ( VALID_SUBSTITUTIONS_CHARACTERS = (
+23 -7
View File
@@ -40,16 +40,31 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size
return true; return true;
} }
void StaticTask::destroy() { bool StaticTask::destroy() {
if (this->handle_ != nullptr) { if (this->handle_ == nullptr) {
TaskHandle_t handle = this->handle_; return true;
this->handle_ = nullptr;
vTaskDelete(handle);
} }
// 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() { bool StaticTask::deallocate() {
this->destroy(); if (!this->destroy()) {
return false;
}
if (this->stack_buffer_ != nullptr) { if (this->stack_buffer_ != nullptr) {
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
: RAMAllocator<StackType_t>::ALLOC_INTERNAL); : RAMAllocator<StackType_t>::ALLOC_INTERNAL);
@@ -57,6 +72,7 @@ void StaticTask::deallocate() {
this->stack_buffer_ = nullptr; this->stack_buffer_ = nullptr;
this->stack_size_ = 0; this->stack_size_ = 0;
} }
return true;
} }
} // namespace esphome } // namespace esphome
+12 -5
View File
@@ -11,6 +11,7 @@ namespace esphome {
/** Helper for FreeRTOS static task management. /** Helper for FreeRTOS static task management.
* Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods. * 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 { class StaticTask {
public: public:
@@ -23,7 +24,7 @@ class StaticTask {
/// @brief Allocate stack and create task. /// @brief Allocate stack and create task.
/// @param fn Task function /// @param fn Task function
/// @param name Task name (for debug) /// @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 param Parameter passed to task function
/// @param priority FreeRTOS task priority /// @param priority FreeRTOS task priority
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM /// @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 create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
bool use_psram); bool use_psram);
/// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call. /// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call.
void destroy(); /// 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. /// @brief Delete the task (if created) and free the stack buffer.
void deallocate(); /// @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: protected:
TaskHandle_t handle_{nullptr}; TaskHandle_t handle_{nullptr};
+6 -3
View File
@@ -96,6 +96,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
# across the addresses on top of that. # across the addresses on top of that.
EXTRA_UPLOAD_ATTEMPTS = 2 EXTRA_UPLOAD_ATTEMPTS = 2
UPLOAD_RETRY_DELAY = 5.0 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__) _LOGGER = logging.getLogger(__name__)
@@ -694,8 +698,7 @@ def perform_ota(
_LOGGER.info("Handshake complete") _LOGGER.info("Handshake complete")
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(DATA_PHASE_TIMEOUT)
sock.settimeout(90.0)
if extended_proto: if extended_proto:
send_check(sock, ota_type, "ota type") send_check(sock, ota_type, "ota type")
@@ -854,7 +857,7 @@ def run_ota_impl_(
# clean up a half-open connection (its handshake watchdog runs at 20s); # clean up a half-open connection (its handshake watchdog runs at 20s);
# moving on to the next address family stays immediate. Known limitation: # 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 # 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. # common failures where the device resets or closes the link promptly.
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
last_error = "" last_error = ""
+4 -1
View File
@@ -69,7 +69,10 @@ def _make_create_connection() -> Callable[..., socket.socket]:
from aiohappyeyeballs import start_connection from aiohappyeyeballs import start_connection
from urllib3.exceptions import LocationParseError from urllib3.exceptions import LocationParseError
from urllib3.util.connection import _set_socket_options, allowed_gai_family # noqa: PLC2701 from urllib3.util.connection import ( # noqa: PLC2701
_set_socket_options,
allowed_gai_family,
)
from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701 from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701
from esphome import async_thread from esphome import async_thread
+3 -3
View File
@@ -45,7 +45,7 @@ lib_deps_base =
lib_deps = lib_deps =
${common.lib_deps_base} ${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea 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 improv/Improv@1.2.7 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library ; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -244,7 +244,7 @@ lib_deps =
${common:idf-component-libs.lib_deps} ${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard 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 ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -641,7 +641,7 @@ build_unflags =
extends = common extends = common
platform = platformio/native platform = platformio/native
lib_deps = 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 lvgl/lvgl@9.5.0 ; lvgl
build_flags = build_flags =
${common.build_flags} ${common.build_flags}
+4 -4
View File
@@ -14,7 +14,7 @@ esptool==5.3.1
click==8.3.3 click==8.3.3
aioesphomeapi==46.3.0 aioesphomeapi==46.3.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.3 zeroconf==0.151.2
puremagic==2.2.0 puremagic==2.2.0
ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import
@@ -27,9 +27,9 @@ bleak==3.0.2
smpclient==7.2.0 smpclient==7.2.0
requests==2.34.2 requests==2.34.2
py7zr==1.1.3 py7zr==1.1.3
platformdirs==4.11.7 # native esp-idf toolchain global cache dir platformdirs==4.11.5 # native esp-idf toolchain global cache dir
ninja==1.13.2 # native esp8266 arduino toolchain build driver ninja==1.13.0 # native esp8266 arduino toolchain build driver
filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg filelock==3.32.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this # esp-idf >= 5.0 requires this
pyparsing >= 3.3.2 pyparsing >= 3.3.2
+2 -2
View File
@@ -1,4 +1,4 @@
# Useful stuff when working in a development environment # Useful stuff when working in a development environment
clang-format==13.0.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating
clang-tidy==22.1.8 clang-tidy==22.1.8
yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating
+4 -5
View File
@@ -1,9 +1,8 @@
pylint==4.0.8 pylint==4.0.8
flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.16.6 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.5 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
prek==0.5.2 # .github/workflows/ci.yml reads this pin prek==0.5.0 # also change in .github/workflows/ci.yml when updating
yamlrocks==0.6.1 # used by script/sync_dependency_versions.py
# Unit tests # Unit tests
pytest==9.1.1 pytest==9.1.1
+1 -25
View File
@@ -14,31 +14,7 @@ top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
[ -x "$top/venv/bin/python" ] && exit 0 [ -x "$top/venv/bin/python" ] && exit 0
[ -x "$top/script/setup" ] || 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 # 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 # activated still gets its own, rather than having the active one repointed at
# this working tree. # this working tree.
env -u VIRTUAL_ENV "$top/script/setup" exec 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,10 +1104,6 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]:
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)") _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 @cache
@@ -1127,19 +1123,6 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
for func in _TEST_FUNC_RE.findall(content): for func in _TEST_FUNC_RE.findall(content):
base_name = func.replace("test_", "").partition("[")[0] base_name = func.replace("test_", "").partition("[")[0]
result.setdefault(base_name, set()).add(rel_path) 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()} return {k: frozenset(v) for k, v in result.items()}
-164
View File
@@ -1,164 +0,0 @@
#!/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,90 @@
"""Validation tests for the sendspin media_source platform.
These cover the codec preference list, whose rejection branches a compile test
cannot reach: a `test*.yaml` can only assert that a configuration is accepted.
"""
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import CONF_CODECS, _get_data
from esphome.components.sendspin.media_source import CONFIG_SCHEMA
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _media_source_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid media source config, allowing field overrides."""
config: ConfigType = {
"id": "sendspin_media_source",
"sendspin_id": "sendspin_hub",
}
config.update(overrides)
return config
def test_default_codecs_at_48_khz(set_core_config: SetCoreConfigCallable) -> None:
"""Every codec is advertised when the sample rate suits all of them."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(_media_source_config())
assert config[CONF_CODECS] == ["flac", "opus", "pcm"]
def test_default_codecs_drop_opus_at_other_rates(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Opus only supports 48 kHz, so it leaves the default list at other rates."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(_media_source_config(sample_rate=44100))
assert config[CONF_CODECS] == ["flac", "pcm"]
def test_configured_order_is_preserved(set_core_config: SetCoreConfigCallable) -> None:
"""The list is a preference order, so it reaches the player role as written."""
set_core_config(PlatformFramework.ESP32_IDF)
CONFIG_SCHEMA(_media_source_config(codecs=["pcm", "flac"]))
assert _get_data().player_config[CONF_CODECS] == ["pcm", "flac"]
def test_empty_codec_list_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""A player with no codecs at all could never be given a stream."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="length of value must be at least 1"):
CONFIG_SCHEMA(_media_source_config(codecs=[]))
def test_duplicate_codec_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""A repeated codec has no meaning in a preference order."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="may only be listed once"):
CONFIG_SCHEMA(_media_source_config(codecs=["flac", "flac"]))
def test_unknown_codec_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""Only codecs the player role can decode are accepted."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="Unknown value"):
CONFIG_SCHEMA(_media_source_config(codecs=["mp3"]))
def test_opus_at_wrong_sample_rate_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Asking for Opus at a rate it cannot handle fails rather than silently
dropping the stated preference."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"):
CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100))
@@ -9,3 +9,4 @@ media_source:
static_delay_adjustable: true static_delay_adjustable: true
fixed_delay: 480us fixed_delay: 480us
decode_memory: internal decode_memory: internal
codecs: [pcm, opus, flac]
-1
View File
@@ -14,7 +14,6 @@ esphome:
condition: wifi.ap_active condition: wifi.ap_active
then: then:
- logger.log: "WiFi AP is active!" - logger.log: "WiFi AP is active!"
- wifi.roam
wifi: wifi:
networks: networks:
-7
View File
@@ -21,13 +21,6 @@ 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 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 - 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 ### Key Fixtures
- `run_compiled` - Combines write, compile, and run operations into a single context manager - `run_compiled` - Combines write, compile, and run operations into a single context manager
+81 -335
View File
@@ -4,22 +4,17 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncGenerator, Callable, Generator from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress from contextlib import AbstractAsyncContextManager, asynccontextmanager
import fcntl import fcntl
from functools import cache
import hashlib
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import platform import platform
import re
import shutil
import signal import signal
import socket import socket
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
from typing import TextIO from typing import TextIO
from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic
@@ -28,13 +23,7 @@ import pytest_asyncio
import esphome.config import esphome.config
from esphome.core import CORE from esphome.core import CORE
from esphome.helpers import ( from esphome.helpers import get_usable_cpu_count
get_usable_cpu_count,
read_file,
rmtree,
write_file,
write_file_if_changed,
)
from esphome.platformio.toolchain import get_idedata from esphome.platformio.toolchain import get_idedata
from .const import ( from .const import (
@@ -67,21 +56,6 @@ import pty # not available on Windows
pytest.register_assert_rewrite("tests.integration.entity_utils") 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]: def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
"""Get environment variables for PlatformIO with shared cache.""" """Get environment variables for PlatformIO with shared cache."""
env = os.environ.copy() env = os.environ.copy()
@@ -104,7 +78,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
) )
# Compile with THIS tree's esphome sources, not wherever the venv's editable # Compile with THIS tree's esphome sources, not wherever the venv's editable
# install points (which may be a different git worktree or checkout). # install points (which may be a different git worktree or checkout).
repo_root = str(REPO_ROOT) repo_root = str(Path(__file__).resolve().parent.parent.parent)
existing = env.get("PYTHONPATH") existing = env.get("PYTHONPATH")
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root
return env return env
@@ -114,7 +88,8 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
def shared_platformio_cache() -> Generator[Path]: def shared_platformio_cache() -> Generator[Path]:
"""Initialize a shared PlatformIO cache for all integration tests.""" """Initialize a shared PlatformIO cache for all integration tests."""
# Use a dedicated directory for integration tests to avoid conflicts. # Use a dedicated directory for integration tests to avoid conflicts.
test_cache_dir = INTEGRATION_TESTS_ROOT # CI caches parts of this path; keep in sync with ci.yml integration-tests.
test_cache_dir = Path.home() / ".esphome-integration-tests"
cache_dir = test_cache_dir / "platformio" cache_dir = test_cache_dir / "platformio"
# Use a lock file in the home directory to ensure only one process initializes the cache # Use a lock file in the home directory to ensure only one process initializes the cache
@@ -137,9 +112,7 @@ def shared_platformio_cache() -> Generator[Path]:
init_dir = Path(tmpdir) init_dir = Path(tmpdir)
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml" fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
config_path = init_dir / "cache_init.yaml" config_path = init_dir / "cache_init.yaml"
config_path.write_text( config_path.write_text(fixture_path.read_text())
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
)
# Run compilation to populate the cache # Run compilation to populate the cache
# We must succeed here to avoid race conditions where multiple # We must succeed here to avoid race conditions where multiple
@@ -189,6 +162,13 @@ def integration_test_dir() -> Generator[Path]:
yield Path(tmpdir) 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 @pytest.fixture
def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]: def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]:
"""Reserve an unused TCP port by holding the socket open.""" """Reserve an unused TCP port by holding the socket open."""
@@ -208,29 +188,21 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int:
return reserved_tcp_port[0] 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 @pytest_asyncio.fixture
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str: async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
"""Load YAML configuration based on test name.""" """Load YAML configuration based on test name."""
shared_name = _shared_yaml_name(request) # Get the test function name
# Base test name: test_ prefix and any parametrization stripped test_name: str = request.node.name
base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0] # Extract the base test name (remove test_ prefix and any parametrization)
base_name = test_name.replace("test_", "").partition("[")[0]
# Load the fixture file # Load the fixture file
fixture_path = FIXTURES_DIR / f"{base_name}.yaml" fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml"
if not fixture_path.exists(): if not fixture_path.exists():
raise FileNotFoundError(f"Fixture file not found: {fixture_path}") raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
content = await loop.run_in_executor(None, read_file, fixture_path) content = await loop.run_in_executor(None, fixture_path.read_text)
# Replace the port in the config if it contains api section # Replace the port in the config if it contains api section
if "api:" in content: if "api:" in content:
@@ -254,13 +226,11 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
# Replace external component path placeholder if present # Replace external component path placeholder if present
if "EXTERNAL_COMPONENT_PATH" in content: if "EXTERNAL_COMPONENT_PATH" in content:
external_components_path = str(FIXTURES_DIR / "external_components") external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path) 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 return content
@@ -270,218 +240,24 @@ async def write_yaml_config(
) -> AsyncGenerator[ConfigWriter]: ) -> AsyncGenerator[ConfigWriter]:
"""Write YAML configuration to a file.""" """Write YAML configuration to a file."""
# Get the test name for default filename # Get the test name for default filename
base_name = request.node.name.replace("test_", "").partition("[")[0] test_name = request.node.name
base_name = test_name.replace("test_", "").split("[")[0]
async def _write_config(content: str, filename: str | None = None) -> Path: async def _write_config(content: str, filename: str | None = None) -> Path:
if filename is None: if filename is None:
filename = f"{base_name}.yaml" filename = f"{base_name}.yaml"
config_path = integration_test_dir / filename config_path = integration_test_dir / filename
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, write_file, config_path, content) await loop.run_in_executor(None, config_path.write_text, content)
return config_path return config_path
yield _write_config 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 @pytest_asyncio.fixture
async def compile_esphome( async def compile_esphome(
integration_test_dir: Path, integration_test_dir: Path,
shared_platformio_cache: Path, shared_platformio_cache: Path,
request: pytest.FixtureRequest,
) -> AsyncGenerator[CompileFunction]: ) -> AsyncGenerator[CompileFunction]:
"""Compile an ESPHome configuration and return the binary path.""" """Compile an ESPHome configuration and return the binary path."""
@@ -489,96 +265,66 @@ async def compile_esphome(
# Use the shared PlatformIO cache for faster compilation # Use the shared PlatformIO cache for faster compilation
# This avoids re-downloading dependencies for each test # This avoids re-downloading dependencies for each test
env = _get_platformio_env(shared_platformio_cache) 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() loop = asyncio.get_running_loop()
name = _shared_yaml_name(request) def _read_config_and_get_binary():
if name is None: CORE.reset() # Reset CORE state between test runs
await _run_esphome_compile(config_path, integration_test_dir, env) CORE.config_path = config_path
return await loop.run_in_executor( config = esphome.config.read_config(
None, _resolve_compiled_binary, config_path {"command": "compile", "config": str(config_path)}
) )
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
# Shared fixture: build in a hash-keyed dir so tests sharing a config # Get the compiled binary path
# pay one full compile and later only a main.cpp (port) rebuild + relink idedata = get_idedata(config)
shared_dir = _shared_build_dir(name) return Path(idedata.firmware_elf_path)
shared_dir.mkdir(parents=True, exist_ok=True)
# Freshen the dir before locking so a concurrent age sweep, which binary_path = await loop.run_in_executor(None, _read_config_and_get_binary)
# 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 if not binary_path.exists():
with suppress(FileNotFoundError): raise RuntimeError(f"Compiled binary not found at {binary_path}")
os.utime(shared_dir)
if shared_dir not in _pruned_dirs: return binary_path
_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 yield _compile
@@ -0,0 +1,58 @@
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));
}
@@ -0,0 +1,111 @@
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]);
}
@@ -0,0 +1,88 @@
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)
@@ -0,0 +1,106 @@
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.
@@ -0,0 +1,95 @@
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)
@@ -0,0 +1,97 @@
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)
@@ -1,233 +0,0 @@
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
@@ -0,0 +1,138 @@
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)
@@ -0,0 +1,124 @@
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();"
@@ -0,0 +1,203 @@
esphome:
name: uart-mock-modbus-server-contro
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;
- 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;
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 99;
- address: 0x02
value_type: U_WORD_S
read_lambda: return 4660;
- address: 0x03
value_type: S_WORD
read_lambda: return -99;
- address: 0x04
value_type: S_WORD_S
read_lambda: return -2;
- address: 0x05
value_type: U_DWORD
read_lambda: return 16909060;
- address: 0x08
value_type: S_DWORD
read_lambda: return -16909060;
- address: 0x0B
value_type: U_DWORD_R
read_lambda: return 67305985;
- address: 0x0E
value_type: S_DWORD_R
read_lambda: return -67305985;
- address: 0x11
value_type: U_QWORD
read_lambda: return 72623859790382856;
- address: 0x16
value_type: S_QWORD
read_lambda: return -72623859790382856;
- address: 0x1B
value_type: U_QWORD_R
read_lambda: return 578437695752307201;
- address: 0x20
value_type: S_QWORD_R
read_lambda: return -578437695752307201;
- address: 0x25
value_type: FP32
read_lambda: return 3.14;
- address: 0x28
value_type: FP32_R
read_lambda: return 3.14;
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_1
name: "reg_u_word_s"
address: 0x02
register_type: holding
value_type: U_WORD_S
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word_s_raw"
address: 0x02
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word"
address: 0x03
register_type: holding
value_type: S_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word_s"
address: 0x04
register_type: holding
value_type: S_WORD_S
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword"
address: 0x05
register_type: holding
value_type: U_DWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword"
address: 0x08
register_type: holding
value_type: S_DWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword_r"
address: 0x0B
register_type: holding
value_type: U_DWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword_r"
address: 0x0E
register_type: holding
value_type: S_DWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword"
address: 0x11
register_type: holding
value_type: U_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword"
address: 0x16
register_type: holding
value_type: S_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword_r"
address: 0x1B
register_type: holding
value_type: U_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword_r"
address: 0x20
register_type: holding
value_type: S_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32"
address: 0x25
register_type: holding
value_type: FP32
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32_r"
address: 0x28
register_type: holding
value_type: FP32_R
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,147 @@
esphome:
name: uart-mock-modbus-srv-bits
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;
- 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;
globals:
- id: stored_bit_2
type: bool
initial_value: "false"
- id: stored_bit_3
type: bool
initial_value: "true"
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
update_interval: 1s
id: modbus_controller_1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
bits:
- address: 0x00
read_lambda: return true;
- address: 0x01
read_lambda: return false;
- address: 0x02
read_lambda: return id(stored_bit_2);
write_lambda: id(stored_bit_2) = x; return true;
- address: 0x03
read_lambda: return id(stored_bit_3);
write_lambda: id(stored_bit_3) = x; return true;
# The same four bits are read both as coils (FC 0x01) and as discrete inputs
# (FC 0x02): the server serves both from one shared bit table, so the two
# views must always agree.
binary_sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_0"
address: 0x00
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_1"
address: 0x01
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_3"
address: 0x03
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_0"
address: 0x00
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_1"
address: 0x01
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_2"
address: 0x02
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_3"
address: 0x03
register_type: discrete_input
# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the
# multiple-coils write (FC 0x0F) so both server write paths are exercised.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_bit_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_bit_3"
address: 0x03
register_type: coil
use_write_multiple: true
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,116 @@
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: esphome:
name: uart-mock-modbus-mesh name: uart-mock-modbus-srv-write
host: host:
api: api:
@@ -17,14 +17,13 @@ uart:
baud_rate: 115200 baud_rate: 115200
port: /dev/null port: /dev/null
# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed registers
# backed by writable globals, addr 5 = the read/write 0x17 target, addr 2/3/6
# 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: uart_mock:
- id: virtual_uart_server - id: virtual_uart_server
baud_rate: 9600 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 auto_start: true
debug: debug:
on_tx: on_tx:
@@ -32,120 +31,79 @@ uart_mock:
- uart_mock.inject_rx: - uart_mock.inject_rx:
id: virtual_uart_controller id: virtual_uart_controller
data: !lambda return data; 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
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 - id: virtual_uart_controller
baud_rate: 9600 baud_rate: 9600
auto_start: true auto_start: true # See comment on virtual_uart_server above
debug: debug:
on_tx: on_tx:
- then: - then:
- uart_mock.inject_rx: - uart_mock.inject_rx:
id: virtual_uart_server id: virtual_uart_server
data: !lambda return data; data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
globals: globals:
- id: stored_1
type: uint16_t
initial_value: "0"
- id: stored_u_word - id: stored_u_word
type: uint16_t type: uint16_t
initial_value: "99" initial_value: "11"
- id: stored_u_word_s - id: stored_u_word_s
type: uint16_t type: uint16_t
initial_value: "4660" initial_value: "4660"
- id: stored_s_word - id: stored_s_word
type: int16_t type: int16_t
initial_value: "-99" initial_value: "-11"
- id: stored_s_word_s - id: stored_s_word_s
type: int16_t type: int16_t
initial_value: "-2" initial_value: "-2"
- id: stored_u_dword - id: stored_u_dword
type: uint32_t type: uint32_t
initial_value: "16909060" initial_value: "1001"
- id: stored_s_dword - id: stored_s_dword
type: int32_t type: int32_t
initial_value: "-16909060" initial_value: "-1001"
- id: stored_u_dword_r - id: stored_u_dword_r
type: uint32_t type: uint32_t
initial_value: "67305985" initial_value: "3003"
- id: stored_s_dword_r - id: stored_s_dword_r
type: int32_t type: int32_t
initial_value: "-67305985" initial_value: "-3003"
- id: stored_u_qword - id: stored_u_qword
type: uint64_t type: uint64_t
initial_value: "72623859790382856" initial_value: "5005"
- id: stored_s_qword - id: stored_s_qword
type: int64_t type: int64_t
initial_value: "-72623859790382856" initial_value: "-5005"
- id: stored_u_qword_r - id: stored_u_qword_r
type: uint64_t type: uint64_t
initial_value: "578437695752307201" initial_value: "7007"
- id: stored_s_qword_r - id: stored_s_qword_r
type: int64_t type: int64_t
initial_value: "-578437695752307201" initial_value: "-7007"
- id: stored_fp32 - id: stored_fp32
type: float type: float
initial_value: "3.14" initial_value: "1.5"
- id: stored_fp32_r - id: stored_fp32_r
type: float type: float
initial_value: "2.5" initial_value: "2.5"
- id: stored_bit_2
type: bool
initial_value: "false"
- id: stored_bit_3
type: bool
initial_value: "true"
modbus: modbus:
- uart_id: virtual_uart_server - uart_id: virtual_uart_server
id: virtual_modbus_server id: virtual_modbus_server
role: server role: server
- uart_id: virtual_uart_server_2
id: virtual_modbus_server_2
role: server
- uart_id: virtual_uart_controller - uart_id: virtual_uart_controller
id: virtual_modbus_client id: virtual_modbus_controller
role: client role: client
turnaround_time: 10ms turnaround_time: 10ms
modbus_controller: modbus_controller:
- address: 1 - address: 1
modbus_id: virtual_modbus_client modbus_id: virtual_modbus_controller
update_interval: 2s
id: modbus_controller_1 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
- address: 6
modbus_id: virtual_modbus_client
id: modbus_controller_6
update_interval: 1s
modbus_server: modbus_server:
- address: 1 - address: 1
modbus_id: virtual_modbus_server modbus_id: virtual_modbus_server
id: modbus_server_1
registers: registers:
- address: 0x01 - address: 0x01
value_type: U_WORD value_type: U_WORD
@@ -203,47 +161,6 @@ modbus_server:
value_type: FP32_R value_type: FP32_R
read_lambda: return id(stored_fp32_r); read_lambda: return id(stored_fp32_r);
write_lambda: id(stored_fp32_r) = x; return true; write_lambda: id(stored_fp32_r) = x; return true;
- 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;
- address: 6
modbus_id: virtual_modbus_server_2
bits:
- address: 0x00
read_lambda: return true;
- address: 0x01
read_lambda: return false;
- address: 0x02
read_lambda: return id(stored_bit_2);
write_lambda: id(stored_bit_2) = x; return true;
- address: 0x03
read_lambda: return id(stored_bit_3);
write_lambda: id(stored_bit_3) = x; return true;
sensor: sensor:
- platform: modbus_controller - platform: modbus_controller
@@ -258,12 +175,6 @@ sensor:
address: 0x02 address: 0x02
register_type: holding register_type: holding
value_type: U_WORD_S value_type: U_WORD_S
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word_s_raw"
address: 0x02
register_type: holding
value_type: U_WORD
- platform: modbus_controller - platform: modbus_controller
modbus_controller_id: modbus_controller_1 modbus_controller_id: modbus_controller_1
name: "reg_s_word" name: "reg_s_word"
@@ -336,31 +247,7 @@ sensor:
address: 0x28 address: 0x28
register_type: holding register_type: holding
value_type: FP32_R 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
# The number schema caps min/max at 16777215 (float32 integer precision), so
# the large dword/qword baselines cannot be written back through these numbers.
number: number:
- platform: modbus_controller - platform: modbus_controller
modbus_controller_id: modbus_controller_1 modbus_controller_id: modbus_controller_1
@@ -477,82 +364,8 @@ number:
max_value: 16777215 max_value: 16777215
step: 0.01 step: 0.01
# The four bits are read both as coils (FC 0x01) and discrete inputs (FC 0x02);
# the server serves both from one shared table, so the two views must agree.
binary_sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_coil_0"
address: 0x00
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_coil_1"
address: 0x01
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_coil_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_coil_3"
address: 0x03
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_di_0"
address: 0x00
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_di_1"
address: 0x01
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_di_2"
address: 0x02
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_di_3"
address: 0x03
register_type: discrete_input
# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the
# multiple-coils write (FC 0x0F) so both server write paths are exercised.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "write_bit_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "write_bit_3"
address: 0x03
register_type: coil
use_write_multiple: true
button: button:
- platform: template - platform: template
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: # This test does not have anything to start (mock is autostart)
# 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,5 +1,5 @@
esphome: esphome:
name: uart-mock-modbus-srv-injected name: uart-mock-modbus-srv-rw
host: host:
api: api:
@@ -17,8 +17,6 @@ uart:
baud_rate: 115200 baud_rate: 115200
port: /dev/null 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: uart_mock:
- id: virtual_uart_dev - id: virtual_uart_dev
baud_rate: 9600 baud_rate: 9600
@@ -27,31 +25,18 @@ uart_mock:
auto_start: false auto_start: false
debug: debug:
injections: injections:
- delay: 100ms # FC 0x17 Read/Write Multiple Registers on device 1:
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read) # write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2).
- delay: 100ms # Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must
# Read holding register 7 on device 2, its reply, then read holding # read back the just-written 0x1234 in the same request.
# 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 - delay: 100ms
inject_rx: inject_rx:
[0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8] [0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8]
# FC 0x17: write reg 0x0006 = 0x5678 (qty 1), then read reg 0x0006 (qty 1) - # FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) -
# a write and read targeting a different register block. # a write and read targeting a different register block.
- delay: 100ms - delay: 100ms
inject_rx: inject_rx:
[0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55] [0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10]
globals: globals:
- id: stored_1 - id: stored_1
@@ -85,18 +70,8 @@ modbus_server:
read_lambda: |- read_lambda: |-
id(rw_read_2).publish_state(0x00AA); id(rw_read_2).publish_state(0x00AA);
return 0x00AA; return 0x00AA;
# Second writable + readable register, targeted by the second request.
- address: 0x03 - 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 value_type: U_WORD
read_lambda: |- read_lambda: |-
id(rw_read_3).publish_state(id(stored_3)); id(rw_read_3).publish_state(id(stored_3));
@@ -105,22 +80,8 @@ modbus_server:
id(stored_3) = x; id(stored_3) = x;
id(rw_write_3).publish_state(x); id(rw_write_3).publish_state(x);
return true; return true;
- address: 0x0A
value_type: U_WORD
read_lambda: |-
id(read_after_peer_timeout).publish_state(1);
return 1;
sensor: 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 - platform: template
name: "rw_write_1" name: "rw_write_1"
id: rw_write_1 id: rw_write_1
+3 -11
View File
@@ -1,7 +1,7 @@
"""Helpers for manipulating the host platform's preferences file. """Helpers for manipulating the host platform's preferences file.
ESPHome's host platform stores preferences in ESPHome's host platform stores preferences in
``$ESPHOME_PREFDIR/<app_name>.prefs`` using a simple binary layout that ``~/.esphome/prefs/<app_name>.prefs`` using a simple binary layout that
mirrors ``HostPreferences::sync()``: mirrors ``HostPreferences::sync()``:
``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry. ``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry.
@@ -11,21 +11,13 @@ boot (e.g. forcing safe mode) or to clear stale state between runs.
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
import struct import struct
def host_prefs_path(device_name: str) -> Path: def host_prefs_path(device_name: str) -> Path:
"""Return the on-disk prefs file path for a host-platform device. """Return the on-disk prefs file path for a host-platform device."""
return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs"
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: def clear_host_prefs(device_name: str) -> None:
@@ -24,6 +24,7 @@ from .types import (
RunCompiledFunction, RunCompiledFunction,
) )
pytestmark = pytest.mark.usefixtures("isolated_preferences")
NEW_KEY = PROVISIONING_PSK NEW_KEY = PROVISIONING_PSK
@@ -41,6 +41,15 @@ async def _poll_until_exists(path: Path) -> None:
await asyncio.sleep(0.05) 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 @pytest.mark.asyncio
async def test_host_preferences_suspend_resume( async def test_host_preferences_suspend_resume(
yaml_config: str, yaml_config: str,
@@ -49,7 +58,7 @@ async def test_host_preferences_suspend_resume(
isolated_preferences: Path, isolated_preferences: Path,
) -> None: ) -> None:
"""Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing.""" """Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing."""
pref_file = isolated_preferences / f"{DEVICE_NAME}.prefs" pref_file = isolated_preferences
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
saved_in_memory = loop.create_future() saved_in_memory = loop.create_future()
@@ -11,6 +11,14 @@ from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction 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 @pytest.mark.asyncio
async def test_light_initial_state( async def test_light_initial_state(
yaml_config: str, yaml_config: str,
+81 -82
View File
@@ -19,40 +19,23 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState
import pytest import pytest
from .state_utils import SensorTracker, find_entity, require_entity, wait_for_state from .state_utils import SensorTracker, find_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction from .types import APIClientConnectedFactory, RunCompiledFunction
def _swap16(value: int) -> int: @dataclass
"""Byte-swapped view of a 16-bit register as the raw U_WORD wire value.""" class RegisterTestCase:
return ((value & 0xFF) << 8) | (value >> 8) """Test parameters for a single modbus register write/read round-trip."""
initial_value: object
# Raw U_WORD view of reg_u_word_s's initial 0x1234 write_number_name: str
MESH_RAW_U_WORD_S = _swap16(4660) write_value: float
post_write_value: object
# Initial values of the mesh fixture's address 1 registers; the
# server_controller test reads them and the write test uses them as baseline.
MESH_INITIAL_VALUES: dict[str, object] = {
"reg_u_word": 99,
"reg_u_word_s": 4660,
"reg_s_word": -99,
"reg_s_word_s": -2,
"reg_u_dword": 16909060,
"reg_s_dword": -16909060,
"reg_u_dword_r": pytest.approx(67305985),
"reg_s_dword_r": pytest.approx(-67305985),
"reg_u_qword": pytest.approx(72623859790382856),
"reg_s_qword": pytest.approx(-72623859790382856),
"reg_u_qword_r": pytest.approx(578437695752307201),
"reg_s_qword_r": pytest.approx(-578437695752307201),
"reg_fp32": pytest.approx(3.14),
"reg_fp32_r": pytest.approx(2.5),
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -190,7 +173,6 @@ async def test_uart_mock_modbus_no_threshold(
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_server( async def test_uart_mock_modbus_server(
yaml_config: str, yaml_config: str,
@@ -221,7 +203,6 @@ async def test_uart_mock_modbus_server(
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_server_read_write( async def test_uart_mock_modbus_server_read_write(
yaml_config: str, yaml_config: str,
@@ -250,8 +231,8 @@ async def test_uart_mock_modbus_server_read_write(
"rw_write_1": 4660, # 0x1234 written to reg 0x0001 "rw_write_1": 4660, # 0x1234 written to reg 0x0001
"rw_read_1": 4660, # reg 0x0001 reads back the just-written value "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_read_2": 170, # 0x00AA read from reg 0x0002 in the same request
"rw_write_3": 22136, # 0x5678 written to reg 0x0006 "rw_write_3": 22136, # 0x5678 written to reg 0x0003
"rw_read_3": 22136, # reg 0x0006 reads back the just-written value "rw_read_3": 22136, # reg 0x0003 reads back the just-written value
} }
) )
@@ -260,8 +241,7 @@ async def test_uart_mock_modbus_server_read_write(
api_client_connected() as client, api_client_connected() as client,
): ):
await tracker.setup_and_start_scenario(client) await tracker.setup_and_start_scenario(client)
# The FC 0x17 injections fire last, behind four earlier 100ms delays await tracker.await_all(futures)
await tracker.await_all(futures, timeout=4.0)
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@@ -316,7 +296,6 @@ async def test_uart_mock_modbus_server_read_write_invalid(
) )
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller( async def test_uart_mock_modbus_server_controller(
yaml_config: str, yaml_config: str,
@@ -327,7 +306,23 @@ async def test_uart_mock_modbus_server_controller(
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
expected_values = MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} expected_values = {
"reg_u_word": 99,
"reg_u_word_s": 4660,
"reg_u_word_s_raw": 13330,
"reg_s_word": -99,
"reg_s_word_s": -2,
"reg_u_dword": 16909060,
"reg_s_dword": -16909060,
"reg_u_dword_r": pytest.approx(67305985),
"reg_s_dword_r": pytest.approx(-67305985),
"reg_u_qword": pytest.approx(72623859790382856),
"reg_s_qword": pytest.approx(-72623859790382856),
"reg_u_qword_r": pytest.approx(578437695752307201),
"reg_s_qword_r": pytest.approx(-578437695752307201),
"reg_fp32": pytest.approx(3.14),
"reg_fp32_r": pytest.approx(3.14),
}
tracker = SensorTracker(list(expected_values.keys())) tracker = SensorTracker(list(expected_values.keys()))
futures = tracker.expect_all(expected_values) futures = tracker.expect_all(expected_values)
@@ -335,12 +330,14 @@ async def test_uart_mock_modbus_server_controller(
run_compiled(yaml_config, line_callback=line_callback), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
# The controller polls from boot, so the first values can already be in
# the states the device sends on connect; matching them there saves
# waiting for the next poll
await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.setup_and_start_scenario(client, match_initial_states=True)
await tracker.await_all(futures) await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller_write( async def test_uart_mock_modbus_server_controller_write(
yaml_config: str, yaml_config: str,
@@ -356,47 +353,51 @@ async def test_uart_mock_modbus_server_controller_write(
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
# Per read-back sensor: the number entity to write through and the value; register_test_cases: dict[str, RegisterTestCase] = {
# floats read back within tolerance, everything else exactly "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42),
register_writes: dict[str, tuple[str, int | float]] = { "reg_u_word_s": RegisterTestCase(4660, "write_u_word_s", 17185, 17185),
"reg_u_word": ("write_u_word", 42), "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42),
"reg_u_word_s": ("write_u_word_s", 17185), "reg_s_word_s": RegisterTestCase(-2, "write_s_word_s", -257, -257),
"reg_s_word": ("write_s_word", -42), "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002),
"reg_s_word_s": ("write_s_word_s", -257), "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002),
"reg_u_dword": ("write_u_dword", 2002), "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004),
"reg_s_dword": ("write_s_dword", -2002), "reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004),
"reg_u_dword_r": ("write_u_dword_r", 4004), "reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006),
"reg_s_dword_r": ("write_s_dword_r", -4004), "reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006),
"reg_u_qword": ("write_u_qword", 6006), "reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008),
"reg_s_qword": ("write_s_qword", -6006), "reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008),
"reg_u_qword_r": ("write_u_qword_r", 8008), "reg_fp32": RegisterTestCase(
"reg_s_qword_r": ("write_s_qword_r", -8008), pytest.approx(1.5, abs=0.01),
"reg_fp32": ("write_fp32", 6.28), "write_fp32",
"reg_fp32_r": ("write_fp32_r", 9.42), 3.14,
pytest.approx(3.14, abs=0.01),
),
"reg_fp32_r": RegisterTestCase(
pytest.approx(2.5, abs=0.01),
"write_fp32_r",
6.28,
pytest.approx(6.28, abs=0.01),
),
} }
tracker = SensorTracker([*register_writes, "reg_u_word_s_raw"]) tracker = SensorTracker(list(register_test_cases.keys()))
# The raw U_WORD view of 0x02 pins the byte swap on the write path: the
# round trip through write_u_word_s applies the swap an even number of
# times, so only the raw sensor can catch a symmetrically dropped swap.
# Phase 1: expect initial baseline values # Phase 1: expect initial baseline values
initial_futures = tracker.expect_all( initial_futures = tracker.expect_all(
MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} {name: case.initial_value for name, case in register_test_cases.items()}
) )
# Phase 2: expect post-write values (registered now so on_state can match them) # Phase 2: expect post-write values (registered now so on_state can match them)
written_futures = tracker.expect_all( written_futures = tracker.expect_all(
{ {name: case.post_write_value for name, case in register_test_cases.items()}
name: pytest.approx(value, abs=0.01) if isinstance(value, float) else value
for name, (_, value) in register_writes.items()
}
| {"reg_u_word_s_raw": _swap16(register_writes["reg_u_word_s"][1])}
) )
async with ( async with (
run_compiled(yaml_config, line_callback=line_callback), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
# The controller polls from boot, so the baseline can already be in the
# states the device sends on connect; matching it there saves waiting for
# the next poll
entities = await tracker.setup_and_start_scenario( entities = await tracker.setup_and_start_scenario(
client, match_initial_states=True client, match_initial_states=True
) )
@@ -405,22 +406,19 @@ async def test_uart_mock_modbus_server_controller_write(
# connection is working before issuing writes # connection is working before issuing writes
await tracker.await_all(initial_futures, timeout=4.0) await tracker.await_all(initial_futures, timeout=4.0)
# Issue write commands for all register types; exact object_id match, # Issue write commands for all register types
# since several write_* names are prefixes of a sibling for case in register_test_cases.values():
numbers = { entity = find_entity(entities, case.write_number_name, NumberInfo)
e.object_id.lower(): e for e in entities if isinstance(e, NumberInfo) assert entity is not None, (
} f"{case.write_number_name} number entity not found"
for number_name, value in register_writes.values(): )
entity = numbers.get(number_name) client.number_command(entity.key, case.write_value)
assert entity is not None, f"{number_name} number entity not found"
client.number_command(entity.key, value)
# Wait for sensors to reflect the written values (round-trip write+read) # Wait for sensors to reflect the written values (round-trip write+read)
await tracker.await_all(written_futures, timeout=4.0) await tracker.await_all(written_futures, timeout=4.0)
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller_bits( async def test_uart_mock_modbus_server_controller_bits(
yaml_config: str, yaml_config: str,
@@ -466,6 +464,8 @@ async def test_uart_mock_modbus_server_controller_bits(
run_compiled(yaml_config, line_callback=line_callback), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
# The controller polls from boot and binary sensors drop repeats, so the
# baseline can arrive only in the states the device sends on connect
entities = await tracker.setup_and_start_scenario( entities = await tracker.setup_and_start_scenario(
client, match_initial_states=True client, match_initial_states=True
) )
@@ -476,7 +476,8 @@ async def test_uart_mock_modbus_server_controller_bits(
# Flip both writable bits: 0x02 false -> true, 0x03 true -> false # Flip both writable bits: 0x02 false -> true, 0x03 true -> false
for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)):
entity = require_entity(entities, switch_name, SwitchInfo) entity = find_entity(entities, switch_name, SwitchInfo)
assert entity is not None, f"{switch_name} switch entity not found"
client.switch_command(entity.key, value) client.switch_command(entity.key, value)
# Wait for both read views to reflect the written values # Wait for both read views to reflect the written values
@@ -484,7 +485,6 @@ async def test_uart_mock_modbus_server_controller_bits(
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller_multiple( async def test_uart_mock_modbus_server_controller_multiple(
yaml_config: str, yaml_config: str,
@@ -495,7 +495,7 @@ async def test_uart_mock_modbus_server_controller_multiple(
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
expected_values = {"multi_reg_a": 919, "multi_reg_b": 929} expected_values = {"reg_u_word": 919, "reg_u_word_2": 929}
tracker = SensorTracker(list(expected_values.keys())) tracker = SensorTracker(list(expected_values.keys()))
futures = tracker.expect_all(expected_values) futures = tracker.expect_all(expected_values)
@@ -503,6 +503,9 @@ async def test_uart_mock_modbus_server_controller_multiple(
run_compiled(yaml_config, line_callback=line_callback), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
# The controller polls from boot, so the first values can already be in
# the states the device sends on connect; matching them there saves
# waiting for the next poll
await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.setup_and_start_scenario(client, match_initial_states=True)
await tracker.await_all(futures) await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@@ -703,7 +706,6 @@ async def test_uart_mock_modbus_shared_address(
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_custom_pdu( async def test_uart_mock_modbus_custom_pdu(
yaml_config: str, yaml_config: str,
@@ -930,7 +932,6 @@ async def test_uart_mock_modbus_broadcast_write(
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_client_read_write( async def test_uart_mock_modbus_client_read_write(
yaml_config: str, yaml_config: str,
@@ -946,7 +947,9 @@ async def test_uart_mock_modbus_client_read_write(
""" """
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
tracker = SensorTracker(["srv_write_1", "client_read_0", "client_read_1"]) tracker = SensorTracker(
["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"]
)
futures = tracker.expect_all( futures = tracker.expect_all(
{ {
"srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001 "srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001
@@ -964,7 +967,6 @@ async def test_uart_mock_modbus_client_read_write(
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_register_offset( async def test_uart_mock_modbus_register_offset(
yaml_config: str, yaml_config: str,
@@ -1020,7 +1022,6 @@ async def test_uart_mock_modbus_register_offset(
) )
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_lambda_write( async def test_uart_mock_modbus_lambda_write(
yaml_config: str, yaml_config: str,
@@ -1057,7 +1058,6 @@ async def test_uart_mock_modbus_lambda_write(
await tracker.await_change(wrote_30, "reg_30", timeout=4.0) await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_lambda_invert( async def test_uart_mock_modbus_lambda_invert(
yaml_config: str, yaml_config: str,
@@ -1113,7 +1113,6 @@ async def test_uart_mock_modbus_lambda_invert(
) )
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_deprecated_write_buffer( async def test_uart_mock_modbus_deprecated_write_buffer(
yaml_config: str, yaml_config: str,
-28
View File
@@ -2122,34 +2122,6 @@ def test_get_cpp_changed_components_independent_of_cwd(
) == ["time"] ) == ["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: def test_lpt_partition_balances_skewed_weights() -> None:
"""Heavy items spread across groups instead of clustering.""" """Heavy items spread across groups instead of clustering."""
items = [f"i{n}" for n in range(6)] 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: def test_spec_key_collapses_destinations() -> None:
"""Two specs delivering one package share a directory and one key.""" """Two specs delivering one package share a directory and one key."""
mod = _load_script() mod = _load_script()
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.24") == "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( assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
"esp32async/asynctcp @ 3.5.0" "esp32async/asynctcp @ 3.5.0"
) )
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
"[env:a]\n" "[env:a]\n"
"platform = fake/platform@1\n" "platform = fake/platform@1\n"
"lib_deps =\n" "lib_deps =\n"
" esphome/noise-c @ 0.1.24\n" " esphome/noise-c @ 0.1.26\n"
" ${common.lib_deps}\n" " ${common.lib_deps}\n"
" internal_lib\n" " internal_lib\n"
"[env:b]\n" "[env:b]\n"
"lib_deps =\n" "lib_deps =\n"
" esphome/noise-c @ 0.1.24\n" " esphome/noise-c @ 0.1.26\n"
) )
mod = _load_script() mod = _load_script()
args = Namespace(libraries=True, platforms=True, tools=False) args = Namespace(libraries=True, platforms=True, tools=False)
libs, platforms, tools = mod.parse_specs(str(ini), args) libs, platforms, tools = mod.parse_specs(str(ini), args)
# exact-string duplicates collapse; distinct version pins survive # 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 platforms == ["fake/platform@1"]
assert tools == [] assert tools == []
assert mod.build_cli_args(libs, platforms, tools) == [ assert mod.build_cli_args(libs, platforms, tools) == [
"-l", "-l",
"esphome/noise-c @ 0.1.24", "esphome/noise-c @ 0.1.26",
"-p", "-p",
"fake/platform@1", "fake/platform@1",
] ]
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
mod.parallel_install( mod.parallel_install(
cls, cls,
[ [
"esphome/noise-c @ 0.1.24", "esphome/noise-c @ 0.1.26",
"esphome/noise-c @ 0.1.24", "esphome/noise-c @ 0.1.26",
"esphome/already @ 1.0", "esphome/already @ 1.0",
"https://x/framework.tar.xz", "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"] assert cls.lock_events == ["lock", "unlock"]
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
mod = _load_script() mod = _load_script()
cls = _reset_fake(str(tmp_path)) cls = _reset_fake(str(tmp_path))
cls.deps = { cls.deps = {
"esphome/noise-c @ 0.1.24": [ "esphome/noise-c @ 0.1.26": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"owner": "esphome", "name": "libsodium", "version": "^1.0"},
{"name": "SPI"}, {"name": "SPI"},
], ],
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
{"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"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 len(cls.calls) == 3 # the shared dep installs exactly once
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
# Wave-1 strings carry no compatibility; the dependency wave does # Wave-1 strings carry no compatibility; the dependency wave does
compats = dict(cls.compat_calls) 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) 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 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() mod = _load_script()
cls = _reset_fake(str(tmp_path)) cls = _reset_fake(str(tmp_path))
cls.deps = { cls.deps = {
"esphome/noise-c @ 0.1.24": [ "esphome/noise-c @ 0.1.26": [
{"name": "vendored", "version": "https://github.com/x/y.git"}, {"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"} 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 """Already-installed top-level packages still feed the dependency
wave; a warm store can be missing a transitive dep.""" wave; a warm store can be missing a transitive dep."""
mod = _load_script() 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 = { cls.deps = {
"esphome/noise-c @ 0.1.24": [ "esphome/noise-c @ 0.1.26": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"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"] assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
@@ -1,219 +0,0 @@
"""Unit tests for script/sync_dependency_versions.py."""
from pathlib import Path
import subprocess
import sys
import pytest
import yamlrocks
sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve()))
import sync_dependency_versions as sync_mod # noqa: E402
PRECOMMIT = """\
# See https://pre-commit.com for more information
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.1.0
hooks:
- id: ruff
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
hooks:
- id: flake8
- repo: https://github.com/asottile/pyupgrade
rev: v3.0.0
hooks:
- id: pyupgrade
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v13.0.1
hooks:
- id: clang-format
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.0.0
hooks:
- id: yamllint
- repo: local
hooks:
- id: pylint
"""
REQ_TEST = """\
pylint==4.0.8
flake8==7.1.0
ruff==0.2.0 # comment
pyupgrade==3.0.0
"""
REQ_DEV = """\
clang-format==13.0.1
yamllint==1.0.0
"""
RUFF_REPO = "https://github.com/astral-sh/ruff-pre-commit"
DUPLICATE_RUFF_BLOCK = f" - repo: {RUFF_REPO}\n rev: v0.3.0\n hooks: []\n"
EXPECTED_DRIFT = ["ruff: 0.1.0 -> 0.2.0", "flake8: 7.0.0 -> 7.1.0"]
EXPECTED_PRECOMMIT = PRECOMMIT.replace("rev: v0.1.0", "rev: v0.2.0").replace(
"rev: 7.0.0", "rev: 7.1.0"
)
@pytest.fixture
def root(tmp_path: Path) -> Path:
"""A fake checkout where ruff (v-prefixed) and flake8 (bare) have drifted."""
(tmp_path / ".pre-commit-config.yaml").write_text(PRECOMMIT)
(tmp_path / "requirements_test.txt").write_text(REQ_TEST)
(tmp_path / "requirements_dev.txt").write_text(REQ_DEV)
return tmp_path
def _load(text: str) -> object:
return yamlrocks.loads(text.encode(), option=yamlrocks.OPT_ROUND_TRIP)
@pytest.mark.parametrize(
("requirements", "expected"),
[
("prek==0.5.1 # comment\n", "0.5.1"),
("Prek==0.5.1\n", "0.5.1"),
("other==1.0\nprek==0.5.1\n", "0.5.1"),
("prek>=0.5.1\n", None),
("prek-extra==0.5.1\n", None),
("", None),
],
)
def test_read_requirement_version(requirements: str, expected: str | None) -> None:
assert sync_mod.read_requirement_version(requirements, "prek") == expected
def test_find_repo_entry() -> None:
entry = sync_mod.find_repo_entry(_load(PRECOMMIT), RUFF_REPO)
assert entry["rev"] == "v0.1.0"
@pytest.mark.parametrize(
("text", "message"),
[
("hooks: []\n", "missing key 'repos'"),
("repos:\n - rev: 1.0.0\n", "missing key 'repo'"),
(PRECOMMIT + DUPLICATE_RUFF_BLOCK, "found 2"),
("repos:\n - repo: other\n rev: 1.0.0\n", "found 0"),
],
)
def test_find_repo_entry_errors(text: str, message: str) -> None:
with pytest.raises(sync_mod.SyncError, match=message):
sync_mod.find_repo_entry(_load(text), RUFF_REPO)
@pytest.mark.parametrize(
("rev", "expected"),
[("v0.1.0", ("v", "0.1.0")), ("7.0.0", ("", "7.0.0")), ("'1.0'", ("", "1.0"))],
)
def test_current_rev(rev: str, expected: tuple[str, str]) -> None:
doc = _load(f"repos:\n - repo: {RUFF_REPO}\n rev: {rev}\n")
assert sync_mod.current_rev(doc["repos"][0], RUFF_REPO) == expected
@pytest.mark.parametrize(
("block", "message"),
[(" hooks: []\n", "has no rev"), (" rev: 1.0\n", "not a string: 1.0")],
)
def test_current_rev_errors(block: str, message: str) -> None:
doc = _load(f"repos:\n - repo: {RUFF_REPO}\n{block}")
with pytest.raises(sync_mod.SyncError, match=message):
sync_mod.current_rev(doc["repos"][0], RUFF_REPO)
def test_sync_reports_without_writing(root: Path) -> None:
assert sync_mod.sync(root, write=False) == EXPECTED_DRIFT
assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT
def test_sync_writes_keeps_layout_and_is_idempotent(root: Path) -> None:
assert sync_mod.sync(root, write=True) == EXPECTED_DRIFT
assert (root / ".pre-commit-config.yaml").read_text() == EXPECTED_PRECOMMIT
assert sync_mod.sync(root, write=True) == []
def test_sync_does_not_touch_a_config_that_matches(root: Path) -> None:
(root / ".pre-commit-config.yaml").write_text(EXPECTED_PRECOMMIT)
before = (root / ".pre-commit-config.yaml").stat().st_mtime_ns
assert sync_mod.sync(root, write=True) == []
assert (root / ".pre-commit-config.yaml").stat().st_mtime_ns == before
def test_sync_missing_requirement_pin(root: Path) -> None:
(root / "requirements_dev.txt").write_text("")
with pytest.raises(sync_mod.SyncError, match="no 'clang-format==' pin"):
sync_mod.sync(root, write=True)
def test_sync_propagates_config_errors(root: Path) -> None:
(root / ".pre-commit-config.yaml").write_text(PRECOMMIT + DUPLICATE_RUFF_BLOCK)
with pytest.raises(sync_mod.SyncError, match="found 2"):
sync_mod.sync(root, write=True)
def test_main_check_reports_drift(
root: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert sync_mod.main(["--check", "--root", str(root)]) == 1
assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT
assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT
def test_main_writes_then_check_is_clean(
root: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert sync_mod.main(["--root", str(root)]) == 0
assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT
assert sync_mod.main(["--check", "--root", str(root)]) == 0
assert capsys.readouterr().out == ""
def test_main_reports_sync_error(
root: Path, capsys: pytest.CaptureFixture[str]
) -> None:
(root / "requirements_dev.txt").write_text("")
assert sync_mod.main(["--root", str(root)]) == 1
assert (
"error: requirements_dev.txt: no 'clang-format==' pin"
in capsys.readouterr().err
)
def test_main_defaults_to_repo_root(monkeypatch: pytest.MonkeyPatch) -> None:
seen: dict[str, object] = {}
def fake_sync(root: Path, *, write: bool) -> list[str]:
seen["root"] = root
seen["write"] = write
return []
monkeypatch.setattr(sync_mod, "sync", fake_sync)
assert sync_mod.main([]) == 0
assert seen == {"root": sync_mod.REPO_ROOT, "write": True}
def test_repository_is_in_sync() -> None:
"""The real checkout must match; a failure here means a rev has drifted.
Also proves every SYNC_TARGETS entry still resolves in the real files.
"""
assert sync_mod.sync(sync_mod.REPO_ROOT, write=False) == []
def test_cli_entry_point(root: Path) -> None:
"""Run the script the way the workflow does, as a subprocess."""
script = Path(sync_mod.__file__)
result = subprocess.run(
[sys.executable, str(script), "--check", "--root", str(root)],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
assert result.stdout.splitlines() == EXPECTED_DRIFT
+3
View File
@@ -416,6 +416,9 @@ def test_perform_ota_no_auth(
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)" "Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
in caplog.text in caplog.text
) )
# The data phase timeout must outlast the device's 105 s data timeout
mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT)
assert espota2.DATA_PHASE_TIMEOUT > 105.0
@pytest.mark.usefixtures("mock_time") @pytest.mark.usefixtures("mock_time")
+2 -2
View File
@@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None:
{"name": "SPI"}, {"name": "SPI"},
] ]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
# The dep wave carries its compatibility so _install searches qualified # The dep wave carries its compatibility so _install searches qualified
dep_call = m._install.call_args_list[-1] dep_call = m._install.call_args_list[-1]
@@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
installed.append(getattr(spec, "name", str(spec))) installed.append(getattr(spec, "name", str(spec)))
) )
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c"] assert installed == ["noise-c"]