1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-03 08:31:47 +00:00

Compare commits

..

55 Commits

Author SHA1 Message Date
J. Nick Koston
eb53b00d08 Merge remote-tracking branch 'origin/clang_tidy_small_prs_clang_tidy_test' into clang_tidy_small_prs_clang_tidy_test 2025-10-19 15:43:02 -10:00
J. Nick Koston
71bb34e95c no a small batch 2025-10-19 15:42:17 -10:00
J. Nick Koston
3224f4179c Merge branch 'clang_tidy_small_prs' into clang_tidy_small_prs_clang_tidy_test 2025-10-19 15:38:11 -10:00
J. Nick Koston
f1fa1f6503 tweak 2025-10-19 15:37:49 -10:00
J. Nick Koston
dc8dfede3a tweak 2025-10-19 15:36:41 -10:00
J. Nick Koston
ea9a8bacf7 debug 2025-10-19 15:28:18 -10:00
J. Nick Koston
c1aa0ae9ba debug 2025-10-19 15:25:47 -10:00
J. Nick Koston
3a7f2afc68 debug 2025-10-19 15:24:13 -10:00
J. Nick Koston
b901d6bee0 [ci] Optimize clang-tidy for small PRs by avoiding unnecessary job splitting 2025-10-19 15:22:29 -10:00
Jesse Hills
e6ce5c58d1 Merge branch 'release' into dev 2025-10-20 13:43:31 +13:00
Jesse Hills
ebc0f5f7c9 Merge pull request #11387 from esphome/bump-2025.10.2
2025.10.2
2025-10-20 13:42:48 +13:00
Juan Antonio Aldea
0f87e7508b remove hexencode due 2022.1 deprecation (#11383) 2025-10-19 13:09:28 -10:00
J. Nick Koston
862bbb7fe1 [ci] Fix memory impact analysis failing on fork PRs (#11380)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2025-10-19 13:09:09 -10:00
Jesse Hills
020cea80b2 [nextion] Clean up deprecated code from 1.20 (#11393) 2025-10-19 22:16:50 +00:00
Jesse Hills
9c146a7070 [climate] Clean up deprecated functions from 1.20 (#11388) 2025-10-19 22:11:35 +00:00
Jesse Hills
afbd3f77af [light] Clean up deprecated functions from 1.21 (#11389) 2025-10-19 22:08:30 +00:00
Javier Peletier
1e1fefbd0a [substitutions] !extend and !remove now support substitutions and jinja (#11203) 2025-10-20 10:31:25 +13:00
Juan Antonio Aldea
1a2057df30 Migrate from hexencode() to format_hex_pretty() in Kuntze component (#11372) 2025-10-20 10:15:17 +13:00
J. Nick Koston
87ca8784ef [openthread] Backport address resolution support to prevent OTA crash (#11312)
Co-authored-by: Daniel Stiner <danstiner@gmail.com>
2025-10-20 10:12:56 +13:00
Jesse Hills
a186c1062f Bump version to 2025.10.2 2025-10-20 10:06:43 +13:00
Jonathan Swoboda
ea38237f29 [esp32] Fix OTA rollback (#11300)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-10-20 10:06:43 +13:00
J. Nick Koston
6aff1394ad [core] Fix IndexError when OTA devices cannot be resolved (#11311) 2025-10-20 10:06:43 +13:00
Spectre5
0e34d1b64d Change all temperature offsets to temperature_delta (#11347) 2025-10-20 10:06:43 +13:00
tomaszduda23
1483cee0fb [dashboard] fix migration to Path (#11342)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-10-20 10:06:43 +13:00
J. Nick Koston
8c1bd2fd85 [dashboard] Fix binary download with packages using secrets after Path migration (#11313) 2025-10-20 10:06:43 +13:00
Daniel Stiner
ea609dc0f6 [const] Add CONF_OPENTHREAD (#11318) 2025-10-20 10:06:42 +13:00
Jonathan Swoboda
913095f6be [esp32] Reduce tx power on Arduino (#11304) 2025-10-20 10:06:42 +13:00
Jonathan Swoboda
bb24ad4a30 [htu21d] Revert register address change (#11291) 2025-10-20 10:06:42 +13:00
Jonathan Swoboda
0d612fecfc [core] Add ESP32 ROM functions to reserved ids (#11293) 2025-10-20 10:06:42 +13:00
J. Nick Koston
9c235b4140 [datetime] Fix DateTimeStateTrigger compilation when time component is not used (#11287) 2025-10-20 10:06:42 +13:00
J. Nick Koston
70cb1793f3 [wifi] Optimize WiFi scan results with in-place construction (#11330) 2025-10-19 19:53:05 +00:00
J. Nick Koston
3bdd351d49 [wifi] Convert fast_connect to compile-time define, save 156-1024 bytes flash (#11328) 2025-10-19 19:52:33 +00:00
Jonathan Swoboda
b0ea3f57de [esp32] Fix OTA rollback (#11300)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-10-19 09:49:05 -10:00
J. Nick Koston
c9312d5c27 [script] Fix unbounded queue growth, optimize queued mode (default max_runs=5) (#11308)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2025-10-19 09:42:17 -10:00
J. Nick Koston
33fea90c19 [wifi] Optimize WiFi scanning to reduce copies and heap allocations (#11323) 2025-10-19 19:26:18 +00:00
J. Nick Koston
25f3b6a959 [mqtt] Reduce flash usage by optimizing ArduinoJson assignments (#11340) 2025-10-19 19:17:33 +00:00
J. Nick Koston
e993312640 [core] Fix IndexError when OTA devices cannot be resolved (#11311) 2025-10-20 08:15:47 +13:00
J. Nick Koston
85babe85e4 [sensor] Optimize sliding window filters to eliminate heap fragmentation (#11282) 2025-10-20 07:59:47 +13:00
J. Nick Koston
0266c897c9 [mdns] Use std::unique_ptr for TXT records to reduce ESP32 flash usage (#11362) 2025-10-20 07:53:00 +13:00
J. Nick Koston
bda7676e3a [bluetooth_proxy] Merge duplicate loops in get_connection_() (#11359) 2025-10-20 07:51:41 +13:00
J. Nick Koston
57e98ec3fc [wifi] Replace std::vector with std::unique_ptr for WiFi scan buffer (#11364) 2025-10-20 07:49:58 +13:00
J. Nick Koston
09b2ad071b [esp32_ble_client] Remove duplicate MAC address extraction in set_address() (#11358) 2025-10-20 07:49:13 +13:00
J. Nick Koston
fdecda3d65 [light] Use bitmask instead of std::set for color modes (#11348) 2025-10-20 07:48:14 +13:00
J. Nick Koston
a0922bc8b0 [ci] Add automated memory impact analysis for pull requests (#11242)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2025-10-20 07:43:38 +13:00
J. Nick Koston
f25af18655 [scheduler] Replace defer queue deque with vector to avoid 512-byte upfront allocation (#11305) 2025-10-20 07:34:34 +13:00
J. Nick Koston
5db07c2d70 [api][time] Refactor timezone update logic for cleaner code (#11327) 2025-10-20 07:31:40 +13:00
Juan Antonio Aldea
40823df7bc make types sensors_t and sensor_type_t internal to StatsdComponent. (#11345) 2025-10-19 07:47:31 -10:00
tomaszduda23
5e1019a6fa [nrf52, ble_nus] add logging over BLE (#9846) 2025-10-19 07:41:19 -10:00
tomaszduda23
f3cdbd0a05 [nrf52] fix task names in logs (#11367) 2025-10-19 07:39:48 -10:00
Keith Burzinski
ddf1b67e49 [prometheus] Update to use new climate API (#11361) 2025-10-18 22:11:44 -10:00
Keith Burzinski
b4d9fddd07 [mqtt] Update to use new climate API (#11360) 2025-10-18 22:11:10 -10:00
Keith Burzinski
25f03074ab [web_server] Update to use new climate API (#11363) 2025-10-18 22:10:07 -10:00
Keith Burzinski
590f6ff70b [api] Update to use new climate API (#11357) 2025-10-19 06:20:11 +00:00
Keith Burzinski
a33ed5e47b [thermostat] Add humidity support (#11286) 2025-10-18 17:25:53 -10:00
Spectre5
c11a9bb97f Change all temperature offsets to temperature_delta (#11347) 2025-10-18 21:13:57 -04:00
97 changed files with 3986 additions and 907 deletions

View File

@@ -0,0 +1,108 @@
---
name: Memory Impact Comment (Forks)
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
contents: read
pull-requests: write
actions: read
jobs:
memory-impact-comment:
name: Post memory impact comment (fork PRs only)
runs-on: ubuntu-24.04
# Only run for PRs from forks that had successful CI runs
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name != github.repository
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Get PR details
id: pr
run: |
# Get PR details by searching for PR with matching head SHA
# The workflow_run.pull_requests field is often empty for forks
head_sha="${{ github.event.workflow_run.head_sha }}"
pr_data=$(gh api "/repos/${{ github.repository }}/commits/$head_sha/pulls" \
--jq '.[0] | {number: .number, base_ref: .base.ref}')
if [ -z "$pr_data" ] || [ "$pr_data" == "null" ]; then
echo "No PR found for SHA $head_sha, skipping"
echo "skip=true" >> $GITHUB_OUTPUT
exit 0
fi
pr_number=$(echo "$pr_data" | jq -r '.number')
base_ref=$(echo "$pr_data" | jq -r '.base_ref')
echo "pr_number=$pr_number" >> $GITHUB_OUTPUT
echo "base_ref=$base_ref" >> $GITHUB_OUTPUT
echo "Found PR #$pr_number targeting base branch: $base_ref"
- name: Check out code from base repository
if: steps.pr.outputs.skip != 'true'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
# Always check out from the base repository (esphome/esphome), never from forks
# Use the PR's target branch to ensure we run trusted code from the main repo
repository: ${{ github.repository }}
ref: ${{ steps.pr.outputs.base_ref }}
- name: Restore Python
if: steps.pr.outputs.skip != 'true'
uses: ./.github/actions/restore-python
with:
python-version: "3.11"
cache-key: ${{ hashFiles('.cache-key') }}
- name: Download memory analysis artifacts
if: steps.pr.outputs.skip != 'true'
run: |
run_id="${{ github.event.workflow_run.id }}"
echo "Downloading artifacts from workflow run $run_id"
mkdir -p memory-analysis
# Download target analysis artifact
if gh run download --name "memory-analysis-target" --dir memory-analysis --repo "${{ github.repository }}" "$run_id"; then
echo "Downloaded memory-analysis-target artifact."
else
echo "No memory-analysis-target artifact found."
fi
# Download PR analysis artifact
if gh run download --name "memory-analysis-pr" --dir memory-analysis --repo "${{ github.repository }}" "$run_id"; then
echo "Downloaded memory-analysis-pr artifact."
else
echo "No memory-analysis-pr artifact found."
fi
- name: Check if artifacts exist
id: check
if: steps.pr.outputs.skip != 'true'
run: |
if [ -f ./memory-analysis/memory-analysis-target.json ] && [ -f ./memory-analysis/memory-analysis-pr.json ]; then
echo "found=true" >> $GITHUB_OUTPUT
else
echo "found=false" >> $GITHUB_OUTPUT
echo "Memory analysis artifacts not found, skipping comment"
fi
- name: Post or update PR comment
if: steps.pr.outputs.skip != 'true' && steps.check.outputs.found == 'true'
env:
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
run: |
. venv/bin/activate
# Pass PR number and JSON file paths directly to Python script
# Let Python parse the JSON to avoid shell injection risks
# The script will validate and sanitize all inputs
python script/ci_memory_impact_comment.py \
--pr-number "$PR_NUMBER" \
--target-json ./memory-analysis/memory-analysis-target.json \
--pr-json ./memory-analysis/memory-analysis-pr.json

View File

@@ -170,11 +170,13 @@ jobs:
outputs:
integration-tests: ${{ steps.determine.outputs.integration-tests }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
python-linters: ${{ steps.determine.outputs.python-linters }}
changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
component-test-count: ${{ steps.determine.outputs.component-test-count }}
changed-cpp-file-count: ${{ steps.determine.outputs.changed-cpp-file-count }}
memory_impact: ${{ steps.determine.outputs.memory-impact }}
steps:
- name: Check out code from GitHub
@@ -200,11 +202,13 @@ jobs:
# Extract individual fields
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT
echo "changed-cpp-file-count=$(echo "$output" | jq -r '.changed_cpp_file_count')" >> $GITHUB_OUTPUT
echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT
integration-tests:
@@ -243,7 +247,7 @@ jobs:
. venv/bin/activate
pytest -vv --no-cov --tb=native -n auto tests/integration/
clang-tidy:
clang-tidy-single:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
needs:
@@ -261,22 +265,6 @@ jobs:
name: Run script/clang-tidy for ESP8266
options: --environment esp8266-arduino-tidy --grep USE_ESP8266
pio_cache_key: tidyesp8266
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 1/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 1
pio_cache_key: tidyesp32
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 2/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 2
pio_cache_key: tidyesp32
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 3/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 3
pio_cache_key: tidyesp32
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 4/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 4
pio_cache_key: tidyesp32
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF
options: --environment esp32-idf-tidy --grep USE_ESP_IDF
@@ -357,6 +345,166 @@ jobs:
# yamllint disable-line rule:line-length
if: always()
clang-tidy-nosplit:
name: Run script/clang-tidy for ESP32 Arduino
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.clang-tidy-mode == 'nosplit'
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
echo "::add-matcher::.github/workflows/matchers/clang-tidy.json"
- name: Check if full clang-tidy scan needed
id: check_full_scan
run: |
. venv/bin/activate
if python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT
fi
- name: Run clang-tidy
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (hash changed)"
script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy
else
echo "Running clang-tidy on changed files only"
script/clang-tidy --all-headers --fix --changed --environment esp32-arduino-tidy
fi
env:
# Also cache libdeps, store them in a ~/.platformio subfolder
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
clang-tidy-split:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.clang-tidy-mode == 'split'
env:
GH_TOKEN: ${{ github.token }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 1/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 1
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 2/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 2
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 3/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 3
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 4/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 4
steps:
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
echo "::add-matcher::.github/workflows/matchers/clang-tidy.json"
- name: Check if full clang-tidy scan needed
id: check_full_scan
run: |
. venv/bin/activate
if python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT
fi
- name: Run clang-tidy
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (hash changed)"
script/clang-tidy --all-headers --fix ${{ matrix.options }}
else
echo "Running clang-tidy on changed files only"
script/clang-tidy --all-headers --fix --changed ${{ matrix.options }}
fi
env:
# Also cache libdeps, store them in a ~/.platformio subfolder
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
test-build-components-splitter:
name: Split components for intelligent grouping (40 weighted per batch)
runs-on: ubuntu-24.04
@@ -641,6 +789,12 @@ jobs:
--output-env \
--output-json memory-analysis-target.json
# Add metadata to JSON before caching
python script/ci_add_metadata_to_json.py \
--json-file memory-analysis-target.json \
--components "$components" \
--platform "$platform"
- name: Save memory analysis to cache
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
@@ -720,6 +874,13 @@ jobs:
python script/ci_memory_impact_extract.py \
--output-env \
--output-json memory-analysis-pr.json
# Add metadata to JSON (components and platform are in shell variables above)
python script/ci_add_metadata_to_json.py \
--json-file memory-analysis-pr.json \
--components "$components" \
--platform "$platform"
- name: Upload memory analysis JSON
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
@@ -736,10 +897,12 @@ jobs:
- determine-jobs
- memory-impact-target-branch
- memory-impact-pr-branch
if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.memory_impact).should_run == 'true' && needs.memory-impact-target-branch.outputs.skip != 'true'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && fromJSON(needs.determine-jobs.outputs.memory_impact).should_run == 'true' && needs.memory-impact-target-branch.outputs.skip != 'true'
permissions:
contents: read
pull-requests: write
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -762,52 +925,16 @@ jobs:
continue-on-error: true
- name: Post or update PR comment
env:
GH_TOKEN: ${{ github.token }}
COMPONENTS: ${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}
PLATFORM: ${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}
TARGET_RAM: ${{ needs.memory-impact-target-branch.outputs.ram_usage }}
TARGET_FLASH: ${{ needs.memory-impact-target-branch.outputs.flash_usage }}
PR_RAM: ${{ needs.memory-impact-pr-branch.outputs.ram_usage }}
PR_FLASH: ${{ needs.memory-impact-pr-branch.outputs.flash_usage }}
TARGET_CACHE_HIT: ${{ needs.memory-impact-target-branch.outputs.cache_hit }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
. venv/bin/activate
# Check if analysis JSON files exist
target_json_arg=""
pr_json_arg=""
if [ -f ./memory-analysis/memory-analysis-target.json ]; then
echo "Found target analysis JSON"
target_json_arg="--target-json ./memory-analysis/memory-analysis-target.json"
else
echo "No target analysis JSON found"
fi
if [ -f ./memory-analysis/memory-analysis-pr.json ]; then
echo "Found PR analysis JSON"
pr_json_arg="--pr-json ./memory-analysis/memory-analysis-pr.json"
else
echo "No PR analysis JSON found"
fi
# Add cache flag if target was cached
cache_flag=""
if [ "$TARGET_CACHE_HIT" == "true" ]; then
cache_flag="--target-cache-hit"
fi
# Pass JSON file paths directly to Python script
# All data is extracted from JSON files for security
python script/ci_memory_impact_comment.py \
--pr-number "${{ github.event.pull_request.number }}" \
--components "$COMPONENTS" \
--platform "$PLATFORM" \
--target-ram "$TARGET_RAM" \
--target-flash "$TARGET_FLASH" \
--pr-ram "$PR_RAM" \
--pr-flash "$PR_FLASH" \
$target_json_arg \
$pr_json_arg \
$cache_flag
--pr-number "$PR_NUMBER" \
--target-json ./memory-analysis/memory-analysis-target.json \
--pr-json ./memory-analysis/memory-analysis-pr.json
ci-status:
name: CI Status
@@ -818,7 +945,9 @@ jobs:
- pylint
- pytest
- integration-tests
- clang-tidy
- clang-tidy-single
- clang-tidy-nosplit
- clang-tidy-split
- determine-jobs
- test-build-components-splitter
- test-build-components-split

View File

@@ -70,6 +70,7 @@ esphome/components/bl0939/* @ziceva
esphome/components/bl0940/* @dan-s-github @tobias-
esphome/components/bl0942/* @dbuezas @dwmw2
esphome/components/ble_client/* @buxtronix @clydebarrow
esphome/components/ble_nus/* @tomaszduda23
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
esphome/components/bme280_base/* @esphome/core
esphome/components/bme280_spi/* @apbodrov

View File

@@ -185,7 +185,9 @@ def choose_upload_log_host(
else:
resolved.append(device)
if not resolved:
_LOGGER.error("All specified devices: %s could not be resolved.", defaults)
raise EsphomeError(
f"All specified devices {defaults} could not be resolved. Is the device connected to the network?"
)
return resolved
# No devices specified, show interactive chooser

View File

@@ -506,7 +506,7 @@ message ListEntitiesLightResponse {
string name = 3;
reserved 4; // Deprecated: was string unique_id
repeated ColorMode supported_color_modes = 12 [(container_pointer) = "std::set<light::ColorMode>"];
repeated ColorMode supported_color_modes = 12 [(container_pointer_no_template) = "light::ColorModeMask"];
// next four supports_* are for legacy clients, newer clients should use color modes
// Deprecated in API version 1.6
bool legacy_supports_brightness = 5 [deprecated=true];

View File

@@ -453,7 +453,6 @@ uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection *
bool is_single) {
auto *light = static_cast<light::LightState *>(entity);
LightStateResponse resp;
auto traits = light->get_traits();
auto values = light->remote_values;
auto color_mode = values.get_color_mode();
resp.state = values.is_on();
@@ -477,7 +476,8 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c
auto *light = static_cast<light::LightState *>(entity);
ListEntitiesLightResponse msg;
auto traits = light->get_traits();
msg.supported_color_modes = &traits.get_supported_color_modes_for_api_();
// Pass pointer to ColorModeMask so the iterator can encode actual ColorMode enum values
msg.supported_color_modes = &traits.get_supported_color_modes();
if (traits.supports_color_capability(light::ColorCapability::COLOR_TEMPERATURE) ||
traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) {
msg.min_mireds = traits.get_min_mireds();
@@ -661,11 +661,12 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection
ListEntitiesClimateResponse msg;
auto traits = climate->get_traits();
// Flags set for backward compatibility, deprecated in 2025.11.0
msg.supports_current_temperature = traits.get_supports_current_temperature();
msg.supports_current_humidity = traits.get_supports_current_humidity();
msg.supports_two_point_target_temperature = traits.get_supports_two_point_target_temperature();
msg.supports_target_humidity = traits.get_supports_target_humidity();
msg.supports_action = traits.get_supports_action();
msg.supports_current_temperature = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
msg.supports_current_humidity = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY);
msg.supports_two_point_target_temperature = traits.has_feature_flags(
climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE);
msg.supports_target_humidity = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY);
msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
// Current feature flags and other supported parameters
msg.feature_flags = traits.get_feature_flags();
msg.supported_modes = &traits.get_supported_modes_for_api_();
@@ -1081,13 +1082,8 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) {
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
#ifdef USE_TIME_TIMEZONE
if (value.timezone_len > 0) {
const std::string &current_tz = homeassistant::global_homeassistant_time->get_timezone();
// Compare without allocating a string
if (current_tz.length() != value.timezone_len ||
memcmp(current_tz.c_str(), value.timezone, value.timezone_len) != 0) {
homeassistant::global_homeassistant_time->set_timezone(
std::string(reinterpret_cast<const char *>(value.timezone), value.timezone_len));
}
homeassistant::global_homeassistant_time->set_timezone(reinterpret_cast<const char *>(value.timezone),
value.timezone_len);
}
#endif
}

View File

@@ -70,4 +70,14 @@ extend google.protobuf.FieldOptions {
// init(size) before adding elements. This eliminates std::vector template overhead
// and is ideal when the exact size is known before populating the array.
optional bool fixed_vector = 50013 [default=false];
// container_pointer_no_template: Use a non-template container type for repeated fields
// Similar to container_pointer, but for containers that don't take template parameters.
// The container type is used as-is without appending element type.
// The container must have:
// - begin() and end() methods returning iterators
// - empty() method
// Example: [(container_pointer_no_template) = "light::ColorModeMask"]
// generates: const light::ColorModeMask *supported_color_modes{};
optional string container_pointer_no_template = 50014;
}

View File

@@ -790,7 +790,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage {
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *message_name() const override { return "list_entities_light_response"; }
#endif
const std::set<light::ColorMode> *supported_color_modes{};
const light::ColorModeMask *supported_color_modes{};
float min_mireds{0.0f};
float max_mireds{0.0f};
std::vector<std::string> effects{};

View File

@@ -0,0 +1,29 @@
import esphome.codegen as cg
from esphome.components.zephyr import zephyr_add_prj_conf
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LOGS, CONF_TYPE
AUTO_LOAD = ["zephyr_ble_server"]
CODEOWNERS = ["@tomaszduda23"]
ble_nus_ns = cg.esphome_ns.namespace("ble_nus")
BLENUS = ble_nus_ns.class_("BLENUS", cg.Component)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLENUS),
cv.Optional(CONF_TYPE, default=CONF_LOGS): cv.one_of(
*[CONF_LOGS], lower=True
),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_with_framework("zephyr"),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
zephyr_add_prj_conf("BT_NUS", True)
cg.add(var.set_expose_log(config[CONF_TYPE] == CONF_LOGS))
await cg.register_component(var, config)

View File

@@ -0,0 +1,157 @@
#ifdef USE_ZEPHYR
#include "ble_nus.h"
#include <zephyr/kernel.h>
#include <bluetooth/services/nus.h>
#include "esphome/core/log.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#include "esphome/core/application.h"
#endif
#include <zephyr/sys/ring_buffer.h>
namespace esphome::ble_nus {
constexpr size_t BLE_TX_BUF_SIZE = 2048;
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
BLENUS *global_ble_nus;
RING_BUF_DECLARE(global_ble_tx_ring_buf, BLE_TX_BUF_SIZE);
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
static const char *const TAG = "ble_nus";
size_t BLENUS::write_array(const uint8_t *data, size_t len) {
if (atomic_get(&this->tx_status_) == TX_DISABLED) {
return 0;
}
return ring_buf_put(&global_ble_tx_ring_buf, data, len);
}
void BLENUS::connected(bt_conn *conn, uint8_t err) {
if (err == 0) {
global_ble_nus->conn_.store(bt_conn_ref(conn));
}
}
void BLENUS::disconnected(bt_conn *conn, uint8_t reason) {
if (global_ble_nus->conn_) {
bt_conn_unref(global_ble_nus->conn_.load());
// Connection array is global static.
// Reference can be kept even if disconnected.
}
}
void BLENUS::tx_callback(bt_conn *conn) {
atomic_cas(&global_ble_nus->tx_status_, TX_BUSY, TX_ENABLED);
ESP_LOGVV(TAG, "Sent operation completed");
}
void BLENUS::send_enabled_callback(bt_nus_send_status status) {
switch (status) {
case BT_NUS_SEND_STATUS_ENABLED:
atomic_set(&global_ble_nus->tx_status_, TX_ENABLED);
#ifdef USE_LOGGER
if (global_ble_nus->expose_log_) {
App.schedule_dump_config();
}
#endif
ESP_LOGD(TAG, "NUS notification has been enabled");
break;
case BT_NUS_SEND_STATUS_DISABLED:
atomic_set(&global_ble_nus->tx_status_, TX_DISABLED);
ESP_LOGD(TAG, "NUS notification has been disabled");
break;
}
}
void BLENUS::rx_callback(bt_conn *conn, const uint8_t *const data, uint16_t len) {
ESP_LOGD(TAG, "Received %d bytes.", len);
}
void BLENUS::setup() {
bt_nus_cb callbacks = {
.received = rx_callback,
.sent = tx_callback,
.send_enabled = send_enabled_callback,
};
bt_nus_init(&callbacks);
static bt_conn_cb conn_callbacks = {
.connected = BLENUS::connected,
.disconnected = BLENUS::disconnected,
};
bt_conn_cb_register(&conn_callbacks);
global_ble_nus = this;
#ifdef USE_LOGGER
if (logger::global_logger != nullptr && this->expose_log_) {
logger::global_logger->add_on_log_callback(
[this](int level, const char *tag, const char *message, size_t message_len) {
this->write_array(reinterpret_cast<const uint8_t *>(message), message_len);
const char c = '\n';
this->write_array(reinterpret_cast<const uint8_t *>(&c), 1);
});
}
#endif
}
void BLENUS::dump_config() {
ESP_LOGCONFIG(TAG, "ble nus:");
ESP_LOGCONFIG(TAG, " log: %s", YESNO(this->expose_log_));
uint32_t mtu = 0;
bt_conn *conn = this->conn_.load();
if (conn) {
mtu = bt_nus_get_mtu(conn);
}
ESP_LOGCONFIG(TAG, " MTU: %u", mtu);
}
void BLENUS::loop() {
if (ring_buf_is_empty(&global_ble_tx_ring_buf)) {
return;
}
if (!atomic_cas(&this->tx_status_, TX_ENABLED, TX_BUSY)) {
if (atomic_get(&this->tx_status_) == TX_DISABLED) {
ring_buf_reset(&global_ble_tx_ring_buf);
}
return;
}
bt_conn *conn = this->conn_.load();
if (conn) {
conn = bt_conn_ref(conn);
}
if (nullptr == conn) {
atomic_cas(&this->tx_status_, TX_BUSY, TX_ENABLED);
return;
}
uint32_t req_len = bt_nus_get_mtu(conn);
uint8_t *buf;
uint32_t size = ring_buf_get_claim(&global_ble_tx_ring_buf, &buf, req_len);
int err, err2;
err = bt_nus_send(conn, buf, size);
err2 = ring_buf_get_finish(&global_ble_tx_ring_buf, size);
if (err2) {
// It should no happen.
ESP_LOGE(TAG, "Size %u exceeds valid bytes in the ring buffer (%d error)", size, err2);
}
if (err == 0) {
ESP_LOGVV(TAG, "Sent %d bytes", size);
} else {
ESP_LOGE(TAG, "Failed to send %d bytes (%d error)", size, err);
atomic_cas(&this->tx_status_, TX_BUSY, TX_ENABLED);
}
bt_conn_unref(conn);
}
} // namespace esphome::ble_nus
#endif

View File

@@ -0,0 +1,37 @@
#pragma once
#ifdef USE_ZEPHYR
#include "esphome/core/defines.h"
#include "esphome/core/component.h"
#include <shell/shell_bt_nus.h>
#include <atomic>
namespace esphome::ble_nus {
class BLENUS : public Component {
enum TxStatus {
TX_DISABLED,
TX_ENABLED,
TX_BUSY,
};
public:
void setup() override;
void dump_config() override;
void loop() override;
size_t write_array(const uint8_t *data, size_t len);
void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; }
protected:
static void send_enabled_callback(bt_nus_send_status status);
static void tx_callback(bt_conn *conn);
static void rx_callback(bt_conn *conn, const uint8_t *data, uint16_t len);
static void connected(bt_conn *conn, uint8_t err);
static void disconnected(bt_conn *conn, uint8_t reason);
std::atomic<bt_conn *> conn_ = nullptr;
bool expose_log_ = false;
atomic_t tx_status_ = ATOMIC_INIT(TX_DISABLED);
};
} // namespace esphome::ble_nus
#endif

View File

@@ -155,16 +155,12 @@ esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_par
BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) {
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
if (connection->get_address() == address)
uint64_t conn_addr = connection->get_address();
if (conn_addr == address)
return connection;
}
if (!reserve)
return nullptr;
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
if (connection->get_address() == 0) {
if (reserve && conn_addr == 0) {
connection->send_service_ = INIT_SENDING_SERVICES;
connection->set_address(address);
// All connections must start at INIT
@@ -175,7 +171,6 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese
return connection;
}
}
return nullptr;
}

View File

@@ -41,7 +41,7 @@ CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BME680BSECComponent),
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature,
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta,
cv.Optional(CONF_IAQ_MODE, default="STATIC"): cv.enum(
IAQ_MODE_OPTIONS, upper=True
),

View File

@@ -139,7 +139,7 @@ CONFIG_SCHEMA_BASE = (
cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum(
VOLTAGE_OPTIONS, upper=True
),
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature,
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta,
cv.Optional(
CONF_STATE_SAVE_INTERVAL, default="6hours"
): cv.positive_time_period_minutes,

View File

@@ -1,8 +1,8 @@
#pragma once
#include "esphome/core/helpers.h"
#include "climate_mode.h"
#include <set>
#include "climate_mode.h"
#include "esphome/core/helpers.h"
namespace esphome {
@@ -109,44 +109,12 @@ class ClimateTraits {
void set_supported_modes(std::set<ClimateMode> modes) { this->supported_modes_ = std::move(modes); }
void add_supported_mode(ClimateMode mode) { this->supported_modes_.insert(mode); }
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
void set_supports_auto_mode(bool supports_auto_mode) { set_mode_support_(CLIMATE_MODE_AUTO, supports_auto_mode); }
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
void set_supports_cool_mode(bool supports_cool_mode) { set_mode_support_(CLIMATE_MODE_COOL, supports_cool_mode); }
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
void set_supports_heat_mode(bool supports_heat_mode) { set_mode_support_(CLIMATE_MODE_HEAT, supports_heat_mode); }
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
void set_supports_heat_cool_mode(bool supported) { set_mode_support_(CLIMATE_MODE_HEAT_COOL, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
void set_supports_fan_only_mode(bool supports_fan_only_mode) {
set_mode_support_(CLIMATE_MODE_FAN_ONLY, supports_fan_only_mode);
}
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
void set_supports_dry_mode(bool supports_dry_mode) { set_mode_support_(CLIMATE_MODE_DRY, supports_dry_mode); }
bool supports_mode(ClimateMode mode) const { return this->supported_modes_.count(mode); }
const std::set<ClimateMode> &get_supported_modes() const { return this->supported_modes_; }
void set_supported_fan_modes(std::set<ClimateFanMode> modes) { this->supported_fan_modes_ = std::move(modes); }
void add_supported_fan_mode(ClimateFanMode mode) { this->supported_fan_modes_.insert(mode); }
void add_supported_custom_fan_mode(const std::string &mode) { this->supported_custom_fan_modes_.insert(mode); }
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
void set_supports_fan_mode_on(bool supported) { set_fan_mode_support_(CLIMATE_FAN_ON, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
void set_supports_fan_mode_off(bool supported) { set_fan_mode_support_(CLIMATE_FAN_OFF, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
void set_supports_fan_mode_auto(bool supported) { set_fan_mode_support_(CLIMATE_FAN_AUTO, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
void set_supports_fan_mode_low(bool supported) { set_fan_mode_support_(CLIMATE_FAN_LOW, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
void set_supports_fan_mode_medium(bool supported) { set_fan_mode_support_(CLIMATE_FAN_MEDIUM, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
void set_supports_fan_mode_high(bool supported) { set_fan_mode_support_(CLIMATE_FAN_HIGH, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
void set_supports_fan_mode_middle(bool supported) { set_fan_mode_support_(CLIMATE_FAN_MIDDLE, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
void set_supports_fan_mode_focus(bool supported) { set_fan_mode_support_(CLIMATE_FAN_FOCUS, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
void set_supports_fan_mode_diffuse(bool supported) { set_fan_mode_support_(CLIMATE_FAN_DIFFUSE, supported); }
bool supports_fan_mode(ClimateFanMode fan_mode) const { return this->supported_fan_modes_.count(fan_mode); }
bool get_supports_fan_modes() const {
return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty();
@@ -178,16 +146,6 @@ class ClimateTraits {
void set_supported_swing_modes(std::set<ClimateSwingMode> modes) { this->supported_swing_modes_ = std::move(modes); }
void add_supported_swing_mode(ClimateSwingMode mode) { this->supported_swing_modes_.insert(mode); }
ESPDEPRECATED("This method is deprecated, use set_supported_swing_modes() instead", "v1.20")
void set_supports_swing_mode_off(bool supported) { set_swing_mode_support_(CLIMATE_SWING_OFF, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_swing_modes() instead", "v1.20")
void set_supports_swing_mode_both(bool supported) { set_swing_mode_support_(CLIMATE_SWING_BOTH, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_swing_modes() instead", "v1.20")
void set_supports_swing_mode_vertical(bool supported) { set_swing_mode_support_(CLIMATE_SWING_VERTICAL, supported); }
ESPDEPRECATED("This method is deprecated, use set_supported_swing_modes() instead", "v1.20")
void set_supports_swing_mode_horizontal(bool supported) {
set_swing_mode_support_(CLIMATE_SWING_HORIZONTAL, supported);
}
bool supports_swing_mode(ClimateSwingMode swing_mode) const { return this->supported_swing_modes_.count(swing_mode); }
bool get_supports_swing_modes() const { return !this->supported_swing_modes_.empty(); }
const std::set<ClimateSwingMode> &get_supported_swing_modes() const { return this->supported_swing_modes_; }

View File

@@ -805,6 +805,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True)
add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True)
cg.add_build_flag("-Wno-nonnull-compare")

View File

@@ -6,6 +6,7 @@
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <esp_idf_version.h>
#include <esp_ota_ops.h>
#include <esp_task_wdt.h>
#include <esp_timer.h>
#include <soc/rtc.h>
@@ -52,6 +53,16 @@ void arch_init() {
disableCore1WDT();
#endif
#endif
// If the bootloader was compiled with CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE the current
// partition will get rolled back unless it is marked as valid.
esp_ota_img_states_t state;
const esp_partition_t *running = esp_ota_get_running_partition();
if (esp_ota_get_state_partition(running, &state) == ESP_OK) {
if (state == ESP_OTA_IMG_PENDING_VERIFY) {
esp_ota_mark_app_valid_cancel_rollback();
}
}
}
void IRAM_ATTR HOT arch_feed_wdt() { esp_task_wdt_reset(); }

View File

@@ -61,12 +61,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
this->address_str_ = "";
} else {
char buf[18];
uint8_t mac[6] = {
(uint8_t) ((this->address_ >> 40) & 0xff), (uint8_t) ((this->address_ >> 32) & 0xff),
(uint8_t) ((this->address_ >> 24) & 0xff), (uint8_t) ((this->address_ >> 16) & 0xff),
(uint8_t) ((this->address_ >> 8) & 0xff), (uint8_t) ((this->address_ >> 0) & 0xff),
};
format_mac_addr_upper(mac, buf);
format_mac_addr_upper(this->remote_bda_, buf);
this->address_str_ = buf;
}
}

View File

@@ -14,7 +14,7 @@ void Kuntze::on_modbus_data(const std::vector<uint8_t> &data) {
auto get_16bit = [&](int i) -> uint16_t { return (uint16_t(data[i * 2]) << 8) | uint16_t(data[i * 2 + 1]); };
this->waiting_ = false;
ESP_LOGV(TAG, "Data: %s", hexencode(data).c_str());
ESP_LOGV(TAG, "Data: %s", format_hex_pretty(data).c_str());
float value = (float) get_16bit(0);
for (int i = 0; i < data[3]; i++)

View File

@@ -1,11 +1,11 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/color.h"
#include "esp_color_correction.h"
#include "esp_color_view.h"
#include "esp_range_view.h"
#include "esphome/core/color.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "light_output.h"
#include "light_state.h"
#include "transformers.h"
@@ -17,8 +17,6 @@
namespace esphome {
namespace light {
using ESPColor ESPDEPRECATED("esphome::light::ESPColor is deprecated, use esphome::Color instead.", "v1.21") = Color;
/// Convert the color information from a `LightColorValues` object to a `Color` object (does not apply brightness).
Color color_from_light_color_values(LightColorValues val);

View File

@@ -104,5 +104,200 @@ constexpr ColorModeHelper operator|(ColorModeHelper lhs, ColorMode rhs) {
return static_cast<ColorMode>(static_cast<uint8_t>(lhs) | static_cast<uint8_t>(rhs));
}
// Type alias for raw color mode bitmask values
using color_mode_bitmask_t = uint16_t;
// Constants for ColorMode count and bit range
static constexpr int COLOR_MODE_COUNT = 10; // UNKNOWN through RGB_COLD_WARM_WHITE
static constexpr int MAX_BIT_INDEX = sizeof(color_mode_bitmask_t) * 8; // Number of bits in bitmask type
// Compile-time array of all ColorMode values in declaration order
// Bit positions (0-9) map directly to enum declaration order
static constexpr ColorMode COLOR_MODES[COLOR_MODE_COUNT] = {
ColorMode::UNKNOWN, // bit 0
ColorMode::ON_OFF, // bit 1
ColorMode::BRIGHTNESS, // bit 2
ColorMode::WHITE, // bit 3
ColorMode::COLOR_TEMPERATURE, // bit 4
ColorMode::COLD_WARM_WHITE, // bit 5
ColorMode::RGB, // bit 6
ColorMode::RGB_WHITE, // bit 7
ColorMode::RGB_COLOR_TEMPERATURE, // bit 8
ColorMode::RGB_COLD_WARM_WHITE, // bit 9
};
/// Map ColorMode enum values to bit positions (0-9)
/// Bit positions follow the enum declaration order
static constexpr int mode_to_bit(ColorMode mode) {
// Linear search through COLOR_MODES array
// Compiler optimizes this to efficient code since array is constexpr
for (int i = 0; i < COLOR_MODE_COUNT; ++i) {
if (COLOR_MODES[i] == mode)
return i;
}
return 0;
}
/// Map bit positions (0-9) to ColorMode enum values
/// Bit positions follow the enum declaration order
static constexpr ColorMode bit_to_mode(int bit) {
// Direct lookup in COLOR_MODES array
return (bit >= 0 && bit < COLOR_MODE_COUNT) ? COLOR_MODES[bit] : ColorMode::UNKNOWN;
}
/// Helper to compute capability bitmask at compile time
static constexpr color_mode_bitmask_t compute_capability_bitmask(ColorCapability capability) {
color_mode_bitmask_t mask = 0;
uint8_t cap_bit = static_cast<uint8_t>(capability);
// Check each ColorMode to see if it has this capability
for (int bit = 0; bit < COLOR_MODE_COUNT; ++bit) {
uint8_t mode_val = static_cast<uint8_t>(bit_to_mode(bit));
if ((mode_val & cap_bit) != 0) {
mask |= (1 << bit);
}
}
return mask;
}
// Number of ColorCapability enum values
static constexpr int COLOR_CAPABILITY_COUNT = 6;
/// Compile-time lookup table mapping ColorCapability to bitmask
/// This array is computed at compile time using constexpr
static constexpr color_mode_bitmask_t CAPABILITY_BITMASKS[] = {
compute_capability_bitmask(ColorCapability::ON_OFF), // 1 << 0
compute_capability_bitmask(ColorCapability::BRIGHTNESS), // 1 << 1
compute_capability_bitmask(ColorCapability::WHITE), // 1 << 2
compute_capability_bitmask(ColorCapability::COLOR_TEMPERATURE), // 1 << 3
compute_capability_bitmask(ColorCapability::COLD_WARM_WHITE), // 1 << 4
compute_capability_bitmask(ColorCapability::RGB), // 1 << 5
};
/// Bitmask for storing a set of ColorMode values efficiently.
/// Replaces std::set<ColorMode> to eliminate red-black tree overhead (~586 bytes).
class ColorModeMask {
public:
constexpr ColorModeMask() = default;
/// Support initializer list syntax: {ColorMode::RGB, ColorMode::WHITE}
constexpr ColorModeMask(std::initializer_list<ColorMode> modes) {
for (auto mode : modes) {
this->add(mode);
}
}
constexpr void add(ColorMode mode) { this->mask_ |= (1 << mode_to_bit(mode)); }
/// Add multiple modes at once using initializer list
constexpr void add(std::initializer_list<ColorMode> modes) {
for (auto mode : modes) {
this->add(mode);
}
}
constexpr bool contains(ColorMode mode) const { return (this->mask_ & (1 << mode_to_bit(mode))) != 0; }
constexpr size_t size() const {
// Count set bits using Brian Kernighan's algorithm
// More efficient for sparse bitmasks (typical case: 2-4 modes out of 10)
uint16_t n = this->mask_;
size_t count = 0;
while (n) {
n &= n - 1; // Clear the least significant set bit
count++;
}
return count;
}
constexpr bool empty() const { return this->mask_ == 0; }
/// Iterator support for API encoding
class Iterator {
public:
using iterator_category = std::forward_iterator_tag;
using value_type = ColorMode;
using difference_type = std::ptrdiff_t;
using pointer = const ColorMode *;
using reference = ColorMode;
constexpr Iterator(color_mode_bitmask_t mask, int bit) : mask_(mask), bit_(bit) { advance_to_next_set_bit_(); }
constexpr ColorMode operator*() const { return bit_to_mode(bit_); }
constexpr Iterator &operator++() {
++bit_;
advance_to_next_set_bit_();
return *this;
}
constexpr bool operator==(const Iterator &other) const { return bit_ == other.bit_; }
constexpr bool operator!=(const Iterator &other) const { return !(*this == other); }
private:
constexpr void advance_to_next_set_bit_() { bit_ = ColorModeMask::find_next_set_bit(mask_, bit_); }
color_mode_bitmask_t mask_;
int bit_;
};
constexpr Iterator begin() const { return Iterator(mask_, 0); }
constexpr Iterator end() const { return Iterator(mask_, MAX_BIT_INDEX); }
/// Get the raw bitmask value for API encoding
constexpr color_mode_bitmask_t get_mask() const { return this->mask_; }
/// Find the next set bit in a bitmask starting from a given position
/// Returns the bit position, or MAX_BIT_INDEX if no more bits are set
static constexpr int find_next_set_bit(color_mode_bitmask_t mask, int start_bit) {
int bit = start_bit;
while (bit < MAX_BIT_INDEX && !(mask & (1 << bit))) {
++bit;
}
return bit;
}
/// Find the first set bit in a bitmask and return the corresponding ColorMode
/// Used for optimizing compute_color_mode_() intersection logic
static constexpr ColorMode first_mode_from_mask(color_mode_bitmask_t mask) {
return bit_to_mode(find_next_set_bit(mask, 0));
}
/// Check if a ColorMode is present in a raw bitmask value
/// Useful for checking intersection results without creating a temporary ColorModeMask
static constexpr bool mask_contains(color_mode_bitmask_t mask, ColorMode mode) {
return (mask & (1 << mode_to_bit(mode))) != 0;
}
/// Check if any mode in the bitmask has a specific capability
/// Used for checking if a light supports a capability (e.g., BRIGHTNESS, RGB)
bool has_capability(ColorCapability capability) const {
// Lookup the pre-computed bitmask for this capability and check intersection with our mask
// ColorCapability values: 1, 2, 4, 8, 16, 32 -> array indices: 0, 1, 2, 3, 4, 5
// We need to convert the power-of-2 value to an index
uint8_t cap_val = static_cast<uint8_t>(capability);
#if defined(__GNUC__) || defined(__clang__)
// Use compiler intrinsic for efficient bit position lookup (O(1) vs O(log n))
int index = __builtin_ctz(cap_val);
#else
// Fallback for compilers without __builtin_ctz
int index = 0;
while (cap_val > 1) {
cap_val >>= 1;
++index;
}
#endif
return (this->mask_ & CAPABILITY_BITMASKS[index]) != 0;
}
private:
// Using uint16_t instead of uint32_t for more efficient iteration (fewer bits to scan).
// Currently only 10 ColorMode values exist, so 16 bits is sufficient.
// Can be changed to uint32_t if more than 16 color modes are needed in the future.
// Note: Due to struct padding, uint16_t and uint32_t result in same LightTraits size (12 bytes).
color_mode_bitmask_t mask_{0};
};
} // namespace light
} // namespace esphome

View File

@@ -406,7 +406,7 @@ void LightCall::transform_parameters_() {
}
}
ColorMode LightCall::compute_color_mode_() {
auto supported_modes = this->parent_->get_traits().get_supported_color_modes();
const auto &supported_modes = this->parent_->get_traits().get_supported_color_modes();
int supported_count = supported_modes.size();
// Some lights don't support any color modes (e.g. monochromatic light), leave it at unknown.
@@ -425,20 +425,19 @@ ColorMode LightCall::compute_color_mode_() {
// If no color mode is specified, we try to guess the color mode. This is needed for backward compatibility to
// pre-colormode clients and automations, but also for the MQTT API, where HA doesn't let us know which color mode
// was used for some reason.
std::set<ColorMode> suitable_modes = this->get_suitable_color_modes_();
// Compute intersection of suitable and supported modes using bitwise AND
color_mode_bitmask_t intersection = this->get_suitable_color_modes_mask_() & supported_modes.get_mask();
// Don't change if the current mode is suitable.
if (suitable_modes.count(current_mode) > 0) {
// Don't change if the current mode is in the intersection (suitable AND supported)
if (ColorModeMask::mask_contains(intersection, current_mode)) {
ESP_LOGI(TAG, "'%s': color mode not specified; retaining %s", this->parent_->get_name().c_str(),
LOG_STR_ARG(color_mode_to_human(current_mode)));
return current_mode;
}
// Use the preferred suitable mode.
for (auto mode : suitable_modes) {
if (supported_modes.count(mode) == 0)
continue;
if (intersection != 0) {
ColorMode mode = ColorModeMask::first_mode_from_mask(intersection);
ESP_LOGI(TAG, "'%s': color mode not specified; using %s", this->parent_->get_name().c_str(),
LOG_STR_ARG(color_mode_to_human(mode)));
return mode;
@@ -451,7 +450,7 @@ ColorMode LightCall::compute_color_mode_() {
LOG_STR_ARG(color_mode_to_human(color_mode)));
return color_mode;
}
std::set<ColorMode> LightCall::get_suitable_color_modes_() {
color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() {
bool has_white = this->has_white() && this->white_ > 0.0f;
bool has_ct = this->has_color_temperature();
bool has_cwww =
@@ -459,36 +458,44 @@ std::set<ColorMode> LightCall::get_suitable_color_modes_() {
bool has_rgb = (this->has_color_brightness() && this->color_brightness_ > 0.0f) ||
(this->has_red() || this->has_green() || this->has_blue());
// Build key from flags: [rgb][cwww][ct][white]
// Build key from flags: [rgb][cwww][ct][white]
#define KEY(white, ct, cwww, rgb) ((white) << 0 | (ct) << 1 | (cwww) << 2 | (rgb) << 3)
uint8_t key = KEY(has_white, has_ct, has_cwww, has_rgb);
switch (key) {
case KEY(true, false, false, false): // white only
return {ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE,
ColorMode::RGB_COLD_WARM_WHITE};
return ColorModeMask({ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(false, true, false, false): // ct only
return {ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE,
ColorMode::RGB_COLD_WARM_WHITE};
return ColorModeMask({ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE,
ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(true, true, false, false): // white + ct
return {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE};
return ColorModeMask(
{ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(false, false, true, false): // cwww only
return {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE};
return ColorModeMask({ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}).get_mask();
case KEY(false, false, false, false): // none
return {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB,
ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE};
return ColorModeMask({ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE,
ColorMode::RGB, ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE})
.get_mask();
case KEY(true, false, false, true): // rgb + white
return {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE};
return ColorModeMask({ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(false, true, false, true): // rgb + ct
case KEY(true, true, false, true): // rgb + white + ct
return {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE};
return ColorModeMask({ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}).get_mask();
case KEY(false, false, true, true): // rgb + cwww
return {ColorMode::RGB_COLD_WARM_WHITE};
return ColorModeMask({ColorMode::RGB_COLD_WARM_WHITE}).get_mask();
case KEY(false, false, false, true): // rgb only
return {ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE};
return ColorModeMask({ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
default:
return {}; // conflicting flags
return 0; // conflicting flags
}
#undef KEY

View File

@@ -1,7 +1,6 @@
#pragma once
#include "light_color_values.h"
#include <set>
namespace esphome {
@@ -186,8 +185,8 @@ class LightCall {
//// Compute the color mode that should be used for this call.
ColorMode compute_color_mode_();
/// Get potential color modes for this light call.
std::set<ColorMode> get_suitable_color_modes_();
/// Get potential color modes bitmask for this light call.
color_mode_bitmask_t get_suitable_color_modes_mask_();
/// Some color modes also can be set using non-native parameters, transform those calls.
void transform_parameters_();

View File

@@ -43,7 +43,6 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) {
}
auto values = state.remote_values;
auto traits = state.get_output()->get_traits();
const auto color_mode = values.get_color_mode();
const char *mode_str = get_color_mode_json_str(color_mode);

View File

@@ -191,11 +191,9 @@ void LightState::current_values_as_brightness(float *brightness) {
this->current_values.as_brightness(brightness, this->gamma_correct_);
}
void LightState::current_values_as_rgb(float *red, float *green, float *blue, bool color_interlock) {
auto traits = this->get_traits();
this->current_values.as_rgb(red, green, blue, this->gamma_correct_, false);
}
void LightState::current_values_as_rgbw(float *red, float *green, float *blue, float *white, bool color_interlock) {
auto traits = this->get_traits();
this->current_values.as_rgbw(red, green, blue, white, this->gamma_correct_, false);
}
void LightState::current_values_as_rgbww(float *red, float *green, float *blue, float *cold_white, float *warm_white,
@@ -209,7 +207,6 @@ void LightState::current_values_as_rgbct(float *red, float *green, float *blue,
white_brightness, this->gamma_correct_);
}
void LightState::current_values_as_cwww(float *cold_white, float *warm_white, bool constant_brightness) {
auto traits = this->get_traits();
this->current_values.as_cwww(cold_white, warm_white, this->gamma_correct_, constant_brightness);
}
void LightState::current_values_as_ct(float *color_temperature, float *white_brightness) {

View File

@@ -1,8 +1,7 @@
#pragma once
#include "esphome/core/helpers.h"
#include "color_mode.h"
#include <set>
#include "esphome/core/helpers.h"
namespace esphome {
@@ -19,38 +18,17 @@ class LightTraits {
public:
LightTraits() = default;
const std::set<ColorMode> &get_supported_color_modes() const { return this->supported_color_modes_; }
void set_supported_color_modes(std::set<ColorMode> supported_color_modes) {
this->supported_color_modes_ = std::move(supported_color_modes);
const ColorModeMask &get_supported_color_modes() const { return this->supported_color_modes_; }
void set_supported_color_modes(ColorModeMask supported_color_modes) {
this->supported_color_modes_ = supported_color_modes;
}
void set_supported_color_modes(std::initializer_list<ColorMode> modes) {
this->supported_color_modes_ = ColorModeMask(modes);
}
bool supports_color_mode(ColorMode color_mode) const { return this->supported_color_modes_.count(color_mode); }
bool supports_color_mode(ColorMode color_mode) const { return this->supported_color_modes_.contains(color_mode); }
bool supports_color_capability(ColorCapability color_capability) const {
for (auto mode : this->supported_color_modes_) {
if (mode & color_capability)
return true;
}
return false;
}
ESPDEPRECATED("get_supports_brightness() is deprecated, use color modes instead.", "v1.21")
bool get_supports_brightness() const { return this->supports_color_capability(ColorCapability::BRIGHTNESS); }
ESPDEPRECATED("get_supports_rgb() is deprecated, use color modes instead.", "v1.21")
bool get_supports_rgb() const { return this->supports_color_capability(ColorCapability::RGB); }
ESPDEPRECATED("get_supports_rgb_white_value() is deprecated, use color modes instead.", "v1.21")
bool get_supports_rgb_white_value() const {
return this->supports_color_mode(ColorMode::RGB_WHITE) ||
this->supports_color_mode(ColorMode::RGB_COLOR_TEMPERATURE);
}
ESPDEPRECATED("get_supports_color_temperature() is deprecated, use color modes instead.", "v1.21")
bool get_supports_color_temperature() const {
return this->supports_color_capability(ColorCapability::COLOR_TEMPERATURE);
}
ESPDEPRECATED("get_supports_color_interlock() is deprecated, use color modes instead.", "v1.21")
bool get_supports_color_interlock() const {
return this->supports_color_mode(ColorMode::RGB) &&
(this->supports_color_mode(ColorMode::WHITE) || this->supports_color_mode(ColorMode::COLD_WARM_WHITE) ||
this->supports_color_mode(ColorMode::COLOR_TEMPERATURE));
return this->supported_color_modes_.has_capability(color_capability);
}
float get_min_mireds() const { return this->min_mireds_; }
@@ -59,19 +37,9 @@ class LightTraits {
void set_max_mireds(float max_mireds) { this->max_mireds_ = max_mireds; }
protected:
#ifdef USE_API
// The API connection is a friend class to access internal methods
friend class api::APIConnection;
// This method returns a reference to the internal color modes set.
// It is used by the API to avoid copying data when encoding messages.
// Warning: Do not use this method outside of the API connection code.
// It returns a reference to internal data that can be invalidated.
const std::set<ColorMode> &get_supported_color_modes_for_api_() const { return this->supported_color_modes_; }
#endif
std::set<ColorMode> supported_color_modes_{};
float min_mireds_{0};
float max_mireds_{0};
ColorModeMask supported_color_modes_{};
};
} // namespace light

View File

@@ -294,3 +294,5 @@ void Logger::set_log_level(uint8_t level) {
Logger *global_logger = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::logger
// test

View File

@@ -68,6 +68,9 @@ static constexpr char LOG_LEVEL_LETTER_CHARS[] = {
// Maximum header size: 35 bytes fixed + 32 bytes tag + 16 bytes thread name = 83 bytes (45 byte safety margin)
static constexpr uint16_t MAX_HEADER_SIZE = 128;
// "0x" + 2 hex digits per byte + '\0'
static constexpr size_t MAX_POINTER_REPRESENTATION = 2 + sizeof(void *) * 2 + 1;
#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR)
/** Enum for logging UART selection
*
@@ -177,8 +180,11 @@ class Logger : public Component {
inline void HOT format_log_to_buffer_with_terminator_(uint8_t level, const char *tag, int line, const char *format,
va_list args, char *buffer, uint16_t *buffer_at,
uint16_t buffer_size) {
#if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR)
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
this->write_header_to_buffer_(level, tag, line, this->get_thread_name_(), buffer, buffer_at, buffer_size);
#elif defined(USE_ZEPHYR)
char buff[MAX_POINTER_REPRESENTATION];
this->write_header_to_buffer_(level, tag, line, this->get_thread_name_(buff), buffer, buffer_at, buffer_size);
#else
this->write_header_to_buffer_(level, tag, line, nullptr, buffer, buffer_at, buffer_size);
#endif
@@ -277,7 +283,11 @@ class Logger : public Component {
#endif
#if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR)
const char *HOT get_thread_name_() {
const char *HOT get_thread_name_(
#ifdef USE_ZEPHYR
char *buff
#endif
) {
#ifdef USE_ZEPHYR
k_tid_t current_task = k_current_get();
#else
@@ -291,7 +301,13 @@ class Logger : public Component {
#elif defined(USE_LIBRETINY)
return pcTaskGetTaskName(current_task);
#elif defined(USE_ZEPHYR)
return k_thread_name_get(current_task);
const char *name = k_thread_name_get(current_task);
if (name) {
// zephyr print task names only if debug component is present
return name;
}
std::snprintf(buff, MAX_POINTER_REPRESENTATION, "%p", current_task);
return buff;
#endif
}
}
@@ -453,3 +469,5 @@ class LoggerMessageTrigger : public Trigger<uint8_t, const char *, const char *>
};
} // namespace esphome::logger
// test

View File

@@ -167,3 +167,5 @@ const LogString *Logger::get_uart_selection_() {
} // namespace esphome::logger
#endif
// test

View File

@@ -49,3 +49,5 @@ const LogString *Logger::get_uart_selection_() {
} // namespace esphome::logger
#endif
// test

View File

@@ -20,3 +20,5 @@ void Logger::pre_setup() { global_logger = this; }
} // namespace esphome::logger
#endif
// test

View File

@@ -68,3 +68,5 @@ const LogString *Logger::get_uart_selection_() {
} // namespace esphome::logger
#endif // USE_LIBRETINY
// test

View File

@@ -46,3 +46,5 @@ const LogString *Logger::get_uart_selection_() {
} // namespace esphome::logger
#endif // USE_RP2040
// test

View File

@@ -94,3 +94,5 @@ const LogString *Logger::get_uart_selection_() {
} // namespace esphome::logger
#endif
// test

View File

@@ -134,3 +134,5 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin
} // namespace esphome::logger
#endif // USE_ESPHOME_TASK_LOG_BUFFER
// test

View File

@@ -65,3 +65,5 @@ class TaskLogBuffer {
} // namespace esphome::logger
#endif // USE_ESPHOME_TASK_LOG_BUFFER
// test

View File

@@ -31,18 +31,17 @@ void MDNSComponent::setup() {
mdns_instance_name_set(this->hostname_.c_str());
for (const auto &service : services) {
std::vector<mdns_txt_item_t> txt_records;
for (const auto &record : service.txt_records) {
mdns_txt_item_t it{};
auto txt_records = std::make_unique<mdns_txt_item_t[]>(service.txt_records.size());
for (size_t i = 0; i < service.txt_records.size(); i++) {
const auto &record = service.txt_records[i];
// key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_
// Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies
it.key = MDNS_STR_ARG(record.key);
it.value = MDNS_STR_ARG(record.value);
txt_records.push_back(it);
txt_records[i].key = MDNS_STR_ARG(record.key);
txt_records[i].value = MDNS_STR_ARG(record.value);
}
uint16_t port = const_cast<TemplatableValue<uint16_t> &>(service.port).value();
err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port,
txt_records.data(), txt_records.size());
txt_records.get(), service.txt_records.size());
if (err != ESP_OK) {
ESP_LOGW(TAG, "Failed to register service %s: %s", MDNS_STR_ARG(service.service_type), esp_err_to_name(err));

View File

@@ -140,11 +140,8 @@ void MQTTClientComponent::send_device_info_() {
#endif
#ifdef USE_API_NOISE
if (api::global_api_server->get_noise_ctx()->has_psk()) {
root["api_encryption"] = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
} else {
root["api_encryption_supported"] = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
}
root[api::global_api_server->get_noise_ctx()->has_psk() ? "api_encryption" : "api_encryption_supported"] =
"Noise_NNpsk0_25519_ChaChaPoly_SHA256";
#endif
},
2, this->discovery_info_.retain);

View File

@@ -17,11 +17,11 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
auto traits = this->device_->get_traits();
// current_temperature_topic
if (traits.get_supports_current_temperature()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
root[MQTT_CURRENT_TEMPERATURE_TOPIC] = this->get_current_temperature_state_topic();
}
// current_humidity_topic
if (traits.get_supports_current_humidity()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) {
root[MQTT_CURRENT_HUMIDITY_TOPIC] = this->get_current_humidity_state_topic();
}
// mode_command_topic
@@ -45,7 +45,8 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
if (traits.supports_mode(CLIMATE_MODE_HEAT_COOL))
modes.add("heat_cool");
if (traits.get_supports_two_point_target_temperature()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
// temperature_low_command_topic
root[MQTT_TEMPERATURE_LOW_COMMAND_TOPIC] = this->get_target_temperature_low_command_topic();
// temperature_low_state_topic
@@ -61,7 +62,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
root[MQTT_TEMPERATURE_STATE_TOPIC] = this->get_target_temperature_state_topic();
}
if (traits.get_supports_target_humidity()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
// target_humidity_command_topic
root[MQTT_TARGET_HUMIDITY_COMMAND_TOPIC] = this->get_target_humidity_command_topic();
// target_humidity_state_topic
@@ -109,7 +110,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
presets.add(preset);
}
if (traits.get_supports_action()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
// action_topic
root[MQTT_ACTION_TOPIC] = this->get_action_state_topic();
}
@@ -174,7 +175,8 @@ void MQTTClimateComponent::setup() {
call.perform();
});
if (traits.get_supports_two_point_target_temperature()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
this->subscribe(this->get_target_temperature_low_command_topic(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_number<float>(payload);
@@ -211,7 +213,7 @@ void MQTTClimateComponent::setup() {
});
}
if (traits.get_supports_target_humidity()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
this->subscribe(this->get_target_humidity_command_topic(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_number<float>(payload);
@@ -290,12 +292,14 @@ bool MQTTClimateComponent::publish_state_() {
success = false;
int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals();
int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals();
if (traits.get_supports_current_temperature() && !std::isnan(this->device_->current_temperature)) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE) &&
!std::isnan(this->device_->current_temperature)) {
std::string payload = value_accuracy_to_string(this->device_->current_temperature, current_accuracy);
if (!this->publish(this->get_current_temperature_state_topic(), payload))
success = false;
}
if (traits.get_supports_two_point_target_temperature()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
std::string payload = value_accuracy_to_string(this->device_->target_temperature_low, target_accuracy);
if (!this->publish(this->get_target_temperature_low_state_topic(), payload))
success = false;
@@ -308,12 +312,14 @@ bool MQTTClimateComponent::publish_state_() {
success = false;
}
if (traits.get_supports_current_humidity() && !std::isnan(this->device_->current_humidity)) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY) &&
!std::isnan(this->device_->current_humidity)) {
std::string payload = value_accuracy_to_string(this->device_->current_humidity, 0);
if (!this->publish(this->get_current_humidity_state_topic(), payload))
success = false;
}
if (traits.get_supports_target_humidity() && !std::isnan(this->device_->target_humidity)) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY) &&
!std::isnan(this->device_->target_humidity)) {
std::string payload = value_accuracy_to_string(this->device_->target_humidity, 0);
if (!this->publish(this->get_target_humidity_state_topic(), payload))
success = false;
@@ -357,7 +363,7 @@ bool MQTTClimateComponent::publish_state_() {
success = false;
}
if (traits.get_supports_action()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
const char *payload;
switch (this->device_->action) {
case CLIMATE_ACTION_OFF:

View File

@@ -85,24 +85,20 @@ bool MQTTComponent::send_discovery_() {
}
// Fields from EntityBase
if (this->get_entity()->has_own_name()) {
root[MQTT_NAME] = this->friendly_name();
} else {
root[MQTT_NAME] = "";
}
root[MQTT_NAME] = this->get_entity()->has_own_name() ? this->friendly_name() : "";
if (this->is_disabled_by_default())
root[MQTT_ENABLED_BY_DEFAULT] = false;
if (!this->get_icon().empty())
root[MQTT_ICON] = this->get_icon();
switch (this->get_entity()->get_entity_category()) {
const auto entity_category = this->get_entity()->get_entity_category();
switch (entity_category) {
case ENTITY_CATEGORY_NONE:
break;
case ENTITY_CATEGORY_CONFIG:
root[MQTT_ENTITY_CATEGORY] = "config";
break;
case ENTITY_CATEGORY_DIAGNOSTIC:
root[MQTT_ENTITY_CATEGORY] = "diagnostic";
root[MQTT_ENTITY_CATEGORY] = entity_category == ENTITY_CATEGORY_CONFIG ? "config" : "diagnostic";
break;
}
@@ -113,20 +109,14 @@ bool MQTTComponent::send_discovery_() {
if (this->command_retain_)
root[MQTT_COMMAND_RETAIN] = true;
if (this->availability_ == nullptr) {
if (!global_mqtt_client->get_availability().topic.empty()) {
root[MQTT_AVAILABILITY_TOPIC] = global_mqtt_client->get_availability().topic;
if (global_mqtt_client->get_availability().payload_available != "online")
root[MQTT_PAYLOAD_AVAILABLE] = global_mqtt_client->get_availability().payload_available;
if (global_mqtt_client->get_availability().payload_not_available != "offline")
root[MQTT_PAYLOAD_NOT_AVAILABLE] = global_mqtt_client->get_availability().payload_not_available;
}
} else if (!this->availability_->topic.empty()) {
root[MQTT_AVAILABILITY_TOPIC] = this->availability_->topic;
if (this->availability_->payload_available != "online")
root[MQTT_PAYLOAD_AVAILABLE] = this->availability_->payload_available;
if (this->availability_->payload_not_available != "offline")
root[MQTT_PAYLOAD_NOT_AVAILABLE] = this->availability_->payload_not_available;
const Availability &avail =
this->availability_ == nullptr ? global_mqtt_client->get_availability() : *this->availability_;
if (!avail.topic.empty()) {
root[MQTT_AVAILABILITY_TOPIC] = avail.topic;
if (avail.payload_available != "online")
root[MQTT_PAYLOAD_AVAILABLE] = avail.payload_available;
if (avail.payload_not_available != "offline")
root[MQTT_PAYLOAD_NOT_AVAILABLE] = avail.payload_not_available;
}
const MQTTDiscoveryInfo &discovery_info = global_mqtt_client->get_discovery_info();
@@ -145,10 +135,8 @@ bool MQTTComponent::send_discovery_() {
if (discovery_info.object_id_generator == MQTT_DEVICE_NAME_OBJECT_ID_GENERATOR)
root[MQTT_OBJECT_ID] = node_name + "_" + this->get_default_object_id_();
std::string node_friendly_name = App.get_friendly_name();
if (node_friendly_name.empty()) {
node_friendly_name = node_name;
}
const std::string &friendly_name_ref = App.get_friendly_name();
const std::string &node_friendly_name = friendly_name_ref.empty() ? node_name : friendly_name_ref;
std::string node_area = App.get_area();
JsonObject device_info = root[MQTT_DEVICE].to<JsonObject>();
@@ -158,13 +146,9 @@ bool MQTTComponent::send_discovery_() {
#ifdef ESPHOME_PROJECT_NAME
device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_PROJECT_VERSION " (ESPHome " ESPHOME_VERSION ")";
const char *model = std::strchr(ESPHOME_PROJECT_NAME, '.');
if (model == nullptr) { // must never happen but check anyway
device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD;
device_info[MQTT_DEVICE_MANUFACTURER] = ESPHOME_PROJECT_NAME;
} else {
device_info[MQTT_DEVICE_MODEL] = model + 1;
device_info[MQTT_DEVICE_MANUFACTURER] = std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME);
}
device_info[MQTT_DEVICE_MODEL] = model == nullptr ? ESPHOME_BOARD : model + 1;
device_info[MQTT_DEVICE_MANUFACTURER] =
model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME);
#else
device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_VERSION " (" + App.get_compilation_time() + ")";
device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD;

View File

@@ -1291,9 +1291,6 @@ void Nextion::check_pending_waveform_() {
void Nextion::set_writer(const nextion_writer_t &writer) { this->writer_ = writer; }
ESPDEPRECATED("set_wait_for_ack(bool) deprecated, no effect", "v1.20")
void Nextion::set_wait_for_ack(bool wait_for_ack) { ESP_LOGE(TAG, "Deprecated"); }
bool Nextion::is_updating() { return this->connection_state_.is_updating_; }
} // namespace nextion

View File

@@ -916,7 +916,7 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima
auto min_temp_value = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy);
climate_value_row_(stream, obj, area, node, friendly_name, min_temp, min_temp_value);
// now check optional traits
if (traits.get_supports_current_temperature()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
std::string current_temp = "current_temperature";
if (std::isnan(obj->current_temperature)) {
climate_failed_row_(stream, obj, area, node, friendly_name, current_temp, true);
@@ -927,7 +927,7 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima
climate_failed_row_(stream, obj, area, node, friendly_name, current_temp, false);
}
}
if (traits.get_supports_current_humidity()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) {
std::string current_humidity = "current_humidity";
if (std::isnan(obj->current_humidity)) {
climate_failed_row_(stream, obj, area, node, friendly_name, current_humidity, true);
@@ -938,7 +938,7 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima
climate_failed_row_(stream, obj, area, node, friendly_name, current_humidity, false);
}
}
if (traits.get_supports_target_humidity()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
std::string target_humidity = "target_humidity";
if (std::isnan(obj->target_humidity)) {
climate_failed_row_(stream, obj, area, node, friendly_name, target_humidity, true);
@@ -949,7 +949,8 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima
climate_failed_row_(stream, obj, area, node, friendly_name, target_humidity, false);
}
}
if (traits.get_supports_two_point_target_temperature()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
std::string target_temp_low = "target_temperature_low";
auto target_temp_low_value = value_accuracy_to_string(obj->target_temperature_low, target_accuracy);
climate_value_row_(stream, obj, area, node, friendly_name, target_temp_low, target_temp_low_value);
@@ -961,7 +962,7 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima
auto target_temp_value = value_accuracy_to_string(obj->target_temperature, target_accuracy);
climate_value_row_(stream, obj, area, node, friendly_name, target_temp, target_temp_value);
}
if (traits.get_supports_action()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
std::string climate_trait_category = "action";
const auto *climate_trait_value = climate::climate_action_to_string(obj->action);
climate_setting_row_(stream, obj, area, node, friendly_name, climate_trait_category, climate_trait_value);

View File

@@ -81,7 +81,7 @@ CONFIG_SCHEMA = (
cv.int_range(min=0, max=0xFFFF, max_included=False),
),
cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.pressure,
cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature,
cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature_delta,
cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id(
sensor.Sensor
),

View File

@@ -45,13 +45,26 @@ def get_script(script_id):
def check_max_runs(value):
# Set default for queued mode to prevent unbounded queue growth
if CONF_MAX_RUNS not in value and value[CONF_MODE] == CONF_QUEUED:
value[CONF_MAX_RUNS] = 5
if CONF_MAX_RUNS not in value:
return value
if value[CONF_MODE] not in [CONF_QUEUED, CONF_PARALLEL]:
raise cv.Invalid(
"The option 'max_runs' is only valid in 'queue' and 'parallel' mode.",
"The option 'max_runs' is only valid in 'queued' and 'parallel' mode.",
path=[CONF_MAX_RUNS],
)
# Queued mode must have bounded queue (min 1), parallel mode can be unlimited (0)
if value[CONF_MODE] == CONF_QUEUED and value[CONF_MAX_RUNS] < 1:
raise cv.Invalid(
"The option 'max_runs' must be at least 1 for queued mode.",
path=[CONF_MAX_RUNS],
)
return value
@@ -106,7 +119,7 @@ CONFIG_SCHEMA = automation.validate_automation(
cv.Optional(CONF_MODE, default=CONF_SINGLE): cv.one_of(
*SCRIPT_MODES, lower=True
),
cv.Optional(CONF_MAX_RUNS): cv.positive_int,
cv.Optional(CONF_MAX_RUNS): cv.int_range(min=0, max=100),
cv.Optional(CONF_PARAMETERS, default={}): cv.Schema(
{
validate_parameter_name: validate_parameter_type,

View File

@@ -1,10 +1,11 @@
#pragma once
#include <memory>
#include <tuple>
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <queue>
namespace esphome {
namespace script {
@@ -96,23 +97,41 @@ template<typename... Ts> class RestartScript : public Script<Ts...> {
/** A script type that queues new instances that are created.
*
* Only one instance of the script can be active at a time.
*
* Ring buffer implementation:
* - num_queued_ tracks the number of queued (waiting) instances, NOT including the currently running one
* - queue_front_ points to the next item to execute (read position)
* - Buffer size is max_runs_ - 1 (max total instances minus the running one)
* - Write position is calculated as: (queue_front_ + num_queued_) % (max_runs_ - 1)
* - When an item finishes, queue_front_ advances: (queue_front_ + 1) % (max_runs_ - 1)
* - First execute() runs immediately without queuing (num_queued_ stays 0)
* - Subsequent executes while running are queued starting at position 0
* - Maximum total instances = max_runs_ (includes 1 running + (max_runs_ - 1) queued)
*/
template<typename... Ts> class QueueingScript : public Script<Ts...>, public Component {
public:
void execute(Ts... x) override {
if (this->is_action_running() || this->num_runs_ > 0) {
// num_runs_ is the number of *queued* instances, so total number of instances is
// num_runs_ + 1
if (this->max_runs_ != 0 && this->num_runs_ + 1 >= this->max_runs_) {
this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of queued runs exceeded!"),
if (this->is_action_running() || this->num_queued_ > 0) {
// num_queued_ is the number of *queued* instances (waiting, not including currently running)
// max_runs_ is the maximum *total* instances (running + queued)
// So we reject when num_queued_ + 1 >= max_runs_ (queued + running >= max)
if (this->num_queued_ + 1 >= this->max_runs_) {
this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' max instances (running + queued) reached!"),
LOG_STR_ARG(this->name_));
return;
}
// Initialize queue on first queued item (after capacity check)
this->lazy_init_queue_();
this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' queueing new instance (mode: queued)"),
LOG_STR_ARG(this->name_));
this->num_runs_++;
this->var_queue_.push(std::make_tuple(x...));
// Ring buffer: write to (queue_front_ + num_queued_) % queue_capacity
const size_t queue_capacity = static_cast<size_t>(this->max_runs_ - 1);
size_t write_pos = (this->queue_front_ + this->num_queued_) % queue_capacity;
// Use std::make_unique to replace the unique_ptr
this->var_queue_[write_pos] = std::make_unique<std::tuple<Ts...>>(x...);
this->num_queued_++;
return;
}
@@ -122,29 +141,46 @@ template<typename... Ts> class QueueingScript : public Script<Ts...>, public Com
}
void stop() override {
this->num_runs_ = 0;
// Clear all queued items to free memory immediately
// Resetting the array automatically destroys all unique_ptrs and their contents
this->var_queue_.reset();
this->num_queued_ = 0;
this->queue_front_ = 0;
Script<Ts...>::stop();
}
void loop() override {
if (this->num_runs_ != 0 && !this->is_action_running()) {
this->num_runs_--;
auto &vars = this->var_queue_.front();
this->var_queue_.pop();
this->trigger_tuple_(vars, typename gens<sizeof...(Ts)>::type());
if (this->num_queued_ != 0 && !this->is_action_running()) {
// Dequeue: decrement count, move tuple out (frees slot), advance read position
this->num_queued_--;
const size_t queue_capacity = static_cast<size_t>(this->max_runs_ - 1);
auto tuple_ptr = std::move(this->var_queue_[this->queue_front_]);
this->queue_front_ = (this->queue_front_ + 1) % queue_capacity;
this->trigger_tuple_(*tuple_ptr, typename gens<sizeof...(Ts)>::type());
}
}
void set_max_runs(int max_runs) { max_runs_ = max_runs; }
protected:
// Lazy init queue on first use - avoids setup() ordering issues and saves memory
// if script is never executed during this boot cycle
inline void lazy_init_queue_() {
if (!this->var_queue_) {
// Allocate array of max_runs_ - 1 slots for queued items (running item is separate)
// unique_ptr array is zero-initialized, so all slots start as nullptr
this->var_queue_ = std::make_unique<std::unique_ptr<std::tuple<Ts...>>[]>(this->max_runs_ - 1);
}
}
template<int... S> void trigger_tuple_(const std::tuple<Ts...> &tuple, seq<S...> /*unused*/) {
this->trigger(std::get<S>(tuple)...);
}
int num_runs_ = 0;
int max_runs_ = 0;
std::queue<std::tuple<Ts...>> var_queue_;
int num_queued_ = 0; // Number of queued instances (not including currently running)
int max_runs_ = 0; // Maximum total instances (running + queued)
size_t queue_front_ = 0; // Ring buffer read position (next item to execute)
std::unique_ptr<std::unique_ptr<std::tuple<Ts...>>[]> var_queue_; // Ring buffer of queued parameters
};
/** A script type that executes new instances in parallel.

View File

@@ -251,6 +251,9 @@ MaxFilter = sensor_ns.class_("MaxFilter", Filter)
SlidingWindowMovingAverageFilter = sensor_ns.class_(
"SlidingWindowMovingAverageFilter", Filter
)
StreamingMinFilter = sensor_ns.class_("StreamingMinFilter", Filter)
StreamingMaxFilter = sensor_ns.class_("StreamingMaxFilter", Filter)
StreamingMovingAverageFilter = sensor_ns.class_("StreamingMovingAverageFilter", Filter)
ExponentialMovingAverageFilter = sensor_ns.class_(
"ExponentialMovingAverageFilter", Filter
)
@@ -452,14 +455,21 @@ async def skip_initial_filter_to_code(config, filter_id):
return cg.new_Pvariable(filter_id, config)
@FILTER_REGISTRY.register("min", MinFilter, MIN_SCHEMA)
@FILTER_REGISTRY.register("min", Filter, MIN_SCHEMA)
async def min_filter_to_code(config, filter_id):
return cg.new_Pvariable(
filter_id,
config[CONF_WINDOW_SIZE],
config[CONF_SEND_EVERY],
config[CONF_SEND_FIRST_AT],
)
window_size: int = config[CONF_WINDOW_SIZE]
send_every: int = config[CONF_SEND_EVERY]
send_first_at: int = config[CONF_SEND_FIRST_AT]
# Optimization: Use streaming filter for batch windows (window_size == send_every)
# Saves 99.98% memory for large windows (e.g., 20KB → 4 bytes for window_size=5000)
if window_size == send_every:
# Use streaming filter - O(1) memory instead of O(n)
rhs = StreamingMinFilter.new(window_size, send_first_at)
return cg.Pvariable(filter_id, rhs, StreamingMinFilter)
# Use sliding window filter - maintains ring buffer
rhs = MinFilter.new(window_size, send_every, send_first_at)
return cg.Pvariable(filter_id, rhs, MinFilter)
MAX_SCHEMA = cv.All(
@@ -474,14 +484,18 @@ MAX_SCHEMA = cv.All(
)
@FILTER_REGISTRY.register("max", MaxFilter, MAX_SCHEMA)
@FILTER_REGISTRY.register("max", Filter, MAX_SCHEMA)
async def max_filter_to_code(config, filter_id):
return cg.new_Pvariable(
filter_id,
config[CONF_WINDOW_SIZE],
config[CONF_SEND_EVERY],
config[CONF_SEND_FIRST_AT],
)
window_size: int = config[CONF_WINDOW_SIZE]
send_every: int = config[CONF_SEND_EVERY]
send_first_at: int = config[CONF_SEND_FIRST_AT]
# Optimization: Use streaming filter for batch windows (window_size == send_every)
if window_size == send_every:
rhs = StreamingMaxFilter.new(window_size, send_first_at)
return cg.Pvariable(filter_id, rhs, StreamingMaxFilter)
rhs = MaxFilter.new(window_size, send_every, send_first_at)
return cg.Pvariable(filter_id, rhs, MaxFilter)
SLIDING_AVERAGE_SCHEMA = cv.All(
@@ -498,16 +512,20 @@ SLIDING_AVERAGE_SCHEMA = cv.All(
@FILTER_REGISTRY.register(
"sliding_window_moving_average",
SlidingWindowMovingAverageFilter,
Filter,
SLIDING_AVERAGE_SCHEMA,
)
async def sliding_window_moving_average_filter_to_code(config, filter_id):
return cg.new_Pvariable(
filter_id,
config[CONF_WINDOW_SIZE],
config[CONF_SEND_EVERY],
config[CONF_SEND_FIRST_AT],
)
window_size: int = config[CONF_WINDOW_SIZE]
send_every: int = config[CONF_SEND_EVERY]
send_first_at: int = config[CONF_SEND_FIRST_AT]
# Optimization: Use streaming filter for batch windows (window_size == send_every)
if window_size == send_every:
rhs = StreamingMovingAverageFilter.new(window_size, send_first_at)
return cg.Pvariable(filter_id, rhs, StreamingMovingAverageFilter)
rhs = SlidingWindowMovingAverageFilter.new(window_size, send_every, send_first_at)
return cg.Pvariable(filter_id, rhs, SlidingWindowMovingAverageFilter)
EXPONENTIAL_AVERAGE_SCHEMA = cv.All(

View File

@@ -32,50 +32,76 @@ void Filter::initialize(Sensor *parent, Filter *next) {
this->next_ = next;
}
// MedianFilter
MedianFilter::MedianFilter(size_t window_size, size_t send_every, size_t send_first_at)
: send_every_(send_every), send_at_(send_every - send_first_at), window_size_(window_size) {}
void MedianFilter::set_send_every(size_t send_every) { this->send_every_ = send_every; }
void MedianFilter::set_window_size(size_t window_size) { this->window_size_ = window_size; }
optional<float> MedianFilter::new_value(float value) {
while (this->queue_.size() >= this->window_size_) {
this->queue_.pop_front();
}
this->queue_.push_back(value);
ESP_LOGVV(TAG, "MedianFilter(%p)::new_value(%f)", this, value);
// SlidingWindowFilter
SlidingWindowFilter::SlidingWindowFilter(size_t window_size, size_t send_every, size_t send_first_at)
: window_size_(window_size), send_every_(send_every), send_at_(send_every - send_first_at) {
// Allocate ring buffer once at initialization
this->window_.init(window_size);
}
optional<float> SlidingWindowFilter::new_value(float value) {
// Add value to ring buffer
if (this->window_count_ < this->window_size_) {
// Buffer not yet full - just append
this->window_.push_back(value);
this->window_count_++;
} else {
// Buffer full - overwrite oldest value (ring buffer)
this->window_[this->window_head_] = value;
this->window_head_++;
if (this->window_head_ >= this->window_size_) {
this->window_head_ = 0;
}
}
// Check if we should send a result
if (++this->send_at_ >= this->send_every_) {
this->send_at_ = 0;
float median = NAN;
if (!this->queue_.empty()) {
// Copy queue without NaN values
std::vector<float> median_queue;
median_queue.reserve(this->queue_.size());
for (auto v : this->queue_) {
if (!std::isnan(v)) {
median_queue.push_back(v);
}
}
sort(median_queue.begin(), median_queue.end());
size_t queue_size = median_queue.size();
if (queue_size) {
if (queue_size % 2) {
median = median_queue[queue_size / 2];
} else {
median = (median_queue[queue_size / 2] + median_queue[(queue_size / 2) - 1]) / 2.0f;
}
}
}
ESP_LOGVV(TAG, "MedianFilter(%p)::new_value(%f) SENDING %f", this, value, median);
return median;
float result = this->compute_result();
ESP_LOGVV(TAG, "SlidingWindowFilter(%p)::new_value(%f) SENDING %f", this, value, result);
return result;
}
return {};
}
// SortedWindowFilter
FixedVector<float> SortedWindowFilter::get_window_values_() {
// Copy window without NaN values using FixedVector (no heap allocation)
// Returns unsorted values - caller will use std::nth_element for partial sorting as needed
FixedVector<float> values;
values.init(this->window_count_);
for (size_t i = 0; i < this->window_count_; i++) {
float v = this->window_[i];
if (!std::isnan(v)) {
values.push_back(v);
}
}
return values;
}
// MedianFilter
float MedianFilter::compute_result() {
FixedVector<float> values = this->get_window_values_();
if (values.empty())
return NAN;
size_t size = values.size();
size_t mid = size / 2;
if (size % 2) {
// Odd number of elements - use nth_element to find middle element
std::nth_element(values.begin(), values.begin() + mid, values.end());
return values[mid];
}
// Even number of elements - need both middle elements
// Use nth_element to find upper middle element
std::nth_element(values.begin(), values.begin() + mid, values.end());
float upper = values[mid];
// Find the maximum of the lower half (which is now everything before mid)
float lower = *std::max_element(values.begin(), values.begin() + mid);
return (lower + upper) / 2.0f;
}
// SkipInitialFilter
SkipInitialFilter::SkipInitialFilter(size_t num_to_ignore) : num_to_ignore_(num_to_ignore) {}
optional<float> SkipInitialFilter::new_value(float value) {
@@ -91,136 +117,39 @@ optional<float> SkipInitialFilter::new_value(float value) {
// QuantileFilter
QuantileFilter::QuantileFilter(size_t window_size, size_t send_every, size_t send_first_at, float quantile)
: send_every_(send_every), send_at_(send_every - send_first_at), window_size_(window_size), quantile_(quantile) {}
void QuantileFilter::set_send_every(size_t send_every) { this->send_every_ = send_every; }
void QuantileFilter::set_window_size(size_t window_size) { this->window_size_ = window_size; }
void QuantileFilter::set_quantile(float quantile) { this->quantile_ = quantile; }
optional<float> QuantileFilter::new_value(float value) {
while (this->queue_.size() >= this->window_size_) {
this->queue_.pop_front();
}
this->queue_.push_back(value);
ESP_LOGVV(TAG, "QuantileFilter(%p)::new_value(%f), quantile:%f", this, value, this->quantile_);
: SortedWindowFilter(window_size, send_every, send_first_at), quantile_(quantile) {}
if (++this->send_at_ >= this->send_every_) {
this->send_at_ = 0;
float QuantileFilter::compute_result() {
FixedVector<float> values = this->get_window_values_();
if (values.empty())
return NAN;
float result = NAN;
if (!this->queue_.empty()) {
// Copy queue without NaN values
std::vector<float> quantile_queue;
for (auto v : this->queue_) {
if (!std::isnan(v)) {
quantile_queue.push_back(v);
}
}
size_t position = ceilf(values.size() * this->quantile_) - 1;
ESP_LOGVV(TAG, "QuantileFilter(%p)::position: %zu/%zu", this, position + 1, values.size());
sort(quantile_queue.begin(), quantile_queue.end());
size_t queue_size = quantile_queue.size();
if (queue_size) {
size_t position = ceilf(queue_size * this->quantile_) - 1;
ESP_LOGVV(TAG, "QuantileFilter(%p)::position: %zu/%zu", this, position + 1, queue_size);
result = quantile_queue[position];
}
}
ESP_LOGVV(TAG, "QuantileFilter(%p)::new_value(%f) SENDING %f", this, value, result);
return result;
}
return {};
// Use nth_element to find the quantile element (O(n) instead of O(n log n))
std::nth_element(values.begin(), values.begin() + position, values.end());
return values[position];
}
// MinFilter
MinFilter::MinFilter(size_t window_size, size_t send_every, size_t send_first_at)
: send_every_(send_every), send_at_(send_every - send_first_at), window_size_(window_size) {}
void MinFilter::set_send_every(size_t send_every) { this->send_every_ = send_every; }
void MinFilter::set_window_size(size_t window_size) { this->window_size_ = window_size; }
optional<float> MinFilter::new_value(float value) {
while (this->queue_.size() >= this->window_size_) {
this->queue_.pop_front();
}
this->queue_.push_back(value);
ESP_LOGVV(TAG, "MinFilter(%p)::new_value(%f)", this, value);
if (++this->send_at_ >= this->send_every_) {
this->send_at_ = 0;
float min = NAN;
for (auto v : this->queue_) {
if (!std::isnan(v)) {
min = std::isnan(min) ? v : std::min(min, v);
}
}
ESP_LOGVV(TAG, "MinFilter(%p)::new_value(%f) SENDING %f", this, value, min);
return min;
}
return {};
}
float MinFilter::compute_result() { return this->find_extremum_<std::less<float>>(); }
// MaxFilter
MaxFilter::MaxFilter(size_t window_size, size_t send_every, size_t send_first_at)
: send_every_(send_every), send_at_(send_every - send_first_at), window_size_(window_size) {}
void MaxFilter::set_send_every(size_t send_every) { this->send_every_ = send_every; }
void MaxFilter::set_window_size(size_t window_size) { this->window_size_ = window_size; }
optional<float> MaxFilter::new_value(float value) {
while (this->queue_.size() >= this->window_size_) {
this->queue_.pop_front();
}
this->queue_.push_back(value);
ESP_LOGVV(TAG, "MaxFilter(%p)::new_value(%f)", this, value);
if (++this->send_at_ >= this->send_every_) {
this->send_at_ = 0;
float max = NAN;
for (auto v : this->queue_) {
if (!std::isnan(v)) {
max = std::isnan(max) ? v : std::max(max, v);
}
}
ESP_LOGVV(TAG, "MaxFilter(%p)::new_value(%f) SENDING %f", this, value, max);
return max;
}
return {};
}
float MaxFilter::compute_result() { return this->find_extremum_<std::greater<float>>(); }
// SlidingWindowMovingAverageFilter
SlidingWindowMovingAverageFilter::SlidingWindowMovingAverageFilter(size_t window_size, size_t send_every,
size_t send_first_at)
: send_every_(send_every), send_at_(send_every - send_first_at), window_size_(window_size) {}
void SlidingWindowMovingAverageFilter::set_send_every(size_t send_every) { this->send_every_ = send_every; }
void SlidingWindowMovingAverageFilter::set_window_size(size_t window_size) { this->window_size_ = window_size; }
optional<float> SlidingWindowMovingAverageFilter::new_value(float value) {
while (this->queue_.size() >= this->window_size_) {
this->queue_.pop_front();
}
this->queue_.push_back(value);
ESP_LOGVV(TAG, "SlidingWindowMovingAverageFilter(%p)::new_value(%f)", this, value);
if (++this->send_at_ >= this->send_every_) {
this->send_at_ = 0;
float sum = 0;
size_t valid_count = 0;
for (auto v : this->queue_) {
if (!std::isnan(v)) {
sum += v;
valid_count++;
}
float SlidingWindowMovingAverageFilter::compute_result() {
float sum = 0;
size_t valid_count = 0;
for (size_t i = 0; i < this->window_count_; i++) {
float v = this->window_[i];
if (!std::isnan(v)) {
sum += v;
valid_count++;
}
float average = NAN;
if (valid_count) {
average = sum / valid_count;
}
ESP_LOGVV(TAG, "SlidingWindowMovingAverageFilter(%p)::new_value(%f) SENDING %f", this, value, average);
return average;
}
return {};
return valid_count ? sum / valid_count : NAN;
}
// ExponentialMovingAverageFilter
@@ -543,5 +472,78 @@ optional<float> ToNTCTemperatureFilter::new_value(float value) {
return temp;
}
// StreamingFilter (base class)
StreamingFilter::StreamingFilter(size_t window_size, size_t send_first_at)
: window_size_(window_size), send_first_at_(send_first_at) {}
optional<float> StreamingFilter::new_value(float value) {
// Process the value (child class tracks min/max/sum/etc)
this->process_value(value);
this->count_++;
// Check if we should send (handle send_first_at for first value)
bool should_send = false;
if (this->first_send_ && this->count_ >= this->send_first_at_) {
should_send = true;
this->first_send_ = false;
} else if (!this->first_send_ && this->count_ >= this->window_size_) {
should_send = true;
}
if (should_send) {
float result = this->compute_batch_result();
// Reset for next batch
this->count_ = 0;
this->reset_batch();
ESP_LOGVV(TAG, "StreamingFilter(%p)::new_value(%f) SENDING %f", this, value, result);
return result;
}
return {};
}
// StreamingMinFilter
void StreamingMinFilter::process_value(float value) {
// Update running minimum (ignore NaN values)
if (!std::isnan(value)) {
this->current_min_ = std::isnan(this->current_min_) ? value : std::min(this->current_min_, value);
}
}
float StreamingMinFilter::compute_batch_result() { return this->current_min_; }
void StreamingMinFilter::reset_batch() { this->current_min_ = NAN; }
// StreamingMaxFilter
void StreamingMaxFilter::process_value(float value) {
// Update running maximum (ignore NaN values)
if (!std::isnan(value)) {
this->current_max_ = std::isnan(this->current_max_) ? value : std::max(this->current_max_, value);
}
}
float StreamingMaxFilter::compute_batch_result() { return this->current_max_; }
void StreamingMaxFilter::reset_batch() { this->current_max_ = NAN; }
// StreamingMovingAverageFilter
void StreamingMovingAverageFilter::process_value(float value) {
// Accumulate sum (ignore NaN values)
if (!std::isnan(value)) {
this->sum_ += value;
this->valid_count_++;
}
}
float StreamingMovingAverageFilter::compute_batch_result() {
return this->valid_count_ > 0 ? this->sum_ / this->valid_count_ : NAN;
}
void StreamingMovingAverageFilter::reset_batch() {
this->sum_ = 0.0f;
this->valid_count_ = 0;
}
} // namespace sensor
} // namespace esphome

View File

@@ -44,11 +44,75 @@ class Filter {
Sensor *parent_{nullptr};
};
/** Base class for filters that use a sliding window of values.
*
* Uses a ring buffer to efficiently maintain a fixed-size sliding window without
* reallocations or pop_front() overhead. Eliminates deque fragmentation issues.
*/
class SlidingWindowFilter : public Filter {
public:
SlidingWindowFilter(size_t window_size, size_t send_every, size_t send_first_at);
optional<float> new_value(float value) final;
protected:
/// Called by new_value() to compute the filtered result from the current window
virtual float compute_result() = 0;
/// Access the sliding window values (ring buffer implementation)
/// Use: for (size_t i = 0; i < window_count_; i++) { float val = window_[i]; }
FixedVector<float> window_;
size_t window_head_{0}; ///< Index where next value will be written
size_t window_count_{0}; ///< Number of valid values in window (0 to window_size_)
size_t window_size_; ///< Maximum window size
size_t send_every_; ///< Send result every N values
size_t send_at_; ///< Counter for send_every
};
/** Base class for Min/Max filters.
*
* Provides a templated helper to find extremum values efficiently.
*/
class MinMaxFilter : public SlidingWindowFilter {
public:
using SlidingWindowFilter::SlidingWindowFilter;
protected:
/// Helper to find min or max value in window, skipping NaN values
/// Usage: find_extremum_<std::less<float>>() for min, find_extremum_<std::greater<float>>() for max
template<typename Compare> float find_extremum_() {
float result = NAN;
Compare comp;
for (size_t i = 0; i < this->window_count_; i++) {
float v = this->window_[i];
if (!std::isnan(v)) {
result = std::isnan(result) ? v : (comp(v, result) ? v : result);
}
}
return result;
}
};
/** Base class for filters that need a sorted window (Median, Quantile).
*
* Extends SlidingWindowFilter to provide a helper that filters out NaN values.
* Derived classes use std::nth_element for efficient partial sorting.
*/
class SortedWindowFilter : public SlidingWindowFilter {
public:
using SlidingWindowFilter::SlidingWindowFilter;
protected:
/// Helper to get non-NaN values from the window (not sorted - caller will use nth_element)
/// Returns empty FixedVector if all values are NaN
FixedVector<float> get_window_values_();
};
/** Simple quantile filter.
*
* Takes the quantile of the last <send_every> values and pushes it out every <send_every>.
* Takes the quantile of the last <window_size> values and pushes it out every <send_every>.
*/
class QuantileFilter : public Filter {
class QuantileFilter : public SortedWindowFilter {
public:
/** Construct a QuantileFilter.
*
@@ -61,25 +125,18 @@ class QuantileFilter : public Filter {
*/
explicit QuantileFilter(size_t window_size, size_t send_every, size_t send_first_at, float quantile);
optional<float> new_value(float value) override;
void set_send_every(size_t send_every);
void set_window_size(size_t window_size);
void set_quantile(float quantile);
void set_quantile(float quantile) { this->quantile_ = quantile; }
protected:
std::deque<float> queue_;
size_t send_every_;
size_t send_at_;
size_t window_size_;
float compute_result() override;
float quantile_;
};
/** Simple median filter.
*
* Takes the median of the last <send_every> values and pushes it out every <send_every>.
* Takes the median of the last <window_size> values and pushes it out every <send_every>.
*/
class MedianFilter : public Filter {
class MedianFilter : public SortedWindowFilter {
public:
/** Construct a MedianFilter.
*
@@ -89,18 +146,10 @@ class MedianFilter : public Filter {
* on startup being published on the first *raw* value, so with no filter applied. Must be less than or equal to
* send_every.
*/
explicit MedianFilter(size_t window_size, size_t send_every, size_t send_first_at);
optional<float> new_value(float value) override;
void set_send_every(size_t send_every);
void set_window_size(size_t window_size);
using SortedWindowFilter::SortedWindowFilter;
protected:
std::deque<float> queue_;
size_t send_every_;
size_t send_at_;
size_t window_size_;
float compute_result() override;
};
/** Simple skip filter.
@@ -123,9 +172,9 @@ class SkipInitialFilter : public Filter {
/** Simple min filter.
*
* Takes the min of the last <send_every> values and pushes it out every <send_every>.
* Takes the min of the last <window_size> values and pushes it out every <send_every>.
*/
class MinFilter : public Filter {
class MinFilter : public MinMaxFilter {
public:
/** Construct a MinFilter.
*
@@ -135,25 +184,17 @@ class MinFilter : public Filter {
* on startup being published on the first *raw* value, so with no filter applied. Must be less than or equal to
* send_every.
*/
explicit MinFilter(size_t window_size, size_t send_every, size_t send_first_at);
optional<float> new_value(float value) override;
void set_send_every(size_t send_every);
void set_window_size(size_t window_size);
using MinMaxFilter::MinMaxFilter;
protected:
std::deque<float> queue_;
size_t send_every_;
size_t send_at_;
size_t window_size_;
float compute_result() override;
};
/** Simple max filter.
*
* Takes the max of the last <send_every> values and pushes it out every <send_every>.
* Takes the max of the last <window_size> values and pushes it out every <send_every>.
*/
class MaxFilter : public Filter {
class MaxFilter : public MinMaxFilter {
public:
/** Construct a MaxFilter.
*
@@ -163,18 +204,10 @@ class MaxFilter : public Filter {
* on startup being published on the first *raw* value, so with no filter applied. Must be less than or equal to
* send_every.
*/
explicit MaxFilter(size_t window_size, size_t send_every, size_t send_first_at);
optional<float> new_value(float value) override;
void set_send_every(size_t send_every);
void set_window_size(size_t window_size);
using MinMaxFilter::MinMaxFilter;
protected:
std::deque<float> queue_;
size_t send_every_;
size_t send_at_;
size_t window_size_;
float compute_result() override;
};
/** Simple sliding window moving average filter.
@@ -182,7 +215,7 @@ class MaxFilter : public Filter {
* Essentially just takes takes the average of the last window_size values and pushes them out
* every send_every.
*/
class SlidingWindowMovingAverageFilter : public Filter {
class SlidingWindowMovingAverageFilter : public SlidingWindowFilter {
public:
/** Construct a SlidingWindowMovingAverageFilter.
*
@@ -192,18 +225,10 @@ class SlidingWindowMovingAverageFilter : public Filter {
* on startup being published on the first *raw* value, so with no filter applied. Must be less than or equal to
* send_every.
*/
explicit SlidingWindowMovingAverageFilter(size_t window_size, size_t send_every, size_t send_first_at);
optional<float> new_value(float value) override;
void set_send_every(size_t send_every);
void set_window_size(size_t window_size);
using SlidingWindowFilter::SlidingWindowFilter;
protected:
std::deque<float> queue_;
size_t send_every_;
size_t send_at_;
size_t window_size_;
float compute_result() override;
};
/** Simple exponential moving average filter.
@@ -476,5 +501,81 @@ class ToNTCTemperatureFilter : public Filter {
double c_;
};
/** Base class for streaming filters (batch windows where window_size == send_every).
*
* When window_size equals send_every, we don't need a sliding window.
* This base class handles the common batching logic.
*/
class StreamingFilter : public Filter {
public:
StreamingFilter(size_t window_size, size_t send_first_at);
optional<float> new_value(float value) final;
protected:
/// Called by new_value() to process each value in the batch
virtual void process_value(float value) = 0;
/// Called by new_value() to compute the result after collecting window_size values
virtual float compute_batch_result() = 0;
/// Called by new_value() to reset internal state after sending a result
virtual void reset_batch() = 0;
size_t window_size_;
size_t count_{0};
size_t send_first_at_;
bool first_send_{true};
};
/** Streaming min filter for batch windows (window_size == send_every).
*
* Uses O(1) memory instead of O(n) by tracking only the minimum value.
*/
class StreamingMinFilter : public StreamingFilter {
public:
using StreamingFilter::StreamingFilter;
protected:
void process_value(float value) override;
float compute_batch_result() override;
void reset_batch() override;
float current_min_{NAN};
};
/** Streaming max filter for batch windows (window_size == send_every).
*
* Uses O(1) memory instead of O(n) by tracking only the maximum value.
*/
class StreamingMaxFilter : public StreamingFilter {
public:
using StreamingFilter::StreamingFilter;
protected:
void process_value(float value) override;
float compute_batch_result() override;
void reset_batch() override;
float current_max_{NAN};
};
/** Streaming moving average filter for batch windows (window_size == send_every).
*
* Uses O(1) memory instead of O(n) by tracking only sum and count.
*/
class StreamingMovingAverageFilter : public StreamingFilter {
public:
using StreamingFilter::StreamingFilter;
protected:
void process_value(float value) override;
float compute_batch_result() override;
void reset_batch() override;
float sum_{0.0f};
size_t valid_count_{0};
};
} // namespace sensor
} // namespace esphome

View File

@@ -28,21 +28,6 @@
namespace esphome {
namespace statsd {
using sensor_type_t = enum { TYPE_SENSOR, TYPE_BINARY_SENSOR };
using sensors_t = struct {
const char *name;
sensor_type_t type;
union {
#ifdef USE_SENSOR
esphome::sensor::Sensor *sensor;
#endif
#ifdef USE_BINARY_SENSOR
esphome::binary_sensor::BinarySensor *binary_sensor;
#endif
};
};
class StatsdComponent : public PollingComponent {
public:
~StatsdComponent();
@@ -71,6 +56,20 @@ class StatsdComponent : public PollingComponent {
const char *prefix_;
uint16_t port_;
using sensor_type_t = enum { TYPE_SENSOR, TYPE_BINARY_SENSOR };
using sensors_t = struct {
const char *name;
sensor_type_t type;
union {
#ifdef USE_SENSOR
esphome::sensor::Sensor *sensor;
#endif
#ifdef USE_BINARY_SENSOR
esphome::binary_sensor::BinarySensor *binary_sensor;
#endif
};
};
std::vector<sensors_t> sensors_;
#ifdef USE_ESP8266

View File

@@ -27,6 +27,14 @@ class RealTimeClock : public PollingComponent {
this->apply_timezone_();
}
/// Set the time zone from raw buffer, only if it differs from the current one.
void set_timezone(const char *tz, size_t len) {
if (this->timezone_.length() != len || memcmp(this->timezone_.c_str(), tz, len) != 0) {
this->timezone_.assign(tz, len);
this->apply_timezone_();
}
}
/// Get the time zone currently in use.
std::string get_timezone() { return this->timezone_; }
#endif

View File

@@ -1325,7 +1325,7 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf
root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy);
root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy);
root["step"] = traits.get_visual_target_temperature_step();
if (traits.get_supports_action()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action));
root["state"] = root["action"];
has_state = true;
@@ -1345,14 +1345,15 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf
if (traits.get_supports_swing_modes()) {
root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode));
}
if (traits.get_supports_current_temperature()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
if (!std::isnan(obj->current_temperature)) {
root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy);
} else {
root["current_temperature"] = "NA";
}
}
if (traits.get_supports_two_point_target_temperature()) {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy);
root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy);
if (!has_state) {

View File

@@ -407,7 +407,8 @@ async def to_code(config):
cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT]))
cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE]))
cg.add(var.set_fast_connect(config[CONF_FAST_CONNECT]))
if config[CONF_FAST_CONNECT]:
cg.add_define("USE_WIFI_FAST_CONNECT")
cg.add(var.set_passive_scan(config[CONF_PASSIVE_SCAN]))
if CONF_OUTPUT_POWER in config:
cg.add(var.set_output_power(config[CONF_OUTPUT_POWER]))

View File

@@ -84,9 +84,9 @@ void WiFiComponent::start() {
uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time()) : 88491487UL;
this->pref_ = global_preferences->make_preference<wifi::SavedWifiSettings>(hash, true);
if (this->fast_connect_) {
this->fast_connect_pref_ = global_preferences->make_preference<wifi::SavedWifiFastConnectSettings>(hash + 1, false);
}
#ifdef USE_WIFI_FAST_CONNECT
this->fast_connect_pref_ = global_preferences->make_preference<wifi::SavedWifiFastConnectSettings>(hash + 1, false);
#endif
SavedWifiSettings save{};
if (this->pref_.load(&save)) {
@@ -108,16 +108,16 @@ void WiFiComponent::start() {
ESP_LOGV(TAG, "Setting Power Save Option failed");
}
if (this->fast_connect_) {
this->trying_loaded_ap_ = this->load_fast_connect_settings_();
if (!this->trying_loaded_ap_) {
this->ap_index_ = 0;
this->selected_ap_ = this->sta_[this->ap_index_];
}
this->start_connecting(this->selected_ap_, false);
} else {
this->start_scanning();
#ifdef USE_WIFI_FAST_CONNECT
this->trying_loaded_ap_ = this->load_fast_connect_settings_();
if (!this->trying_loaded_ap_) {
this->ap_index_ = 0;
this->selected_ap_ = this->sta_[this->ap_index_];
}
this->start_connecting(this->selected_ap_, false);
#else
this->start_scanning();
#endif
#ifdef USE_WIFI_AP
} else if (this->has_ap()) {
this->setup_ap_config_();
@@ -168,13 +168,20 @@ void WiFiComponent::loop() {
case WIFI_COMPONENT_STATE_COOLDOWN: {
this->status_set_warning(LOG_STR("waiting to reconnect"));
if (millis() - this->action_started_ > 5000) {
if (this->fast_connect_ || this->retry_hidden_) {
#ifdef USE_WIFI_FAST_CONNECT
// NOTE: This check may not make sense here as it could interfere with AP cycling
if (!this->selected_ap_.get_bssid().has_value())
this->selected_ap_ = this->sta_[0];
this->start_connecting(this->selected_ap_, false);
#else
if (this->retry_hidden_) {
if (!this->selected_ap_.get_bssid().has_value())
this->selected_ap_ = this->sta_[0];
this->start_connecting(this->selected_ap_, false);
} else {
this->start_scanning();
}
#endif
}
break;
}
@@ -244,7 +251,6 @@ WiFiComponent::WiFiComponent() { global_wifi_component = this; }
bool WiFiComponent::has_ap() const { return this->has_ap_; }
bool WiFiComponent::has_sta() const { return !this->sta_.empty(); }
void WiFiComponent::set_fast_connect(bool fast_connect) { this->fast_connect_ = fast_connect; }
#ifdef USE_WIFI_11KV_SUPPORT
void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; }
void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; }
@@ -607,10 +613,12 @@ void WiFiComponent::check_scanning_finished() {
for (auto &ap : this->sta_) {
if (res.matches(ap)) {
res.set_matches(true);
if (!this->has_sta_priority(res.get_bssid())) {
this->set_sta_priority(res.get_bssid(), ap.get_priority());
// Cache priority lookup - do single search instead of 2 separate searches
const bssid_t &bssid = res.get_bssid();
if (!this->has_sta_priority(bssid)) {
this->set_sta_priority(bssid, ap.get_priority());
}
res.set_priority(this->get_sta_priority(res.get_bssid()));
res.set_priority(this->get_sta_priority(bssid));
break;
}
}
@@ -629,8 +637,9 @@ void WiFiComponent::check_scanning_finished() {
return;
}
WiFiAP connect_params;
WiFiScanResult scan_res = this->scan_result_[0];
// Build connection params directly into selected_ap_ to avoid extra copy
const WiFiScanResult &scan_res = this->scan_result_[0];
WiFiAP &selected = this->selected_ap_;
for (auto &config : this->sta_) {
// search for matching STA config, at least one will match (from checks before)
if (!scan_res.matches(config)) {
@@ -639,37 +648,38 @@ void WiFiComponent::check_scanning_finished() {
if (config.get_hidden()) {
// selected network is hidden, we use the data from the config
connect_params.set_hidden(true);
connect_params.set_ssid(config.get_ssid());
// don't set BSSID and channel, there might be multiple hidden networks
selected.set_hidden(true);
selected.set_ssid(config.get_ssid());
// Clear channel and BSSID for hidden networks - there might be multiple hidden networks
// but we can't know which one is the correct one. Rely on probe-req with just SSID.
selected.set_channel(0);
selected.set_bssid(optional<bssid_t>{});
} else {
// selected network is visible, we use the data from the scan
// limit the connect params to only connect to exactly this network
// (network selection is done during scan phase).
connect_params.set_hidden(false);
connect_params.set_ssid(scan_res.get_ssid());
connect_params.set_channel(scan_res.get_channel());
connect_params.set_bssid(scan_res.get_bssid());
selected.set_hidden(false);
selected.set_ssid(scan_res.get_ssid());
selected.set_channel(scan_res.get_channel());
selected.set_bssid(scan_res.get_bssid());
}
// copy manual IP (if set)
connect_params.set_manual_ip(config.get_manual_ip());
selected.set_manual_ip(config.get_manual_ip());
#ifdef USE_WIFI_WPA2_EAP
// copy EAP parameters (if set)
connect_params.set_eap(config.get_eap());
selected.set_eap(config.get_eap());
#endif
// copy password (if set)
connect_params.set_password(config.get_password());
selected.set_password(config.get_password());
break;
}
yield();
this->selected_ap_ = connect_params;
this->start_connecting(connect_params, false);
this->start_connecting(this->selected_ap_, false);
}
void WiFiComponent::dump_config() {
@@ -719,9 +729,9 @@ void WiFiComponent::check_connecting_finished() {
this->scan_result_.shrink_to_fit();
}
if (this->fast_connect_) {
this->save_fast_connect_settings_();
}
#ifdef USE_WIFI_FAST_CONNECT
this->save_fast_connect_settings_();
#endif
return;
}
@@ -769,31 +779,31 @@ void WiFiComponent::retry_connect() {
delay(10);
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_() &&
(this->num_retried_ > 3 || this->error_from_callback_)) {
if (this->fast_connect_) {
if (this->trying_loaded_ap_) {
this->trying_loaded_ap_ = false;
this->ap_index_ = 0; // Retry from the first configured AP
} else if (this->ap_index_ >= this->sta_.size() - 1) {
ESP_LOGW(TAG, "No more APs to try");
this->ap_index_ = 0;
this->restart_adapter();
} else {
// Try next AP
this->ap_index_++;
}
this->num_retried_ = 0;
this->selected_ap_ = this->sta_[this->ap_index_];
#ifdef USE_WIFI_FAST_CONNECT
if (this->trying_loaded_ap_) {
this->trying_loaded_ap_ = false;
this->ap_index_ = 0; // Retry from the first configured AP
} else if (this->ap_index_ >= this->sta_.size() - 1) {
ESP_LOGW(TAG, "No more APs to try");
this->ap_index_ = 0;
this->restart_adapter();
} else {
if (this->num_retried_ > 5) {
// If retry failed for more than 5 times, let's restart STA
this->restart_adapter();
} else {
// Try hidden networks after 3 failed retries
ESP_LOGD(TAG, "Retrying with hidden networks");
this->retry_hidden_ = true;
this->num_retried_++;
}
// Try next AP
this->ap_index_++;
}
this->num_retried_ = 0;
this->selected_ap_ = this->sta_[this->ap_index_];
#else
if (this->num_retried_ > 5) {
// If retry failed for more than 5 times, let's restart STA
this->restart_adapter();
} else {
// Try hidden networks after 3 failed retries
ESP_LOGD(TAG, "Retrying with hidden networks");
this->retry_hidden_ = true;
this->num_retried_++;
}
#endif
} else {
this->num_retried_++;
}
@@ -839,6 +849,7 @@ bool WiFiComponent::is_esp32_improv_active_() {
#endif
}
#ifdef USE_WIFI_FAST_CONNECT
bool WiFiComponent::load_fast_connect_settings_() {
SavedWifiFastConnectSettings fast_connect_save{};
@@ -873,6 +884,7 @@ void WiFiComponent::save_fast_connect_settings_() {
ESP_LOGD(TAG, "Saved fast_connect settings");
}
}
#endif
void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = ssid; }
void WiFiAP::set_bssid(bssid_t bssid) { this->bssid_ = bssid; }
@@ -902,7 +914,7 @@ WiFiScanResult::WiFiScanResult(const bssid_t &bssid, std::string ssid, uint8_t c
rssi_(rssi),
with_auth_(with_auth),
is_hidden_(is_hidden) {}
bool WiFiScanResult::matches(const WiFiAP &config) {
bool WiFiScanResult::matches(const WiFiAP &config) const {
if (config.get_hidden()) {
// User configured a hidden network, only match actually hidden networks
// don't match SSID

View File

@@ -170,7 +170,7 @@ class WiFiScanResult {
public:
WiFiScanResult(const bssid_t &bssid, std::string ssid, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden);
bool matches(const WiFiAP &config);
bool matches(const WiFiAP &config) const;
bool get_matches() const;
void set_matches(bool matches);
@@ -240,7 +240,6 @@ class WiFiComponent : public Component {
void start_scanning();
void check_scanning_finished();
void start_connecting(const WiFiAP &ap, bool two);
void set_fast_connect(bool fast_connect);
void set_ap_timeout(uint32_t ap_timeout) { ap_timeout_ = ap_timeout; }
void check_connecting_finished();
@@ -364,8 +363,10 @@ class WiFiComponent : public Component {
bool is_captive_portal_active_();
bool is_esp32_improv_active_();
#ifdef USE_WIFI_FAST_CONNECT
bool load_fast_connect_settings_();
void save_fast_connect_settings_();
#endif
#ifdef USE_ESP8266
static void wifi_event_callback(System_Event_t *event);
@@ -399,7 +400,9 @@ class WiFiComponent : public Component {
WiFiAP ap_;
optional<float> output_power_;
ESPPreferenceObject pref_;
#ifdef USE_WIFI_FAST_CONNECT
ESPPreferenceObject fast_connect_pref_;
#endif
// Group all 32-bit integers together
uint32_t action_started_;
@@ -411,14 +414,17 @@ class WiFiComponent : public Component {
WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF};
WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE};
uint8_t num_retried_{0};
#ifdef USE_WIFI_FAST_CONNECT
uint8_t ap_index_{0};
#endif
#if USE_NETWORK_IPV6
uint8_t num_ipv6_addresses_{0};
#endif /* USE_NETWORK_IPV6 */
// Group all boolean values together
bool fast_connect_{false};
#ifdef USE_WIFI_FAST_CONNECT
bool trying_loaded_ap_{false};
#endif
bool retry_hidden_{false};
bool has_ap_{false};
bool handled_connected_state_{false};

View File

@@ -706,10 +706,10 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
this->scan_result_.init(count);
for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) {
WiFiScanResult res({it->bssid[0], it->bssid[1], it->bssid[2], it->bssid[3], it->bssid[4], it->bssid[5]},
std::string(reinterpret_cast<char *>(it->ssid), it->ssid_len), it->channel, it->rssi,
it->authmode != AUTH_OPEN, it->is_hidden != 0);
this->scan_result_.push_back(res);
this->scan_result_.emplace_back(
bssid_t{it->bssid[0], it->bssid[1], it->bssid[2], it->bssid[3], it->bssid[4], it->bssid[5]},
std::string(reinterpret_cast<char *>(it->ssid), it->ssid_len), it->channel, it->rssi, it->authmode != AUTH_OPEN,
it->is_hidden != 0);
}
this->scan_done_ = true;
}

View File

@@ -776,13 +776,12 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
}
uint16_t number = it.number;
std::vector<wifi_ap_record_t> records(number);
err = esp_wifi_scan_get_ap_records(&number, records.data());
auto records = std::make_unique<wifi_ap_record_t[]>(number);
err = esp_wifi_scan_get_ap_records(&number, records.get());
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err));
return;
}
records.resize(number);
scan_result_.init(number);
for (int i = 0; i < number; i++) {
@@ -790,8 +789,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
bssid_t bssid;
std::copy(record.bssid, record.bssid + 6, bssid.begin());
std::string ssid(reinterpret_cast<const char *>(record.ssid));
WiFiScanResult result(bssid, ssid, record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid.empty());
scan_result_.push_back(result);
scan_result_.emplace_back(bssid, ssid, record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN,
ssid.empty());
}
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) {

View File

@@ -419,9 +419,9 @@ void WiFiComponent::wifi_scan_done_callback_() {
uint8_t *bssid = WiFi.BSSID(i);
int32_t channel = WiFi.channel(i);
WiFiScanResult scan({bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]}, std::string(ssid.c_str()),
channel, rssi, authmode != WIFI_AUTH_OPEN, ssid.length() == 0);
this->scan_result_.push_back(scan);
this->scan_result_.emplace_back(bssid_t{bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]},
std::string(ssid.c_str()), channel, rssi, authmode != WIFI_AUTH_OPEN,
ssid.length() == 0);
}
WiFi.scanDelete();
this->scan_done_ = true;

View File

@@ -0,0 +1,34 @@
import esphome.codegen as cg
from esphome.components.zephyr import zephyr_add_prj_conf
import esphome.config_validation as cv
from esphome.const import CONF_ESPHOME, CONF_ID, CONF_NAME, Framework
import esphome.final_validate as fv
zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server")
BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLEServer),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_with_framework(Framework.ZEPHYR),
)
def _final_validate(_):
full_config = fv.full_config.get()
zephyr_add_prj_conf("BT_DEVICE_NAME", full_config[CONF_ESPHOME][CONF_NAME])
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
zephyr_add_prj_conf("BT", True)
zephyr_add_prj_conf("BT_PERIPHERAL", True)
zephyr_add_prj_conf("BT_RX_STACK_SIZE", 1536)
# zephyr_add_prj_conf("BT_LL_SW_SPLIT", True)
await cg.register_component(var, config)

View File

@@ -0,0 +1,100 @@
#ifdef USE_ZEPHYR
#include "ble_server.h"
#include "esphome/core/defines.h"
#include "esphome/core/log.h"
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/conn.h>
namespace esphome::zephyr_ble_server {
static const char *const TAG = "zephyr_ble_server";
static struct k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#define DEVICE_NAME CONFIG_BT_DEVICE_NAME
#define DEVICE_NAME_LEN (sizeof(DEVICE_NAME) - 1)
static const struct bt_data AD[] = {
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, DEVICE_NAME_LEN),
};
static const struct bt_data SD[] = {
#ifdef USE_OTA
BT_DATA_BYTES(BT_DATA_UUID128_ALL, 0x84, 0xaa, 0x60, 0x74, 0x52, 0x8a, 0x8b, 0x86, 0xd3, 0x4c, 0xb7, 0x1d, 0x1d,
0xdc, 0x53, 0x8d),
#endif
};
const struct bt_le_adv_param *const ADV_PARAM = BT_LE_ADV_CONN;
static void advertise(struct k_work *work) {
int rc = bt_le_adv_stop();
if (rc) {
ESP_LOGE(TAG, "Advertising failed to stop (rc %d)", rc);
}
rc = bt_le_adv_start(ADV_PARAM, AD, ARRAY_SIZE(AD), SD, ARRAY_SIZE(SD));
if (rc) {
ESP_LOGE(TAG, "Advertising failed to start (rc %d)", rc);
return;
}
ESP_LOGI(TAG, "Advertising successfully started");
}
static void connected(struct bt_conn *conn, uint8_t err) {
if (err) {
ESP_LOGE(TAG, "Connection failed (err 0x%02x)", err);
} else {
ESP_LOGI(TAG, "Connected");
}
}
static void disconnected(struct bt_conn *conn, uint8_t reason) {
ESP_LOGI(TAG, "Disconnected (reason 0x%02x)", reason);
k_work_submit(&advertise_work);
}
static void bt_ready(int err) {
if (err != 0) {
ESP_LOGE(TAG, "Bluetooth failed to initialise: %d", err);
} else {
k_work_submit(&advertise_work);
}
}
BT_CONN_CB_DEFINE(conn_callbacks) = {
.connected = connected,
.disconnected = disconnected,
};
void BLEServer::setup() {
k_work_init(&advertise_work, advertise);
resume_();
}
void BLEServer::loop() {
if (this->suspended_) {
resume_();
this->suspended_ = false;
}
}
void BLEServer::resume_() {
int rc = bt_enable(bt_ready);
if (rc != 0) {
ESP_LOGE(TAG, "Bluetooth enable failed: %d", rc);
return;
}
}
void BLEServer::on_shutdown() {
struct k_work_sync sync;
k_work_cancel_sync(&advertise_work, &sync);
bt_disable();
this->suspended_ = true;
}
} // namespace esphome::zephyr_ble_server
#endif

View File

@@ -0,0 +1,19 @@
#pragma once
#ifdef USE_ZEPHYR
#include "esphome/core/component.h"
namespace esphome::zephyr_ble_server {
class BLEServer : public Component {
public:
void setup() override;
void loop() override;
void on_shutdown() override;
protected:
void resume_();
bool suspended_ = false;
};
} // namespace esphome::zephyr_ble_server
#endif

View File

@@ -12,7 +12,7 @@ from typing import Any
import voluptuous as vol
from esphome import core, loader, pins, yaml_util
from esphome.config_helpers import Extend, Remove, merge_dicts_ordered
from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered
import esphome.config_validation as cv
from esphome.const import (
CONF_ESPHOME,
@@ -324,13 +324,7 @@ def iter_ids(config, path=None):
yield from iter_ids(value, path + [key])
def recursive_check_replaceme(value):
if isinstance(value, list):
return cv.Schema([recursive_check_replaceme])(value)
if isinstance(value, dict):
return cv.Schema({cv.valid: recursive_check_replaceme})(value)
if isinstance(value, ESPLiteralValue):
pass
def check_replaceme(value):
if isinstance(value, str) and value == "REPLACEME":
raise cv.Invalid(
"Found 'REPLACEME' in configuration, this is most likely an error. "
@@ -339,7 +333,86 @@ def recursive_check_replaceme(value):
"If you want to use the literal REPLACEME string, "
'please use "!literal REPLACEME"'
)
return value
def _build_list_index(lst):
index = OrderedDict()
extensions, removals = [], set()
for item in lst:
if item is None:
removals.add(None)
continue
item_id = None
if isinstance(item, dict) and (item_id := item.get(CONF_ID)):
if isinstance(item_id, Extend):
extensions.append(item)
continue
if isinstance(item_id, Remove):
removals.add(item_id.value)
continue
if not item_id or item_id in index:
# no id or duplicate -> pass through with identity-based key
item_id = id(item)
index[item_id] = item
return index, extensions, removals
def resolve_extend_remove(value, is_key=None):
if isinstance(value, ESPLiteralValue):
return # do not check inside literal blocks
if isinstance(value, list):
index, extensions, removals = _build_list_index(value)
if extensions or removals:
# Rebuild the original list after
# processing all extensions and removals
for item in extensions:
item_id = item[CONF_ID].value
if item_id in removals:
continue
old = index.get(item_id)
if old is None:
# Failed to find source for extension
# Find index of item to show error at correct position
i = next(
(
i
for i, d in enumerate(value)
if d.get(CONF_ID) == item[CONF_ID]
)
)
with cv.prepend_path(i):
raise cv.Invalid(
f"Source for extension of ID '{item_id}' was not found."
)
item[CONF_ID] = item_id
index[item_id] = merge_config(old, item)
for item_id in removals:
index.pop(item_id, None)
value[:] = index.values()
for i, item in enumerate(value):
with cv.prepend_path(i):
resolve_extend_remove(item, False)
return
if isinstance(value, dict):
removals = []
for k, v in value.items():
with cv.prepend_path(k):
if isinstance(v, Remove):
removals.append(k)
continue
resolve_extend_remove(k, True)
resolve_extend_remove(v, False)
for k in removals:
value.pop(k, None)
return
if is_key:
return # do not check keys (yet)
check_replaceme(value)
return
class ConfigValidationStep(abc.ABC):
@@ -437,19 +510,6 @@ class LoadValidationStep(ConfigValidationStep):
continue
p_name = p_config.get("platform")
if p_name is None:
p_id = p_config.get(CONF_ID)
if isinstance(p_id, Extend):
result.add_str_error(
f"Source for extension of ID '{p_id.value}' was not found.",
path + [CONF_ID],
)
continue
if isinstance(p_id, Remove):
result.add_str_error(
f"Source for removal of ID '{p_id.value}' was not found.",
path + [CONF_ID],
)
continue
result.add_str_error(
f"'{self.domain}' requires a 'platform' key but it was not specified.",
path,
@@ -934,9 +994,10 @@ def validate_config(
CORE.raw_config = config
# 1.1. Check for REPLACEME special value
# 1.1. Resolve !extend and !remove and check for REPLACEME
# After this step, there will not be any Extend or Remove values in the config anymore
try:
recursive_check_replaceme(config)
resolve_extend_remove(config)
except vol.Invalid as err:
result.add_error(err)

View File

@@ -1,7 +1,6 @@
from collections.abc import Callable
from esphome.const import (
CONF_ID,
CONF_LEVEL,
CONF_LOGGER,
KEY_CORE,
@@ -75,73 +74,28 @@ class Remove:
return isinstance(b, Remove) and self.value == b.value
def merge_config(full_old, full_new):
def merge(old, new):
if isinstance(new, dict):
if not isinstance(old, dict):
return new
# Preserve OrderedDict type by copying to OrderedDict if either input is OrderedDict
if isinstance(old, OrderedDict) or isinstance(new, OrderedDict):
res = OrderedDict(old)
else:
res = old.copy()
for k, v in new.items():
if isinstance(v, Remove) and k in old:
del res[k]
else:
res[k] = merge(old[k], v) if k in old else v
return res
if isinstance(new, list):
if not isinstance(old, list):
return new
res = old.copy()
ids = {
v_id: i
for i, v in enumerate(res)
if isinstance(v, dict)
and (v_id := v.get(CONF_ID))
and isinstance(v_id, str)
}
extend_ids = {
v_id.value: i
for i, v in enumerate(res)
if isinstance(v, dict)
and (v_id := v.get(CONF_ID))
and isinstance(v_id, Extend)
}
ids_to_delete = []
for v in new:
if isinstance(v, dict) and (new_id := v.get(CONF_ID)):
if isinstance(new_id, Extend):
new_id = new_id.value
if new_id in ids:
v[CONF_ID] = new_id
res[ids[new_id]] = merge(res[ids[new_id]], v)
continue
elif isinstance(new_id, Remove):
new_id = new_id.value
if new_id in ids:
ids_to_delete.append(ids[new_id])
continue
elif (
new_id in extend_ids
): # When a package is extending a non-packaged item
extend_res = res[extend_ids[new_id]]
extend_res[CONF_ID] = new_id
new_v = merge(v, extend_res)
res[extend_ids[new_id]] = new_v
continue
else:
ids[new_id] = len(res)
res.append(v)
return [v for i, v in enumerate(res) if i not in ids_to_delete]
if new is None:
return old
def merge_config(old, new):
if isinstance(new, Remove):
return new
if isinstance(new, dict):
if not isinstance(old, dict):
return new
# Preserve OrderedDict type by copying to OrderedDict if either input is OrderedDict
if isinstance(old, OrderedDict) or isinstance(new, OrderedDict):
res = OrderedDict(old)
else:
res = old.copy()
for k, v in new.items():
res[k] = merge_config(old.get(k), v)
return res
if isinstance(new, list):
if not isinstance(old, list):
return new
return old + new
if new is None:
return old
return merge(full_old, full_new)
return new
def filter_source_files_from_platform(

View File

@@ -24,7 +24,6 @@ import voluptuous as vol
from esphome import core
import esphome.codegen as cg
from esphome.config_helpers import Extend, Remove
from esphome.const import (
ALLOWED_NAME_CHARS,
CONF_AVAILABILITY,
@@ -624,12 +623,6 @@ def declare_id(type):
if value is None:
return core.ID(None, is_declaration=True, type=type)
if isinstance(value, Extend):
raise Invalid(f"Source for extension of ID '{value.value}' was not found.")
if isinstance(value, Remove):
raise Invalid(f"Source for Removal of ID '{value.value}' was not found.")
return core.ID(validate_id_name(value), is_declaration=True, type=type)
return validator

View File

@@ -11,6 +11,7 @@ from esphome.const import (
CONF_COMMENT,
CONF_ESPHOME,
CONF_ETHERNET,
CONF_OPENTHREAD,
CONF_PORT,
CONF_USE_ADDRESS,
CONF_WEB_SERVER,
@@ -641,6 +642,9 @@ class EsphomeCore:
if CONF_ETHERNET in self.config:
return self.config[CONF_ETHERNET][CONF_USE_ADDRESS]
if CONF_OPENTHREAD in self.config:
return f"{self.name}.local"
return None
@property

View File

@@ -199,6 +199,7 @@
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_WEBSERVER_SORTING
#define USE_WIFI_11KV_SUPPORT
#define USE_WIFI_FAST_CONNECT
#define USB_HOST_MAX_REQUESTS 16
#ifdef USE_ARDUINO

View File

@@ -281,13 +281,13 @@ template<typename T> class FixedVector {
}
}
/// Emplace element without bounds checking - constructs in-place
/// Emplace element without bounds checking - constructs in-place with arguments
/// Caller must ensure sufficient capacity was allocated via init()
/// Returns reference to the newly constructed element
/// NOTE: Caller MUST ensure size_ < capacity_ before calling
T &emplace_back() {
// Use placement new to default-construct the object in pre-allocated memory
new (&data_[size_]) T();
template<typename... Args> T &emplace_back(Args &&...args) {
// Use placement new to construct the object in pre-allocated memory
new (&data_[size_]) T(std::forward<Args>(args)...);
size_++;
return data_[size_ - 1];
}
@@ -1158,18 +1158,4 @@ template<typename T, enable_if_t<std::is_pointer<T *>::value, int> = 0> T &id(T
///@}
/// @name Deprecated functions
///@{
ESPDEPRECATED("hexencode() is deprecated, use format_hex_pretty() instead.", "2022.1")
inline std::string hexencode(const uint8_t *data, uint32_t len) { return format_hex_pretty(data, len); }
template<typename T>
ESPDEPRECATED("hexencode() is deprecated, use format_hex_pretty() instead.", "2022.1")
std::string hexencode(const T &data) {
return hexencode(data.data(), data.size());
}
///@}
} // namespace esphome

View File

@@ -328,17 +328,30 @@ void HOT Scheduler::call(uint32_t now) {
// Single-core platforms don't use this queue and fall back to the heap-based approach.
//
// Note: Items cancelled via cancel_item_locked_() are marked with remove=true but still
// processed here. They are removed from the queue normally via pop_front() but skipped
// during execution by should_skip_item_(). This is intentional - no memory leak occurs.
while (!this->defer_queue_.empty()) {
// The outer check is done without a lock for performance. If the queue
// appears non-empty, we lock and process an item. We don't need to check
// empty() again inside the lock because only this thread can remove items.
// processed here. They are skipped during execution by should_skip_item_().
// This is intentional - no memory leak occurs.
//
// We use an index (defer_queue_front_) to track the read position instead of calling
// erase() on every pop, which would be O(n). The queue is processed once per loop -
// any items added during processing are left for the next loop iteration.
// Snapshot the queue end point - only process items that existed at loop start
// Items added during processing (by callbacks or other threads) run next loop
// No lock needed: single consumer (main loop), stale read just means we process less this iteration
size_t defer_queue_end = this->defer_queue_.size();
while (this->defer_queue_front_ < defer_queue_end) {
std::unique_ptr<SchedulerItem> item;
{
LockGuard lock(this->lock_);
item = std::move(this->defer_queue_.front());
this->defer_queue_.pop_front();
// SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_.
// This is intentional and safe because:
// 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function
// 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_
// and has_cancelled_timeout_in_container_ in scheduler.h)
// 3. The lock protects concurrent access, but the nullptr remains until cleanup
item = std::move(this->defer_queue_[this->defer_queue_front_]);
this->defer_queue_front_++;
}
// Execute callback without holding lock to prevent deadlocks
@@ -349,6 +362,13 @@ void HOT Scheduler::call(uint32_t now) {
// Recycle the defer item after execution
this->recycle_item_(std::move(item));
}
// If we've consumed all items up to the snapshot point, clean up the dead space
// Single consumer (main loop), so no lock needed for this check
if (this->defer_queue_front_ >= defer_queue_end) {
LockGuard lock(this->lock_);
this->cleanup_defer_queue_locked_();
}
#endif /* not ESPHOME_THREAD_SINGLE */
// Convert the fresh timestamp from main loop to 64-bit for scheduler operations

View File

@@ -264,6 +264,36 @@ class Scheduler {
// Helper to recycle a SchedulerItem
void recycle_item_(std::unique_ptr<SchedulerItem> item);
#ifndef ESPHOME_THREAD_SINGLE
// Helper to cleanup defer_queue_ after processing
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
inline void cleanup_defer_queue_locked_() {
// Check if new items were added by producers during processing
if (this->defer_queue_front_ >= this->defer_queue_.size()) {
// Common case: no new items - clear everything
this->defer_queue_.clear();
} else {
// Rare case: new items were added during processing - compact the vector
// This only happens when:
// 1. A deferred callback calls defer() again, or
// 2. Another thread calls defer() while we're processing
//
// Move unprocessed items (added during this loop) to the front for next iteration
//
// SAFETY: Compacted items may include cancelled items (marked for removal via
// cancel_item_locked_() during execution). This is safe because should_skip_item_()
// checks is_item_removed_() before executing, so cancelled items will be skipped
// and recycled on the next loop iteration.
size_t remaining = this->defer_queue_.size() - this->defer_queue_front_;
for (size_t i = 0; i < remaining; i++) {
this->defer_queue_[i] = std::move(this->defer_queue_[this->defer_queue_front_ + i]);
}
this->defer_queue_.resize(remaining);
}
this->defer_queue_front_ = 0;
}
#endif /* not ESPHOME_THREAD_SINGLE */
// Helper to check if item is marked for removal (platform-specific)
// Returns true if item should be skipped, handles platform-specific synchronization
// For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this
@@ -282,13 +312,18 @@ class Scheduler {
// Helper to mark matching items in a container as removed
// Returns the number of items marked for removal
// For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this
// function.
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
template<typename Container>
size_t mark_matching_items_removed_(Container &container, Component *component, const char *name_cstr,
SchedulerItem::Type type, bool match_retry) {
size_t count = 0;
for (auto &item : container) {
// Skip nullptr items (can happen in defer_queue_ when items are being processed)
// The defer_queue_ uses index-based processing: items are std::moved out but left in the
// vector as nullptr until cleanup. Even though this function is called with lock held,
// the vector can still contain nullptr items from the processing loop. This check prevents crashes.
if (!item)
continue;
if (this->matches_item_(item, component, name_cstr, type, match_retry)) {
// Mark item for removal (platform-specific)
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
@@ -311,6 +346,12 @@ class Scheduler {
bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr,
bool match_retry) const {
for (const auto &item : container) {
// Skip nullptr items (can happen in defer_queue_ when items are being processed)
// The defer_queue_ uses index-based processing: items are std::moved out but left in the
// vector as nullptr until cleanup. If this function is called during defer queue processing,
// it will iterate over these nullptr items. This check prevents crashes.
if (!item)
continue;
if (is_item_removed_(item.get()) &&
this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry,
/* skip_removed= */ false)) {
@@ -324,9 +365,12 @@ class Scheduler {
std::vector<std::unique_ptr<SchedulerItem>> items_;
std::vector<std::unique_ptr<SchedulerItem>> to_add_;
#ifndef ESPHOME_THREAD_SINGLE
// Single-core platforms don't need the defer queue and save 40 bytes of RAM
std::deque<std::unique_ptr<SchedulerItem>> defer_queue_; // FIFO queue for defer() calls
#endif /* ESPHOME_THREAD_SINGLE */
// Single-core platforms don't need the defer queue and save ~32 bytes of RAM
// Using std::vector instead of std::deque avoids 512-byte chunked allocations
// Index tracking avoids O(n) erase() calls when draining the queue each loop
std::vector<std::unique_ptr<SchedulerItem>> defer_queue_; // FIFO queue for defer() calls
size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items)
#endif /* ESPHOME_THREAD_SINGLE */
uint32_t to_remove_{0};
// Memory pool for recycling SchedulerItem objects to reduce heap churn.

View File

@@ -1415,7 +1415,13 @@ class RepeatedTypeInfo(TypeInfo):
super().__init__(field)
# Check if this is a pointer field by looking for container_pointer option
self._container_type = get_field_opt(field, pb.container_pointer, "")
self._use_pointer = bool(self._container_type)
# Check for non-template container pointer
self._container_no_template = get_field_opt(
field, pb.container_pointer_no_template, ""
)
self._use_pointer = bool(self._container_type) or bool(
self._container_no_template
)
# Check if this should use FixedVector instead of std::vector
self._use_fixed_vector = get_field_opt(field, pb.fixed_vector, False)
@@ -1434,12 +1440,18 @@ class RepeatedTypeInfo(TypeInfo):
@property
def cpp_type(self) -> str:
if self._container_no_template:
# Non-template container: use type as-is without appending template parameters
return f"const {self._container_no_template}*"
if self._use_pointer and self._container_type:
# For pointer fields, use the specified container type
# If the container type already includes the element type (e.g., std::set<climate::ClimateMode>)
# use it as-is, otherwise append the element type
# Two cases:
# 1. "std::set<climate::ClimateMode>" - Full type with template params, use as-is
# 2. "std::set" - No <>, append the element type
if "<" in self._container_type and ">" in self._container_type:
# Has template parameters specified, use as-is
return f"const {self._container_type}*"
# No <> at all, append element type
return f"const {self._container_type}<{self._ti.cpp_type}>*"
if self._use_fixed_vector:
return f"FixedVector<{self._ti.cpp_type}>"

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Add metadata to memory analysis JSON file.
This script adds components and platform metadata to an existing
memory analysis JSON file. Used by CI to ensure all required fields are present
for the comment script.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Add metadata to memory analysis JSON file"
)
parser.add_argument(
"--json-file",
required=True,
help="Path to JSON file to update",
)
parser.add_argument(
"--components",
required=True,
help='JSON array of component names (e.g., \'["api", "wifi"]\')',
)
parser.add_argument(
"--platform",
required=True,
help="Platform name",
)
args = parser.parse_args()
# Load existing JSON
json_path = Path(args.json_file)
if not json_path.exists():
print(f"Error: JSON file not found: {args.json_file}", file=sys.stderr)
return 1
try:
with open(json_path, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
print(f"Error loading JSON: {e}", file=sys.stderr)
return 1
# Parse components
try:
components = json.loads(args.components)
if not isinstance(components, list):
print("Error: --components must be a JSON array", file=sys.stderr)
return 1
# Element-level validation: ensure each component is a non-empty string
for idx, comp in enumerate(components):
if not isinstance(comp, str) or not comp.strip():
print(
f"Error: component at index {idx} is not a non-empty string: {comp!r}",
file=sys.stderr,
)
return 1
except json.JSONDecodeError as e:
print(f"Error parsing components: {e}", file=sys.stderr)
return 1
# Add metadata
data["components"] = components
data["platform"] = args.platform
# Write back
try:
with open(json_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
print(f"Added metadata to {args.json_file}", file=sys.stderr)
except OSError as e:
print(f"Error writing JSON: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -24,6 +24,37 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
# Comment marker to identify our memory impact comments
COMMENT_MARKER = "<!-- esphome-memory-impact-analysis -->"
def run_gh_command(args: list[str], operation: str) -> subprocess.CompletedProcess:
"""Run a gh CLI command with error handling.
Args:
args: Command arguments (including 'gh')
operation: Description of the operation for error messages
Returns:
CompletedProcess result
Raises:
subprocess.CalledProcessError: If command fails (with detailed error output)
"""
try:
return subprocess.run(
args,
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as e:
print(
f"ERROR: {operation} failed with exit code {e.returncode}", file=sys.stderr
)
print(f"ERROR: Command: {' '.join(args)}", file=sys.stderr)
print(f"ERROR: stdout: {e.stdout}", file=sys.stderr)
print(f"ERROR: stderr: {e.stderr}", file=sys.stderr)
raise
# Thresholds for emoji significance indicators (percentage)
OVERALL_CHANGE_THRESHOLD = 1.0 # Overall RAM/Flash changes
COMPONENT_CHANGE_THRESHOLD = 3.0 # Component breakdown changes
@@ -238,7 +269,6 @@ def create_comment_body(
pr_analysis: dict | None = None,
target_symbols: dict | None = None,
pr_symbols: dict | None = None,
target_cache_hit: bool = False,
) -> str:
"""Create the comment body with memory impact analysis using Jinja2 templates.
@@ -253,7 +283,6 @@ def create_comment_body(
pr_analysis: Optional component breakdown for PR branch
target_symbols: Optional symbol map for target branch
pr_symbols: Optional symbol map for PR branch
target_cache_hit: Whether target branch analysis was loaded from cache
Returns:
Formatted comment body
@@ -283,7 +312,6 @@ def create_comment_body(
"flash_change": format_change(
target_flash, pr_flash, threshold=OVERALL_CHANGE_THRESHOLD
),
"target_cache_hit": target_cache_hit,
"component_change_threshold": COMPONENT_CHANGE_THRESHOLD,
}
@@ -356,7 +384,7 @@ def find_existing_comment(pr_number: str) -> str | None:
print(f"DEBUG: Looking for existing comment on PR #{pr_number}", file=sys.stderr)
# Use gh api to get comments directly - this returns the numeric id field
result = subprocess.run(
result = run_gh_command(
[
"gh",
"api",
@@ -364,9 +392,7 @@ def find_existing_comment(pr_number: str) -> str | None:
"--jq",
".[] | {id, body}",
],
capture_output=True,
text=True,
check=True,
operation="Get PR comments",
)
print(
@@ -420,7 +446,8 @@ def update_existing_comment(comment_id: str, comment_body: str) -> None:
subprocess.CalledProcessError: If gh command fails
"""
print(f"DEBUG: Updating existing comment {comment_id}", file=sys.stderr)
result = subprocess.run(
print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr)
result = run_gh_command(
[
"gh",
"api",
@@ -430,9 +457,7 @@ def update_existing_comment(comment_id: str, comment_body: str) -> None:
"-f",
f"body={comment_body}",
],
check=True,
capture_output=True,
text=True,
operation="Update PR comment",
)
print(f"DEBUG: Update response: {result.stdout}", file=sys.stderr)
@@ -448,11 +473,10 @@ def create_new_comment(pr_number: str, comment_body: str) -> None:
subprocess.CalledProcessError: If gh command fails
"""
print(f"DEBUG: Posting new comment on PR #{pr_number}", file=sys.stderr)
result = subprocess.run(
print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr)
result = run_gh_command(
["gh", "pr", "comment", pr_number, "--body", comment_body],
check=True,
capture_output=True,
text=True,
operation="Create PR comment",
)
print(f"DEBUG: Post response: {result.stdout}", file=sys.stderr)
@@ -484,80 +508,129 @@ def main() -> int:
description="Post or update PR comment with memory impact analysis"
)
parser.add_argument("--pr-number", required=True, help="PR number")
parser.add_argument(
"--components",
required=True,
help='JSON array of component names (e.g., \'["api", "wifi"]\')',
)
parser.add_argument("--platform", required=True, help="Platform name")
parser.add_argument(
"--target-ram", type=int, required=True, help="Target branch RAM usage"
)
parser.add_argument(
"--target-flash", type=int, required=True, help="Target branch flash usage"
)
parser.add_argument("--pr-ram", type=int, required=True, help="PR branch RAM usage")
parser.add_argument(
"--pr-flash", type=int, required=True, help="PR branch flash usage"
)
parser.add_argument(
"--target-json",
help="Optional path to target branch analysis JSON (for detailed analysis)",
required=True,
help="Path to target branch analysis JSON file",
)
parser.add_argument(
"--pr-json",
help="Optional path to PR branch analysis JSON (for detailed analysis)",
)
parser.add_argument(
"--target-cache-hit",
action="store_true",
help="Indicates that target branch analysis was loaded from cache",
required=True,
help="Path to PR branch analysis JSON file",
)
args = parser.parse_args()
# Parse components from JSON
try:
components = json.loads(args.components)
if not isinstance(components, list):
print("Error: --components must be a JSON array", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error parsing --components JSON: {e}", file=sys.stderr)
# Load analysis JSON files (all data comes from JSON for security)
target_data: dict | None = load_analysis_json(args.target_json)
if not target_data:
print("Error: Failed to load target analysis JSON", file=sys.stderr)
sys.exit(1)
# Load analysis JSON files
target_analysis = None
pr_analysis = None
target_symbols = None
pr_symbols = None
pr_data: dict | None = load_analysis_json(args.pr_json)
if not pr_data:
print("Error: Failed to load PR analysis JSON", file=sys.stderr)
sys.exit(1)
if args.target_json:
target_data = load_analysis_json(args.target_json)
if target_data and target_data.get("detailed_analysis"):
target_analysis = target_data["detailed_analysis"].get("components")
target_symbols = target_data["detailed_analysis"].get("symbols")
# Extract detailed analysis if available
target_analysis: dict | None = None
pr_analysis: dict | None = None
target_symbols: dict | None = None
pr_symbols: dict | None = None
if args.pr_json:
pr_data = load_analysis_json(args.pr_json)
if pr_data and pr_data.get("detailed_analysis"):
pr_analysis = pr_data["detailed_analysis"].get("components")
pr_symbols = pr_data["detailed_analysis"].get("symbols")
if target_data.get("detailed_analysis"):
target_analysis = target_data["detailed_analysis"].get("components")
target_symbols = target_data["detailed_analysis"].get("symbols")
if pr_data.get("detailed_analysis"):
pr_analysis = pr_data["detailed_analysis"].get("components")
pr_symbols = pr_data["detailed_analysis"].get("symbols")
# Extract all values from JSON files (prevents shell injection from PR code)
components = target_data.get("components")
platform = target_data.get("platform")
target_ram = target_data.get("ram_bytes")
target_flash = target_data.get("flash_bytes")
pr_ram = pr_data.get("ram_bytes")
pr_flash = pr_data.get("flash_bytes")
# Validate required fields and types
missing_fields: list[str] = []
type_errors: list[str] = []
if components is None:
missing_fields.append("components")
elif not isinstance(components, list):
type_errors.append(
f"components must be a list, got {type(components).__name__}"
)
else:
for idx, comp in enumerate(components):
if not isinstance(comp, str):
type_errors.append(
f"components[{idx}] must be a string, got {type(comp).__name__}"
)
if platform is None:
missing_fields.append("platform")
elif not isinstance(platform, str):
type_errors.append(f"platform must be a string, got {type(platform).__name__}")
if target_ram is None:
missing_fields.append("target.ram_bytes")
elif not isinstance(target_ram, int):
type_errors.append(
f"target.ram_bytes must be an integer, got {type(target_ram).__name__}"
)
if target_flash is None:
missing_fields.append("target.flash_bytes")
elif not isinstance(target_flash, int):
type_errors.append(
f"target.flash_bytes must be an integer, got {type(target_flash).__name__}"
)
if pr_ram is None:
missing_fields.append("pr.ram_bytes")
elif not isinstance(pr_ram, int):
type_errors.append(
f"pr.ram_bytes must be an integer, got {type(pr_ram).__name__}"
)
if pr_flash is None:
missing_fields.append("pr.flash_bytes")
elif not isinstance(pr_flash, int):
type_errors.append(
f"pr.flash_bytes must be an integer, got {type(pr_flash).__name__}"
)
if missing_fields or type_errors:
if missing_fields:
print(
f"Error: JSON files missing required fields: {', '.join(missing_fields)}",
file=sys.stderr,
)
if type_errors:
print(
f"Error: Type validation failed: {'; '.join(type_errors)}",
file=sys.stderr,
)
print(f"Target JSON keys: {list(target_data.keys())}", file=sys.stderr)
print(f"PR JSON keys: {list(pr_data.keys())}", file=sys.stderr)
sys.exit(1)
# Create comment body
# Note: Memory totals (RAM/Flash) are summed across all builds if multiple were run.
comment_body = create_comment_body(
components=components,
platform=args.platform,
target_ram=args.target_ram,
target_flash=args.target_flash,
pr_ram=args.pr_ram,
pr_flash=args.pr_flash,
platform=platform,
target_ram=target_ram,
target_flash=target_flash,
pr_ram=pr_ram,
pr_flash=pr_flash,
target_analysis=target_analysis,
pr_analysis=pr_analysis,
target_symbols=target_symbols,
pr_symbols=pr_symbols,
target_cache_hit=args.target_cache_hit,
)
# Post or update comment

View File

@@ -210,6 +210,22 @@ def should_run_clang_tidy(branch: str | None = None) -> bool:
return _any_changed_file_endswith(branch, CPP_FILE_EXTENSIONS)
def count_changed_cpp_files(branch: str | None = None) -> int:
"""Count the number of changed C++ files.
This is used to determine whether to split clang-tidy jobs or run them as a single job.
For PRs with < 30 changed C++ files, running a single job is faster than splitting.
Args:
branch: Branch to compare against. If None, uses default.
Returns:
Number of changed C++ files.
"""
files = changed_files(branch)
return sum(1 for file in files if file.endswith(CPP_FILE_EXTENSIONS))
def should_run_clang_format(branch: str | None = None) -> bool:
"""Determine if clang-format should run based on changed files.
@@ -408,6 +424,7 @@ def main() -> None:
run_clang_tidy = should_run_clang_tidy(args.branch)
run_clang_format = should_run_clang_format(args.branch)
run_python_linters = should_run_python_linters(args.branch)
changed_cpp_file_count = count_changed_cpp_files(args.branch)
# Get both directly changed and all changed components (with dependencies) in one call
script_path = Path(__file__).parent / "list-components.py"
@@ -445,10 +462,23 @@ def main() -> None:
# Detect components for memory impact analysis (merged config)
memory_impact = detect_memory_impact_config(args.branch)
# Determine clang-tidy split mode based on file count
# For small PRs (< 30 files), use nosplit for faster CI
# For large PRs (>= 30 files), use split for better parallelization
CLANG_TIDY_SPLIT_THRESHOLD = 30
if run_clang_tidy:
if changed_cpp_file_count < CLANG_TIDY_SPLIT_THRESHOLD:
clang_tidy_mode = "nosplit"
else:
clang_tidy_mode = "split"
else:
clang_tidy_mode = "disabled"
# Build output
output: dict[str, Any] = {
"integration_tests": run_integration,
"clang_tidy": run_clang_tidy,
"clang_tidy_mode": clang_tidy_mode,
"clang_format": run_clang_format,
"python_linters": run_python_linters,
"changed_components": changed_components,
@@ -458,6 +488,7 @@ def main() -> None:
"component_test_count": len(changed_components_with_tests),
"directly_changed_count": len(directly_changed_with_tests),
"dependency_only_count": len(dependency_only_components),
"changed_cpp_file_count": changed_cpp_file_count,
"memory_impact": memory_impact,
}

View File

@@ -25,6 +25,7 @@ int main() { return 0;}
Path(zephyr_dir / "prj.conf").write_text(
"""
CONFIG_NEWLIB_LIBC=y
CONFIG_BT=y
CONFIG_ADC=y
""",
encoding="utf-8",

View File

@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.components.packages import do_packages_pass
from esphome.config import resolve_extend_remove
from esphome.config_helpers import Extend, Remove
import esphome.config_validation as cv
from esphome.const import (
@@ -64,13 +65,20 @@ def fixture_basic_esphome():
return {CONF_NAME: TEST_DEVICE_NAME, CONF_PLATFORM: TEST_PLATFORM}
def packages_pass(config):
"""Wrapper around packages_pass that also resolves Extend and Remove."""
config = do_packages_pass(config)
resolve_extend_remove(config)
return config
def test_package_unused(basic_esphome, basic_wifi):
"""
Ensures do_package_pass does not change a config if packages aren't used.
"""
config = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == config
@@ -83,7 +91,7 @@ def test_package_invalid_dict(basic_esphome, basic_wifi):
config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: basic_wifi | {CONF_URL: ""}}
with pytest.raises(cv.Invalid):
do_packages_pass(config)
packages_pass(config)
def test_package_include(basic_wifi, basic_esphome):
@@ -99,7 +107,7 @@ def test_package_include(basic_wifi, basic_esphome):
expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -124,7 +132,7 @@ def test_package_append(basic_wifi, basic_esphome):
},
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -148,7 +156,7 @@ def test_package_override(basic_wifi, basic_esphome):
},
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -177,7 +185,7 @@ def test_multiple_package_order():
},
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -233,7 +241,7 @@ def test_package_list_merge():
]
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -311,7 +319,7 @@ def test_package_list_merge_by_id():
]
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -350,13 +358,13 @@ def test_package_merge_by_id_with_list():
]
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
def test_package_merge_by_missing_id():
"""
Ensures that components with missing IDs are not merged.
Ensures that a validation error is thrown when trying to extend a missing ID.
"""
config = {
@@ -379,25 +387,15 @@ def test_package_merge_by_missing_id():
],
}
expected = {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_FILTERS: [{CONF_MULTIPLY: 42.0}],
},
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_FILTERS: [{CONF_MULTIPLY: 10.0}],
},
{
CONF_ID: Extend(TEST_SENSOR_ID_2),
CONF_FILTERS: [{CONF_OFFSET: 146.0}],
},
]
}
error_raised = False
try:
packages_pass(config)
assert False, "Expected validation error for missing ID"
except cv.Invalid as err:
error_raised = True
assert err.path == [CONF_SENSOR, 2]
actual = do_packages_pass(config)
assert actual == expected
assert error_raised
def test_package_list_remove_by_id():
@@ -447,7 +445,7 @@ def test_package_list_remove_by_id():
]
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -493,7 +491,7 @@ def test_multiple_package_list_remove_by_id():
]
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -514,7 +512,7 @@ def test_package_dict_remove_by_id(basic_wifi, basic_esphome):
CONF_ESPHOME: basic_esphome,
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -545,7 +543,6 @@ def test_package_remove_by_missing_id():
}
expected = {
"missing_key": Remove(),
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
@@ -555,14 +552,10 @@ def test_package_remove_by_missing_id():
CONF_ID: TEST_SENSOR_ID_1,
CONF_FILTERS: [{CONF_MULTIPLY: 10.0}],
},
{
CONF_ID: Remove(TEST_SENSOR_ID_2),
CONF_FILTERS: [{CONF_OFFSET: 146.0}],
},
],
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -634,7 +627,7 @@ def test_remote_packages_with_files_list(
]
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected
@@ -730,5 +723,5 @@ def test_remote_packages_with_files_and_vars(
]
}
actual = do_packages_pass(config)
actual = packages_pass(config)
assert actual == expected

View File

@@ -0,0 +1,2 @@
ble_nus:
type: logs

View File

@@ -0,0 +1,2 @@
ble_nus:
type: logs

View File

@@ -0,0 +1,101 @@
sensor:
# Source sensor for testing filters
- platform: template
name: "Source Sensor"
id: source_sensor
lambda: return 42.0;
update_interval: 1s
# Streaming filters (window_size == send_every) - uses StreamingFilter base class
- platform: copy
source_id: source_sensor
name: "Streaming Min Filter"
filters:
- min:
window_size: 10
send_every: 10 # Batch window → StreamingMinFilter
- platform: copy
source_id: source_sensor
name: "Streaming Max Filter"
filters:
- max:
window_size: 10
send_every: 10 # Batch window → StreamingMaxFilter
- platform: copy
source_id: source_sensor
name: "Streaming Moving Average Filter"
filters:
- sliding_window_moving_average:
window_size: 10
send_every: 10 # Batch window → StreamingMovingAverageFilter
# Sliding window filters (window_size != send_every) - uses SlidingWindowFilter base class with ring buffer
- platform: copy
source_id: source_sensor
name: "Sliding Min Filter"
filters:
- min:
window_size: 10
send_every: 5 # Sliding window → MinFilter with ring buffer
- platform: copy
source_id: source_sensor
name: "Sliding Max Filter"
filters:
- max:
window_size: 10
send_every: 5 # Sliding window → MaxFilter with ring buffer
- platform: copy
source_id: source_sensor
name: "Sliding Median Filter"
filters:
- median:
window_size: 10
send_every: 5 # Sliding window → MedianFilter with ring buffer
- platform: copy
source_id: source_sensor
name: "Sliding Quantile Filter"
filters:
- quantile:
window_size: 10
send_every: 5
quantile: 0.9 # Sliding window → QuantileFilter with ring buffer
- platform: copy
source_id: source_sensor
name: "Sliding Moving Average Filter"
filters:
- sliding_window_moving_average:
window_size: 10
send_every: 5 # Sliding window → SlidingWindowMovingAverageFilter with ring buffer
# Edge cases
- platform: copy
source_id: source_sensor
name: "Large Batch Window Min"
filters:
- min:
window_size: 1000
send_every: 1000 # Large batch → StreamingMinFilter (4 bytes, not 4KB)
- platform: copy
source_id: source_sensor
name: "Small Sliding Window"
filters:
- median:
window_size: 3
send_every: 1 # Frequent output → MedianFilter with 3-element ring buffer
# send_first_at parameter test
- platform: copy
source_id: source_sensor
name: "Early Send Filter"
filters:
- max:
window_size: 10
send_every: 10
send_first_at: 1 # Send after first value

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -1,4 +1,5 @@
wifi:
fast_connect: true
networks:
- ssid: MySSID
eap:

View File

@@ -7,6 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te
- `conftest.py` - Common fixtures and utilities
- `const.py` - Constants used throughout the integration tests
- `types.py` - Type definitions for fixtures and functions
- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `build_key_to_entity_mapping`)
- `fixtures/` - YAML configuration files for tests
- `test_*.py` - Individual test files
@@ -26,6 +27,32 @@ The `yaml_config` fixture automatically loads YAML configurations based on the t
- `reserved_tcp_port` - Reserves a TCP port by holding the socket open until ESPHome needs it
- `unused_tcp_port` - Provides the reserved port number for each test
### Helper Utilities
#### InitialStateHelper (`state_utils.py`)
The `InitialStateHelper` class solves a common problem in integration tests: when an API client connects, ESPHome automatically broadcasts the current state of all entities. This can interfere with tests that want to track only new state changes triggered by test actions.
**What it does:**
- Tracks all entities (except stateless ones like buttons)
- Swallows the first state broadcast for each entity
- Forwards all subsequent state changes to your test callback
- Provides `wait_for_initial_states()` to synchronize before test actions
**When to use it:**
- Any test that triggers entity state changes and needs to verify them
- Tests that would otherwise see duplicate or unexpected states
- Tests that need clean separation between initial state and test-triggered changes
**Implementation details:**
- Uses `(device_id, key)` tuples to uniquely identify entities across devices
- Automatically excludes `ButtonInfo` entities (stateless)
- Provides debug logging to track state reception (use `--log-cli-level=DEBUG`)
- Safe for concurrent use with multiple entity types
**Future work:**
Consider converting existing integration tests to use `InitialStateHelper` for more reliable state tracking and to eliminate race conditions related to initial state broadcasts.
### Writing Tests
The simplest way to write a test is to use the `run_compiled` and `api_client_connected` fixtures:
@@ -125,6 +152,54 @@ async def test_my_sensor(
```
##### State Subscription Pattern
**Recommended: Using InitialStateHelper**
When an API client connects, ESPHome automatically sends the current state of all entities. The `InitialStateHelper` (from `state_utils.py`) handles this by swallowing these initial states and only forwarding subsequent state changes to your test callback:
```python
from .state_utils import InitialStateHelper
# Track state changes with futures
loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
state_future: asyncio.Future[EntityState] = loop.create_future()
def on_state(state: EntityState) -> None:
"""This callback only receives NEW state changes, not initial states."""
states[state.key] = state
# Check for specific condition using isinstance
if isinstance(state, SensorState) and state.state == expected_value:
if not state_future.done():
state_future.set_result(state)
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
# Subscribe with the wrapper that filters initial states
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for all initial states to be broadcast
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Now perform your test actions - on_state will only receive new changes
# ... trigger state changes ...
# Wait for expected state
try:
result = await asyncio.wait_for(state_future, timeout=5.0)
except asyncio.TimeoutError:
pytest.fail(f"Expected state not received. Got: {list(states.values())}")
```
**Legacy: Manual State Tracking**
If you need to handle initial states manually (not recommended for new tests):
```python
# Track state changes with futures
loop = asyncio.get_running_loop()

View File

@@ -0,0 +1,170 @@
esphome:
name: test-script-queued
host:
api:
actions:
# Test 1: Queue depth with default max_runs=5
- action: test_queue_depth
then:
- logger.log: "=== TEST 1: Queue depth (max_runs=5 means 5 total, reject 6-7) ==="
- script.execute:
id: queue_depth_script
value: 1
- script.execute:
id: queue_depth_script
value: 2
- script.execute:
id: queue_depth_script
value: 3
- script.execute:
id: queue_depth_script
value: 4
- script.execute:
id: queue_depth_script
value: 5
- script.execute:
id: queue_depth_script
value: 6
- script.execute:
id: queue_depth_script
value: 7
# Test 2: Ring buffer wrap test
- action: test_ring_buffer
then:
- logger.log: "=== TEST 2: Ring buffer wrap (should process A, B, C in order) ==="
- script.execute:
id: wrap_script
msg: "A"
- script.execute:
id: wrap_script
msg: "B"
- script.execute:
id: wrap_script
msg: "C"
# Test 3: Stop clears queue
- action: test_stop_clears
then:
- logger.log: "=== TEST 3: Stop clears queue (should only see 1, then 'STOPPED') ==="
- script.execute:
id: stop_script
num: 1
- script.execute:
id: stop_script
num: 2
- script.execute:
id: stop_script
num: 3
- delay: 50ms
- logger.log: "STOPPING script now"
- script.stop: stop_script
# Test 4: Verify rejection (max_runs=3)
- action: test_rejection
then:
- logger.log: "=== TEST 4: Verify rejection (max_runs=3 means 3 total, reject 4-8) ==="
- script.execute:
id: rejection_script
val: 1
- script.execute:
id: rejection_script
val: 2
- script.execute:
id: rejection_script
val: 3
- script.execute:
id: rejection_script
val: 4
- script.execute:
id: rejection_script
val: 5
- script.execute:
id: rejection_script
val: 6
- script.execute:
id: rejection_script
val: 7
- script.execute:
id: rejection_script
val: 8
# Test 5: No parameters test
- action: test_no_params
then:
- logger.log: "=== TEST 5: No params (should process 3 times) ==="
- script.execute: no_params_script
- script.execute: no_params_script
- script.execute: no_params_script
logger:
level: DEBUG
script:
# Test script 1: Queue depth test (default max_runs=5)
- id: queue_depth_script
mode: queued
parameters:
value: int
then:
- logger.log:
format: "Queue test: START item %d"
args: ['value']
- delay: 100ms
- logger.log:
format: "Queue test: END item %d"
args: ['value']
# Test script 2: Ring buffer wrap test (max_runs=3)
- id: wrap_script
mode: queued
max_runs: 3
parameters:
msg: string
then:
- logger.log:
format: "Ring buffer: START '%s'"
args: ['msg.c_str()']
- delay: 50ms
- logger.log:
format: "Ring buffer: END '%s'"
args: ['msg.c_str()']
# Test script 3: Stop test
- id: stop_script
mode: queued
max_runs: 5
parameters:
num: int
then:
- logger.log:
format: "Stop test: START %d"
args: ['num']
- delay: 100ms
- logger.log:
format: "Stop test: END %d"
args: ['num']
# Test script 4: Rejection test (max_runs=3)
- id: rejection_script
mode: queued
max_runs: 3
parameters:
val: int
then:
- logger.log:
format: "Rejection test: START %d"
args: ['val']
- delay: 200ms
- logger.log:
format: "Rejection test: END %d"
args: ['val']
# Test script 5: No parameters
- id: no_params_script
mode: queued
then:
- logger.log: "No params: START"
- delay: 50ms
- logger.log: "No params: END"

View File

@@ -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));
}

View File

@@ -0,0 +1,84 @@
esphome:
name: test-nan-handling
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
sensor:
- platform: template
name: "Source NaN Sensor"
id: source_nan_sensor
accuracy_decimals: 2
- platform: copy
source_id: source_nan_sensor
name: "Min NaN Sensor"
id: min_nan_sensor
filters:
- min:
window_size: 5
send_every: 5
send_first_at: 1
- platform: copy
source_id: source_nan_sensor
name: "Max NaN Sensor"
id: max_nan_sensor
filters:
- max:
window_size: 5
send_every: 5
send_first_at: 1
script:
- id: publish_nan_values_script
then:
- sensor.template.publish:
id: source_nan_sensor
state: 10.0
- delay: 20ms
- sensor.template.publish:
id: source_nan_sensor
state: !lambda 'return NAN;'
- delay: 20ms
- sensor.template.publish:
id: source_nan_sensor
state: 5.0
- delay: 20ms
- sensor.template.publish:
id: source_nan_sensor
state: !lambda 'return NAN;'
- delay: 20ms
- sensor.template.publish:
id: source_nan_sensor
state: 15.0
- delay: 20ms
- sensor.template.publish:
id: source_nan_sensor
state: 8.0
- delay: 20ms
- sensor.template.publish:
id: source_nan_sensor
state: !lambda 'return NAN;'
- delay: 20ms
- sensor.template.publish:
id: source_nan_sensor
state: 12.0
- delay: 20ms
- sensor.template.publish:
id: source_nan_sensor
state: 3.0
- delay: 20ms
- sensor.template.publish:
id: source_nan_sensor
state: !lambda 'return NAN;'
button:
- platform: template
name: "Publish NaN Values Button"
id: publish_nan_button
on_press:
- script.execute: publish_nan_values_script

View File

@@ -0,0 +1,115 @@
esphome:
name: test-sliding-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
# ACTUAL sliding window filters (window_size != send_every) - use ring buffers
# Window of 5, send every 2 values
- platform: copy
source_id: source_sensor
name: "Sliding Min Sensor"
id: sliding_min_sensor
filters:
- min:
window_size: 5
send_every: 2
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Sliding Max Sensor"
id: sliding_max_sensor
filters:
- max:
window_size: 5
send_every: 2
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Sliding Median Sensor"
id: sliding_median_sensor
filters:
- median:
window_size: 5
send_every: 2
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Sliding Moving Avg Sensor"
id: sliding_moving_avg_sensor
filters:
- sliding_window_moving_average:
window_size: 5
send_every: 2
send_first_at: 1
# Button to trigger publishing test values
script:
- id: publish_values_script
then:
# Publish 10 values: 1.0, 2.0, ..., 10.0
# With window_size=5, send_every=2, send_first_at=1:
# - Output at position 1: window=[1], min=1, max=1, median=1, avg=1
# - Output at position 3: window=[1,2,3], min=1, max=3, median=2, avg=2
# - Output at position 5: window=[1,2,3,4,5], min=1, max=5, median=3, avg=3
# - Output at position 7: window=[3,4,5,6,7], min=3, max=7, median=5, avg=5
# - Output at position 9: window=[5,6,7,8,9], min=5, max=9, median=7, avg=7
- sensor.template.publish:
id: source_sensor
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 2.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 3.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 4.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 5.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 6.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 7.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 8.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 9.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 10.0
button:
- platform: template
name: "Publish Values Button"
id: publish_button
on_press:
- script.execute: publish_values_script

View File

@@ -0,0 +1,72 @@
esphome:
name: test-ring-buffer-wraparound
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
sensor:
- platform: template
name: "Source Wraparound Sensor"
id: source_wraparound
accuracy_decimals: 2
- platform: copy
source_id: source_wraparound
name: "Wraparound Min Sensor"
id: wraparound_min_sensor
filters:
- min:
window_size: 3
send_every: 3
send_first_at: 1
script:
- id: publish_wraparound_script
then:
# Publish 9 values to test ring buffer wraparound
# Values: 10, 20, 30, 5, 25, 15, 40, 35, 20
- sensor.template.publish:
id: source_wraparound
state: 10.0
- delay: 20ms
- sensor.template.publish:
id: source_wraparound
state: 20.0
- delay: 20ms
- sensor.template.publish:
id: source_wraparound
state: 30.0
- delay: 20ms
- sensor.template.publish:
id: source_wraparound
state: 5.0
- delay: 20ms
- sensor.template.publish:
id: source_wraparound
state: 25.0
- delay: 20ms
- sensor.template.publish:
id: source_wraparound
state: 15.0
- delay: 20ms
- sensor.template.publish:
id: source_wraparound
state: 40.0
- delay: 20ms
- sensor.template.publish:
id: source_wraparound
state: 35.0
- delay: 20ms
- sensor.template.publish:
id: source_wraparound
state: 20.0
button:
- platform: template
name: "Publish Wraparound Button"
id: publish_wraparound_button
on_press:
- script.execute: publish_wraparound_script

View File

@@ -0,0 +1,123 @@
esphome:
name: test-sliding-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
# Min filter sensor
- platform: copy
source_id: source_sensor
name: "Min Sensor"
id: min_sensor
filters:
- min:
window_size: 5
send_every: 5
send_first_at: 1
# Max filter sensor
- platform: copy
source_id: source_sensor
name: "Max Sensor"
id: max_sensor
filters:
- max:
window_size: 5
send_every: 5
send_first_at: 1
# Median filter sensor
- platform: copy
source_id: source_sensor
name: "Median Sensor"
id: median_sensor
filters:
- median:
window_size: 5
send_every: 5
send_first_at: 1
# Quantile filter sensor (90th percentile)
- platform: copy
source_id: source_sensor
name: "Quantile Sensor"
id: quantile_sensor
filters:
- quantile:
window_size: 5
send_every: 5
send_first_at: 1
quantile: 0.9
# Moving average filter sensor
- 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
# Script to publish values with delays
script:
- id: publish_values_script
then:
- sensor.template.publish:
id: source_sensor
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 2.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 3.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 4.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 5.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 6.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 7.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 8.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 9.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor
state: 10.0
# Button to trigger publishing test values
button:
- platform: template
name: "Publish Values Button"
id: publish_button
on_press:
- script.execute: publish_values_script

View File

@@ -0,0 +1,167 @@
"""Shared utilities for ESPHome integration tests - state handling."""
from __future__ import annotations
import asyncio
import logging
from aioesphomeapi import ButtonInfo, EntityInfo, EntityState
_LOGGER = logging.getLogger(__name__)
def build_key_to_entity_mapping(
entities: list[EntityInfo], entity_names: list[str]
) -> dict[int, str]:
"""Build a mapping from entity keys to entity names.
Args:
entities: List of entity info objects from the API
entity_names: List of entity names to search for in object_ids
Returns:
Dictionary mapping entity keys to entity names
"""
key_to_entity: dict[int, str] = {}
for entity in entities:
obj_id = entity.object_id.lower()
for entity_name in entity_names:
if entity_name in obj_id:
key_to_entity[entity.key] = entity_name
break
return key_to_entity
class InitialStateHelper:
"""Helper to wait for initial states before processing test states.
When an API client connects, ESPHome sends the current state of all entities.
This helper wraps the user's state callback and swallows the first state for
each entity, then forwards all subsequent states to the user callback.
Usage:
entities, services = await client.list_entities_services()
helper = InitialStateHelper(entities)
client.subscribe_states(helper.on_state_wrapper(user_callback))
await helper.wait_for_initial_states()
"""
def __init__(self, entities: list[EntityInfo]) -> None:
"""Initialize the helper.
Args:
entities: All entities from list_entities_services()
"""
# Set of (device_id, key) tuples waiting for initial state
# Buttons are stateless, so exclude them
self._wait_initial_states = {
(entity.device_id, entity.key)
for entity in entities
if not isinstance(entity, ButtonInfo)
}
# Keep entity info for debugging - use (device_id, key) tuple
self._entities_by_id = {
(entity.device_id, entity.key): entity for entity in entities
}
# Log all entities
_LOGGER.debug(
"InitialStateHelper: Found %d total entities: %s",
len(entities),
[(type(e).__name__, e.object_id) for e in entities],
)
# Log which ones we're waiting for
_LOGGER.debug(
"InitialStateHelper: Waiting for %d entities (excluding ButtonInfo): %s",
len(self._wait_initial_states),
[self._entities_by_id[k].object_id for k in self._wait_initial_states],
)
# Log which ones we're NOT waiting for
not_waiting = {
(e.device_id, e.key) for e in entities
} - self._wait_initial_states
if not_waiting:
not_waiting_info = [
f"{type(self._entities_by_id[k]).__name__}:{self._entities_by_id[k].object_id}"
for k in not_waiting
]
_LOGGER.debug(
"InitialStateHelper: NOT waiting for %d entities: %s",
len(not_waiting),
not_waiting_info,
)
# Create future in the running event loop
self._initial_states_received = asyncio.get_running_loop().create_future()
# If no entities to wait for, mark complete immediately
if not self._wait_initial_states:
self._initial_states_received.set_result(True)
def on_state_wrapper(self, user_callback):
"""Wrap a user callback to track initial states.
Args:
user_callback: The user's state callback function
Returns:
Wrapped callback that swallows first state per entity, forwards rest
"""
def wrapper(state: EntityState) -> None:
"""Swallow initial state per entity, forward subsequent states."""
# Create entity identifier tuple
entity_id = (state.device_id, state.key)
# Log which entity is sending state
if entity_id in self._entities_by_id:
entity = self._entities_by_id[entity_id]
_LOGGER.debug(
"Received state for %s (type: %s, device_id: %s, key: %d)",
entity.object_id,
type(entity).__name__,
state.device_id,
state.key,
)
# If this entity is waiting for initial state
if entity_id in self._wait_initial_states:
# Remove from waiting set
self._wait_initial_states.discard(entity_id)
_LOGGER.debug(
"Swallowed initial state for %s, %d entities remaining",
self._entities_by_id[entity_id].object_id
if entity_id in self._entities_by_id
else entity_id,
len(self._wait_initial_states),
)
# Check if we've now seen all entities
if (
not self._wait_initial_states
and not self._initial_states_received.done()
):
_LOGGER.debug("All initial states received")
self._initial_states_received.set_result(True)
# Don't forward initial state to user
return
# Forward subsequent states to user callback
_LOGGER.debug("Forwarding state to user callback")
user_callback(state)
return wrapper
async def wait_for_initial_states(self, timeout: float = 5.0) -> None:
"""Wait for all initial states to be received.
Args:
timeout: Maximum time to wait in seconds
Raises:
asyncio.TimeoutError: If initial states aren't received within timeout
"""
await asyncio.wait_for(self._initial_states_received, timeout=timeout)

View File

@@ -8,6 +8,7 @@ import asyncio
from typing import Any
from aioesphomeapi import LightState
from aioesphomeapi.model import ColorMode
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -35,10 +36,51 @@ async def test_light_calls(
# Get the light entities
entities = await client.list_entities_services()
lights = [e for e in entities[0] if e.object_id.startswith("test_")]
assert len(lights) >= 2 # Should have RGBCW and RGB lights
assert len(lights) >= 3 # Should have RGBCW, RGB, and Binary lights
rgbcw_light = next(light for light in lights if "RGBCW" in light.name)
rgb_light = next(light for light in lights if "RGB Light" in light.name)
binary_light = next(light for light in lights if "Binary" in light.name)
# Test color mode encoding: Verify supported_color_modes contains actual ColorMode enum values
# not bit positions. This is critical - the iterator must convert bit positions to actual
# ColorMode enum values for API encoding.
# RGBCW light (rgbww platform) should support RGB_COLD_WARM_WHITE mode
assert ColorMode.RGB_COLD_WARM_WHITE in rgbcw_light.supported_color_modes, (
f"RGBCW light missing RGB_COLD_WARM_WHITE mode. Got: {rgbcw_light.supported_color_modes}"
)
# Verify it's the actual enum value, not bit position
assert ColorMode.RGB_COLD_WARM_WHITE.value in [
mode.value for mode in rgbcw_light.supported_color_modes
], (
f"RGBCW light has wrong color mode values. Expected {ColorMode.RGB_COLD_WARM_WHITE.value} "
f"(RGB_COLD_WARM_WHITE), got: {[mode.value for mode in rgbcw_light.supported_color_modes]}"
)
# RGB light should support RGB mode
assert ColorMode.RGB in rgb_light.supported_color_modes, (
f"RGB light missing RGB color mode. Got: {rgb_light.supported_color_modes}"
)
# Verify it's the actual enum value, not bit position
assert ColorMode.RGB.value in [
mode.value for mode in rgb_light.supported_color_modes
], (
f"RGB light has wrong color mode values. Expected {ColorMode.RGB.value} (RGB), got: "
f"{[mode.value for mode in rgb_light.supported_color_modes]}"
)
# Binary light (on/off only) should support ON_OFF mode
assert ColorMode.ON_OFF in binary_light.supported_color_modes, (
f"Binary light missing ON_OFF color mode. Got: {binary_light.supported_color_modes}"
)
# Verify it's the actual enum value, not bit position
assert ColorMode.ON_OFF.value in [
mode.value for mode in binary_light.supported_color_modes
], (
f"Binary light has wrong color mode values. Expected {ColorMode.ON_OFF.value} (ON_OFF), got: "
f"{[mode.value for mode in binary_light.supported_color_modes]}"
)
async def wait_for_state_change(key: int, timeout: float = 1.0) -> Any:
"""Wait for a state change for the given entity key."""

View File

@@ -0,0 +1,203 @@
"""Test ESPHome queued script functionality."""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_script_queued(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test comprehensive queued script functionality."""
loop = asyncio.get_running_loop()
# Track all test results
test_results = {
"queue_depth": {"processed": [], "rejections": 0},
"ring_buffer": {"start_order": [], "end_order": []},
"stop": {"processed": [], "stop_logged": False},
"rejection": {"processed": [], "rejections": 0},
"no_params": {"executions": 0},
}
# Patterns for Test 1: Queue depth
queue_start = re.compile(r"Queue test: START item (\d+)")
queue_end = re.compile(r"Queue test: END item (\d+)")
queue_reject = re.compile(r"Script 'queue_depth_script' max instances")
# Patterns for Test 2: Ring buffer
ring_start = re.compile(r"Ring buffer: START '([A-Z])'")
ring_end = re.compile(r"Ring buffer: END '([A-Z])'")
# Patterns for Test 3: Stop
stop_start = re.compile(r"Stop test: START (\d+)")
stop_log = re.compile(r"STOPPING script now")
# Patterns for Test 4: Rejection
reject_start = re.compile(r"Rejection test: START (\d+)")
reject_end = re.compile(r"Rejection test: END (\d+)")
reject_reject = re.compile(r"Script 'rejection_script' max instances")
# Patterns for Test 5: No params
no_params_end = re.compile(r"No params: END")
# Test completion futures
test1_complete = loop.create_future()
test2_complete = loop.create_future()
test3_complete = loop.create_future()
test4_complete = loop.create_future()
test5_complete = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for all test messages."""
# Test 1: Queue depth
if match := queue_start.search(line):
item = int(match.group(1))
if item not in test_results["queue_depth"]["processed"]:
test_results["queue_depth"]["processed"].append(item)
if match := queue_end.search(line):
item = int(match.group(1))
if item == 5 and not test1_complete.done():
test1_complete.set_result(True)
if queue_reject.search(line):
test_results["queue_depth"]["rejections"] += 1
# Test 2: Ring buffer
if match := ring_start.search(line):
msg = match.group(1)
test_results["ring_buffer"]["start_order"].append(msg)
if match := ring_end.search(line):
msg = match.group(1)
test_results["ring_buffer"]["end_order"].append(msg)
if (
len(test_results["ring_buffer"]["end_order"]) == 3
and not test2_complete.done()
):
test2_complete.set_result(True)
# Test 3: Stop
if match := stop_start.search(line):
item = int(match.group(1))
if item not in test_results["stop"]["processed"]:
test_results["stop"]["processed"].append(item)
if stop_log.search(line):
test_results["stop"]["stop_logged"] = True
# Give time for any queued items to be cleared
if not test3_complete.done():
loop.call_later(
0.3,
lambda: test3_complete.set_result(True)
if not test3_complete.done()
else None,
)
# Test 4: Rejection
if match := reject_start.search(line):
item = int(match.group(1))
if item not in test_results["rejection"]["processed"]:
test_results["rejection"]["processed"].append(item)
if match := reject_end.search(line):
item = int(match.group(1))
if item == 3 and not test4_complete.done():
test4_complete.set_result(True)
if reject_reject.search(line):
test_results["rejection"]["rejections"] += 1
# Test 5: No params
if no_params_end.search(line):
test_results["no_params"]["executions"] += 1
if (
test_results["no_params"]["executions"] == 3
and not test5_complete.done()
):
test5_complete.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Get services
_, services = await client.list_entities_services()
# Test 1: Queue depth limit
test_service = next((s for s in services if s.name == "test_queue_depth"), None)
assert test_service is not None, "test_queue_depth service not found"
client.execute_service(test_service, {})
await asyncio.wait_for(test1_complete, timeout=2.0)
await asyncio.sleep(0.1) # Give time for rejections
# Verify Test 1
assert sorted(test_results["queue_depth"]["processed"]) == [1, 2, 3, 4, 5], (
f"Test 1: Expected to process items 1-5 (max_runs=5 means 5 total), got {sorted(test_results['queue_depth']['processed'])}"
)
assert test_results["queue_depth"]["rejections"] >= 2, (
"Test 1: Expected at least 2 rejection warnings (items 6-7 should be rejected)"
)
# Test 2: Ring buffer order
test_service = next((s for s in services if s.name == "test_ring_buffer"), None)
assert test_service is not None, "test_ring_buffer service not found"
client.execute_service(test_service, {})
await asyncio.wait_for(test2_complete, timeout=2.0)
# Verify Test 2
assert test_results["ring_buffer"]["start_order"] == ["A", "B", "C"], (
f"Test 2: Expected start order [A, B, C], got {test_results['ring_buffer']['start_order']}"
)
assert test_results["ring_buffer"]["end_order"] == ["A", "B", "C"], (
f"Test 2: Expected end order [A, B, C], got {test_results['ring_buffer']['end_order']}"
)
# Test 3: Stop clears queue
test_service = next((s for s in services if s.name == "test_stop_clears"), None)
assert test_service is not None, "test_stop_clears service not found"
client.execute_service(test_service, {})
await asyncio.wait_for(test3_complete, timeout=2.0)
# Verify Test 3
assert test_results["stop"]["stop_logged"], (
"Test 3: Stop command was not logged"
)
assert test_results["stop"]["processed"] == [1], (
f"Test 3: Expected only item 1 to process, got {test_results['stop']['processed']}"
)
# Test 4: Rejection enforcement (max_runs=3)
test_service = next((s for s in services if s.name == "test_rejection"), None)
assert test_service is not None, "test_rejection service not found"
client.execute_service(test_service, {})
await asyncio.wait_for(test4_complete, timeout=2.0)
await asyncio.sleep(0.1) # Give time for rejections
# Verify Test 4
assert sorted(test_results["rejection"]["processed"]) == [1, 2, 3], (
f"Test 4: Expected to process items 1-3 (max_runs=3 means 3 total), got {sorted(test_results['rejection']['processed'])}"
)
assert test_results["rejection"]["rejections"] == 5, (
f"Test 4: Expected 5 rejections (items 4-8), got {test_results['rejection']['rejections']}"
)
# Test 5: No parameters
test_service = next((s for s in services if s.name == "test_no_params"), None)
assert test_service is not None, "test_no_params service not found"
client.execute_service(test_service, {})
await asyncio.wait_for(test5_complete, timeout=2.0)
# Verify Test 5
assert test_results["no_params"]["executions"] == 3, (
f"Test 5: Expected 3 executions, got {test_results['no_params']['executions']}"
)

View File

@@ -0,0 +1,151 @@
"""Test sensor ring buffer filter functionality (window_size != send_every)."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState, SensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_sensor_filters_ring_buffer(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that ring buffer filters (window_size != send_every) work correctly."""
loop = asyncio.get_running_loop()
# Track state changes for each sensor
sensor_states: dict[str, list[float]] = {
"sliding_min": [],
"sliding_max": [],
"sliding_median": [],
"sliding_moving_avg": [],
}
# Futures to track when we receive expected values
all_updates_received = loop.create_future()
def on_state(state: EntityState) -> None:
"""Track sensor state updates."""
if not isinstance(state, SensorState):
return
# Skip NaN values
if state.missing_state:
return
# Get the sensor name from the key mapping
sensor_name = key_to_sensor.get(state.key)
if not sensor_name or sensor_name not in sensor_states:
return
sensor_states[sensor_name].append(state.state)
# Check if we've received enough updates from all sensors
# With send_every=2, send_first_at=1, we expect 5 outputs per sensor
if (
len(sensor_states["sliding_min"]) >= 5
and len(sensor_states["sliding_max"]) >= 5
and len(sensor_states["sliding_median"]) >= 5
and len(sensor_states["sliding_moving_avg"]) >= 5
and not all_updates_received.done()
):
all_updates_received.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# Get entities first to build key mapping
entities, services = await client.list_entities_services()
# Build key-to-sensor mapping
key_to_sensor = build_key_to_entity_mapping(
entities,
[
"sliding_min",
"sliding_max",
"sliding_median",
"sliding_moving_avg",
],
)
# Set up initial state helper with all entities
initial_state_helper = InitialStateHelper(entities)
# Subscribe to state changes with wrapper
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for initial states to be sent before pressing button
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Find the publish button
publish_button = next(
(e for e in entities if "publish_values_button" in e.object_id.lower()),
None,
)
assert publish_button is not None, "Publish Values Button not found"
# Press the button to publish test values
client.button_command(publish_button.key)
# Wait for all sensors to receive their values
try:
await asyncio.wait_for(all_updates_received, timeout=10.0)
except TimeoutError:
# Provide detailed failure info
pytest.fail(
f"Timeout waiting for updates. Received states:\n"
f" min: {sensor_states['sliding_min']}\n"
f" max: {sensor_states['sliding_max']}\n"
f" median: {sensor_states['sliding_median']}\n"
f" moving_avg: {sensor_states['sliding_moving_avg']}"
)
# Verify we got 5 outputs per sensor (positions 1, 3, 5, 7, 9)
assert len(sensor_states["sliding_min"]) == 5, (
f"Min sensor should have 5 values, got {len(sensor_states['sliding_min'])}: {sensor_states['sliding_min']}"
)
assert len(sensor_states["sliding_max"]) == 5
assert len(sensor_states["sliding_median"]) == 5
assert len(sensor_states["sliding_moving_avg"]) == 5
# Verify the values at each output position
# Position 1: window=[1]
assert sensor_states["sliding_min"][0] == pytest.approx(1.0)
assert sensor_states["sliding_max"][0] == pytest.approx(1.0)
assert sensor_states["sliding_median"][0] == pytest.approx(1.0)
assert sensor_states["sliding_moving_avg"][0] == pytest.approx(1.0)
# Position 3: window=[1,2,3]
assert sensor_states["sliding_min"][1] == pytest.approx(1.0)
assert sensor_states["sliding_max"][1] == pytest.approx(3.0)
assert sensor_states["sliding_median"][1] == pytest.approx(2.0)
assert sensor_states["sliding_moving_avg"][1] == pytest.approx(2.0)
# Position 5: window=[1,2,3,4,5]
assert sensor_states["sliding_min"][2] == pytest.approx(1.0)
assert sensor_states["sliding_max"][2] == pytest.approx(5.0)
assert sensor_states["sliding_median"][2] == pytest.approx(3.0)
assert sensor_states["sliding_moving_avg"][2] == pytest.approx(3.0)
# Position 7: window=[3,4,5,6,7] (ring buffer wrapped)
assert sensor_states["sliding_min"][3] == pytest.approx(3.0)
assert sensor_states["sliding_max"][3] == pytest.approx(7.0)
assert sensor_states["sliding_median"][3] == pytest.approx(5.0)
assert sensor_states["sliding_moving_avg"][3] == pytest.approx(5.0)
# Position 9: window=[5,6,7,8,9] (ring buffer wrapped)
assert sensor_states["sliding_min"][4] == pytest.approx(5.0)
assert sensor_states["sliding_max"][4] == pytest.approx(9.0)
assert sensor_states["sliding_median"][4] == pytest.approx(7.0)
assert sensor_states["sliding_moving_avg"][4] == pytest.approx(7.0)

View File

@@ -0,0 +1,395 @@
"""Test sensor sliding window filter functionality."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState, SensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_sensor_filters_sliding_window(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that sliding window filters (min, max, median, quantile, moving_average) work correctly."""
loop = asyncio.get_running_loop()
# Track state changes for each sensor
sensor_states: dict[str, list[float]] = {
"min_sensor": [],
"max_sensor": [],
"median_sensor": [],
"quantile_sensor": [],
"moving_avg_sensor": [],
}
# Futures to track when we receive expected values
min_received = loop.create_future()
max_received = loop.create_future()
median_received = loop.create_future()
quantile_received = loop.create_future()
moving_avg_received = loop.create_future()
def on_state(state: EntityState) -> None:
"""Track sensor state updates."""
if not isinstance(state, SensorState):
return
# Skip NaN values
if state.missing_state:
return
# Get the sensor name from the key mapping
sensor_name = key_to_sensor.get(state.key)
if not sensor_name or sensor_name not in sensor_states:
return
sensor_states[sensor_name].append(state.state)
# Check if we received the expected final value
# After publishing 10 values [1.0, 2.0, ..., 10.0], the window has the last 5: [2, 3, 4, 5, 6]
# Filters send at position 1 and position 6 (send_every=5 means every 5th value after first)
if (
sensor_name == "min_sensor"
and state.state == pytest.approx(2.0)
and not min_received.done()
):
min_received.set_result(True)
elif (
sensor_name == "max_sensor"
and state.state == pytest.approx(6.0)
and not max_received.done()
):
max_received.set_result(True)
elif (
sensor_name == "median_sensor"
and state.state == pytest.approx(4.0)
and not median_received.done()
):
# Median of [2, 3, 4, 5, 6] = 4
median_received.set_result(True)
elif (
sensor_name == "quantile_sensor"
and state.state == pytest.approx(6.0)
and not quantile_received.done()
):
# 90th percentile of [2, 3, 4, 5, 6] = 6
quantile_received.set_result(True)
elif (
sensor_name == "moving_avg_sensor"
and state.state == pytest.approx(4.0)
and not moving_avg_received.done()
):
# Average of [2, 3, 4, 5, 6] = 4
moving_avg_received.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# Get entities first to build key mapping
entities, services = await client.list_entities_services()
# Build key-to-sensor mapping
key_to_sensor = build_key_to_entity_mapping(
entities,
[
"min_sensor",
"max_sensor",
"median_sensor",
"quantile_sensor",
"moving_avg_sensor",
],
)
# Set up initial state helper with all entities
initial_state_helper = InitialStateHelper(entities)
# Subscribe to state changes with wrapper
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for initial states to be sent before pressing button
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Find the publish button
publish_button = next(
(e for e in entities if "publish_values_button" in e.object_id.lower()),
None,
)
assert publish_button is not None, "Publish Values Button not found"
# Press the button to publish test values
client.button_command(publish_button.key)
# Wait for all sensors to receive their final values
try:
await asyncio.wait_for(
asyncio.gather(
min_received,
max_received,
median_received,
quantile_received,
moving_avg_received,
),
timeout=10.0,
)
except TimeoutError:
# Provide detailed failure info
pytest.fail(
f"Timeout waiting for expected values. Received states:\n"
f" min: {sensor_states['min_sensor']}\n"
f" max: {sensor_states['max_sensor']}\n"
f" median: {sensor_states['median_sensor']}\n"
f" quantile: {sensor_states['quantile_sensor']}\n"
f" moving_avg: {sensor_states['moving_avg_sensor']}"
)
# Verify we got the expected values
# With batch_delay: 0ms, we should receive all outputs
# Filters output at positions 1 and 6 (send_every: 5)
assert len(sensor_states["min_sensor"]) == 2, (
f"Min sensor should have 2 values, got {len(sensor_states['min_sensor'])}: {sensor_states['min_sensor']}"
)
assert len(sensor_states["max_sensor"]) == 2, (
f"Max sensor should have 2 values, got {len(sensor_states['max_sensor'])}: {sensor_states['max_sensor']}"
)
assert len(sensor_states["median_sensor"]) == 2
assert len(sensor_states["quantile_sensor"]) == 2
assert len(sensor_states["moving_avg_sensor"]) == 2
# Verify the first output (after 1 value: [1])
assert sensor_states["min_sensor"][0] == pytest.approx(1.0), (
f"First min should be 1.0, got {sensor_states['min_sensor'][0]}"
)
assert sensor_states["max_sensor"][0] == pytest.approx(1.0), (
f"First max should be 1.0, got {sensor_states['max_sensor'][0]}"
)
assert sensor_states["median_sensor"][0] == pytest.approx(1.0), (
f"First median should be 1.0, got {sensor_states['median_sensor'][0]}"
)
assert sensor_states["moving_avg_sensor"][0] == pytest.approx(1.0), (
f"First moving avg should be 1.0, got {sensor_states['moving_avg_sensor'][0]}"
)
# Verify the second output (after 6 values, window has [2, 3, 4, 5, 6])
assert sensor_states["min_sensor"][1] == pytest.approx(2.0), (
f"Second min should be 2.0, got {sensor_states['min_sensor'][1]}"
)
assert sensor_states["max_sensor"][1] == pytest.approx(6.0), (
f"Second max should be 6.0, got {sensor_states['max_sensor'][1]}"
)
assert sensor_states["median_sensor"][1] == pytest.approx(4.0), (
f"Second median should be 4.0, got {sensor_states['median_sensor'][1]}"
)
assert sensor_states["moving_avg_sensor"][1] == pytest.approx(4.0), (
f"Second moving avg should be 4.0, got {sensor_states['moving_avg_sensor'][1]}"
)
@pytest.mark.asyncio
async def test_sensor_filters_nan_handling(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that sliding window filters handle NaN values correctly."""
loop = asyncio.get_running_loop()
# Track states
min_states: list[float] = []
max_states: list[float] = []
# Future to track completion
filters_completed = loop.create_future()
def on_state(state: EntityState) -> None:
"""Track sensor state updates."""
if not isinstance(state, SensorState):
return
# Skip NaN values
if state.missing_state:
return
sensor_name = key_to_sensor.get(state.key)
if sensor_name == "min_nan":
min_states.append(state.state)
elif sensor_name == "max_nan":
max_states.append(state.state)
# Check if both have received their final values
# With batch_delay: 0ms, we should receive 2 outputs each
if (
len(min_states) >= 2
and len(max_states) >= 2
and not filters_completed.done()
):
filters_completed.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# Get entities first to build key mapping
entities, services = await client.list_entities_services()
# Build key-to-sensor mapping
key_to_sensor = build_key_to_entity_mapping(entities, ["min_nan", "max_nan"])
# Set up initial state helper with all entities
initial_state_helper = InitialStateHelper(entities)
# Subscribe to state changes with wrapper
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for initial states
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Find the publish button
publish_button = next(
(e for e in entities if "publish_nan_values_button" in e.object_id.lower()),
None,
)
assert publish_button is not None, "Publish NaN Values Button not found"
# Press the button
client.button_command(publish_button.key)
# Wait for filters to process
try:
await asyncio.wait_for(filters_completed, timeout=10.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for NaN handling. Received:\n"
f" min_states: {min_states}\n"
f" max_states: {max_states}"
)
# Verify NaN values were ignored
# With batch_delay: 0ms, we should receive both outputs (at positions 1 and 6)
# Position 1: window=[10], min=10, max=10
# Position 6: window=[NaN, 5, NaN, 15, 8], ignoring NaN -> [5, 15, 8], min=5, max=15
assert len(min_states) == 2, (
f"Should have 2 min states, got {len(min_states)}: {min_states}"
)
assert len(max_states) == 2, (
f"Should have 2 max states, got {len(max_states)}: {max_states}"
)
# First output
assert min_states[0] == pytest.approx(10.0), (
f"First min should be 10.0, got {min_states[0]}"
)
assert max_states[0] == pytest.approx(10.0), (
f"First max should be 10.0, got {max_states[0]}"
)
# Second output - verify NaN values were ignored
assert min_states[1] == pytest.approx(5.0), (
f"Second min should ignore NaN and return 5.0, got {min_states[1]}"
)
assert max_states[1] == pytest.approx(15.0), (
f"Second max should ignore NaN and return 15.0, got {max_states[1]}"
)
@pytest.mark.asyncio
async def test_sensor_filters_ring_buffer_wraparound(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that ring buffer correctly wraps around when window fills up."""
loop = asyncio.get_running_loop()
min_states: list[float] = []
test_completed = loop.create_future()
def on_state(state: EntityState) -> None:
"""Track min sensor states."""
if not isinstance(state, SensorState):
return
# Skip NaN values
if state.missing_state:
return
sensor_name = key_to_sensor.get(state.key)
if sensor_name == "wraparound_min":
min_states.append(state.state)
# With batch_delay: 0ms, we should receive all 3 outputs
if len(min_states) >= 3 and not test_completed.done():
test_completed.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# Get entities first to build key mapping
entities, services = await client.list_entities_services()
# Build key-to-sensor mapping
key_to_sensor = build_key_to_entity_mapping(entities, ["wraparound_min"])
# Set up initial state helper with all entities
initial_state_helper = InitialStateHelper(entities)
# Subscribe to state changes with wrapper
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for initial state
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial state")
# Find the publish button
publish_button = next(
(e for e in entities if "publish_wraparound_button" in e.object_id.lower()),
None,
)
assert publish_button is not None, "Publish Wraparound Button not found"
# Press the button
# Will publish: 10, 20, 30, 5, 25, 15, 40, 35, 20
client.button_command(publish_button.key)
# Wait for completion
try:
await asyncio.wait_for(test_completed, timeout=10.0)
except TimeoutError:
pytest.fail(f"Timeout waiting for wraparound test. Received: {min_states}")
# Verify outputs
# With window_size=3, send_every=3, we get outputs at positions 1, 4, 7
# Position 1: window=[10], min=10
# Position 4: window=[20, 30, 5], min=5
# Position 7: window=[15, 40, 35], min=15
# With batch_delay: 0ms, we should receive all 3 outputs
assert len(min_states) == 3, (
f"Should have 3 states, got {len(min_states)}: {min_states}"
)
assert min_states[0] == pytest.approx(10.0), (
f"First min should be 10.0, got {min_states[0]}"
)
assert min_states[1] == pytest.approx(5.0), (
f"Second min should be 5.0, got {min_states[1]}"
)
assert min_states[2] == pytest.approx(15.0), (
f"Third min should be 15.0, got {min_states[2]}"
)

View File

@@ -107,6 +107,7 @@ def test_main_all_tests_should_run(
assert output["integration_tests"] is True
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is True
assert output["python_linters"] is True
assert output["changed_components"] == ["wifi", "api", "sensor"]
@@ -117,6 +118,9 @@ def test_main_all_tests_should_run(
assert output["component_test_count"] == len(
output["changed_components_with_tests"]
)
# changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false" # No files changed
@@ -156,11 +160,14 @@ def test_main_no_tests_should_run(
assert output["integration_tests"] is False
assert output["clang_tidy"] is False
assert output["clang_tidy_mode"] == "disabled"
assert output["clang_format"] is False
assert output["python_linters"] is False
assert output["changed_components"] == []
assert output["changed_components_with_tests"] == []
assert output["component_test_count"] == 0
# changed_cpp_file_count should be 0
assert output["changed_cpp_file_count"] == 0
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
@@ -239,6 +246,7 @@ def test_main_with_branch_argument(
assert output["integration_tests"] is False
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is False
assert output["python_linters"] is True
assert output["changed_components"] == ["mqtt"]
@@ -249,6 +257,9 @@ def test_main_with_branch_argument(
assert output["component_test_count"] == len(
output["changed_components_with_tests"]
)
# changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
@@ -433,6 +444,40 @@ def test_should_run_clang_format_with_branch() -> None:
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize(
("changed_files", "expected_count"),
[
(["esphome/core.cpp"], 1),
(["esphome/core.h"], 1),
(["test.hpp"], 1),
(["test.cc"], 1),
(["test.cxx"], 1),
(["test.c"], 1),
(["test.tcc"], 1),
(["esphome/core.cpp", "esphome/core.h"], 2),
(["esphome/core.cpp", "esphome/core.h", "test.cc"], 3),
(["README.md"], 0),
(["esphome/config.py"], 0),
(["README.md", "esphome/config.py"], 0),
(["esphome/core.cpp", "README.md", "esphome/config.py"], 1),
([], 0),
],
)
def test_count_changed_cpp_files(changed_files: list[str], expected_count: int) -> None:
"""Test count_changed_cpp_files function."""
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
result = determine_jobs.count_changed_cpp_files()
assert result == expected_count
def test_count_changed_cpp_files_with_branch() -> None:
"""Test count_changed_cpp_files with branch argument."""
with patch.object(determine_jobs, "changed_files") as mock_changed:
mock_changed.return_value = []
determine_jobs.count_changed_cpp_files("release")
mock_changed.assert_called_once_with("release")
def test_main_filters_components_without_tests(
mock_should_run_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
@@ -501,6 +546,9 @@ def test_main_filters_components_without_tests(
assert set(output["changed_components_with_tests"]) == {"wifi", "sensor"}
# component_test_count should be based on components with tests
assert output["component_test_count"] == 2
# changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"

View File

@@ -0,0 +1,9 @@
substitutions:
A: component1
B: component2
C: component3
some_component:
- id: component1
value: 2
- id: component2
value: 5

View File

@@ -0,0 +1,22 @@
substitutions:
A: component1
B: component2
C: component3
packages:
- some_component:
- id: component1
value: 1
- id: !extend ${B}
value: 4
- id: !extend ${B}
value: 5
- id: component3
value: 6
some_component:
- id: !extend ${A}
value: 2
- id: component2
value: 3
- id: !remove ${C}

View File

@@ -570,6 +570,13 @@ class TestEsphomeCore:
assert target.address == "4.3.2.1"
def test_address__openthread(self, target):
target.name = "test-device"
target.config = {}
target.config[const.CONF_OPENTHREAD] = {}
assert target.address == "test-device.local"
def test_is_esp32(self, target):
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}

View File

@@ -321,12 +321,14 @@ def test_choose_upload_log_host_with_serial_device_no_ports(
) -> None:
"""Test SERIAL device when no serial ports are found."""
setup_core()
result = choose_upload_log_host(
default="SERIAL",
check_default=None,
purpose=Purpose.UPLOADING,
)
assert result == []
with pytest.raises(
EsphomeError, match="All specified devices .* could not be resolved"
):
choose_upload_log_host(
default="SERIAL",
check_default=None,
purpose=Purpose.UPLOADING,
)
assert "No serial ports found, skipping SERIAL device" in caplog.text
@@ -367,12 +369,14 @@ def test_choose_upload_log_host_with_ota_device_with_api_config() -> None:
"""Test OTA device when API is configured (no upload without OTA in config)."""
setup_core(config={CONF_API: {}}, address="192.168.1.100")
result = choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.UPLOADING,
)
assert result == []
with pytest.raises(
EsphomeError, match="All specified devices .* could not be resolved"
):
choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.UPLOADING,
)
def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> None:
@@ -405,12 +409,14 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None:
"""Test OTA device with no valid fallback options."""
setup_core()
result = choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.UPLOADING,
)
assert result == []
with pytest.raises(
EsphomeError, match="All specified devices .* could not be resolved"
):
choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.UPLOADING,
)
@pytest.mark.usefixtures("mock_choose_prompt")
@@ -615,21 +621,19 @@ def test_choose_upload_log_host_empty_defaults_list() -> None:
@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging")
def test_choose_upload_log_host_all_devices_unresolved(
caplog: pytest.LogCaptureFixture,
) -> None:
def test_choose_upload_log_host_all_devices_unresolved() -> None:
"""Test when all specified devices cannot be resolved."""
setup_core()
result = choose_upload_log_host(
default=["SERIAL", "OTA"],
check_default=None,
purpose=Purpose.UPLOADING,
)
assert result == []
assert (
"All specified devices: ['SERIAL', 'OTA'] could not be resolved." in caplog.text
)
with pytest.raises(
EsphomeError,
match=r"All specified devices \['SERIAL', 'OTA'\] could not be resolved",
):
choose_upload_log_host(
default=["SERIAL", "OTA"],
check_default=None,
purpose=Purpose.UPLOADING,
)
@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging")
@@ -762,12 +766,14 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None:
"""Test OTA device when OTA is configured but no address is set."""
setup_core(config={CONF_OTA: {}})
result = choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.UPLOADING,
)
assert result == []
with pytest.raises(
EsphomeError, match="All specified devices .* could not be resolved"
):
choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.UPLOADING,
)
@dataclass

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from esphome import config as config_module, yaml_util
from esphome.components import substitutions
from esphome.config import resolve_extend_remove
from esphome.config_helpers import merge_config
from esphome.const import CONF_PACKAGES, CONF_SUBSTITUTIONS
from esphome.core import CORE
@@ -81,6 +82,8 @@ def test_substitutions_fixtures(fixture_path):
substitutions.do_substitution_pass(config, None)
resolve_extend_remove(config)
# Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE
if expected_path.is_file():
expected = yaml_util.load_yaml(expected_path)