1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-28 21:53:48 +00:00

Merge branch 'integration' of https://github.com/esphome/esphome into integration

This commit is contained in:
J. Nick Koston
2025-10-22 16:14:35 -10:00
543 changed files with 18741 additions and 3792 deletions

View File

@@ -1 +1 @@
d7693a1e996cacd4a3d1c9a16336799c2a8cc3db02e4e74084151ce964581248 3d46b63015d761c85ca9cb77ab79a389509e5776701fb22aed16e7b79d432c0c

View File

@@ -1,4 +1,5 @@
[run] [run]
omit = omit =
esphome/components/* esphome/components/*
esphome/analyze_memory/*
tests/integration/* tests/integration/*

View File

@@ -53,6 +53,7 @@ jobs:
'new-target-platform', 'new-target-platform',
'merging-to-release', 'merging-to-release',
'merging-to-beta', 'merging-to-beta',
'chained-pr',
'core', 'core',
'small-pr', 'small-pr',
'dashboard', 'dashboard',
@@ -140,6 +141,8 @@ jobs:
labels.add('merging-to-release'); labels.add('merging-to-release');
} else if (baseRef === 'beta') { } else if (baseRef === 'beta') {
labels.add('merging-to-beta'); labels.add('merging-to-beta');
} else if (baseRef !== 'dev') {
labels.add('chained-pr');
} }
return labels; return labels;
@@ -528,8 +531,8 @@ jobs:
const apiData = await fetchApiData(); const apiData = await fetchApiData();
const baseRef = context.payload.pull_request.base.ref; const baseRef = context.payload.pull_request.base.ref;
// Early exit for non-dev branches // Early exit for release and beta branches only
if (baseRef !== 'dev') { if (baseRef === 'release' || baseRef === 'beta') {
const branchLabels = await detectMergeBranch(); const branchLabels = await detectMergeBranch();
const finalLabels = Array.from(branchLabels); const finalLabels = Array.from(branchLabels);

View File

@@ -0,0 +1,111 @@
---
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
# Use paginate to handle repos with many open PRs
head_sha="${{ github.event.workflow_run.head_sha }}"
pr_data=$(gh api --paginate "/repos/${{ github.repository }}/pulls" \
--jq ".[] | select(.head.sha == \"$head_sha\") | {number: .number, base_ref: .base.ref}" \
| head -n 1)
if [ -z "$pr_data" ]; 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,16 @@ jobs:
outputs: outputs:
integration-tests: ${{ steps.determine.outputs.integration-tests }} integration-tests: ${{ steps.determine.outputs.integration-tests }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
python-linters: ${{ steps.determine.outputs.python-linters }} python-linters: ${{ steps.determine.outputs.python-linters }}
changed-components: ${{ steps.determine.outputs.changed-components }} changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-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 }} 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 }}
cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }}
cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -199,11 +204,16 @@ jobs:
# Extract individual fields # Extract individual fields
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT 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=$(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 "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $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 "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 "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 "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
echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
integration-tests: integration-tests:
name: Run integration tests name: Run integration tests
@@ -241,7 +251,34 @@ jobs:
. venv/bin/activate . venv/bin/activate
pytest -vv --no-cov --tb=native -n auto tests/integration/ pytest -vv --no-cov --tb=native -n auto tests/integration/
clang-tidy: cpp-unit-tests:
name: Run C++ unit tests
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
steps:
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run cpp_unit_test.py
run: |
. venv/bin/activate
if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then
script/cpp_unit_test.py --all
else
ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs)
script/cpp_unit_test.py $ARGS
fi
clang-tidy-single:
name: ${{ matrix.name }} name: ${{ matrix.name }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
@@ -259,22 +296,6 @@ jobs:
name: Run script/clang-tidy for ESP8266 name: Run script/clang-tidy for ESP8266
options: --environment esp8266-arduino-tidy --grep USE_ESP8266 options: --environment esp8266-arduino-tidy --grep USE_ESP8266
pio_cache_key: tidyesp8266 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 - id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF name: Run script/clang-tidy for ESP32 IDF
options: --environment esp32-idf-tidy --grep USE_ESP_IDF options: --environment esp32-idf-tidy --grep USE_ESP_IDF
@@ -355,6 +376,166 @@ jobs:
# yamllint disable-line rule:line-length # yamllint disable-line rule:line-length
if: always() 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: test-build-components-splitter:
name: Split components for intelligent grouping (40 weighted per batch) name: Split components for intelligent grouping (40 weighted per batch)
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -379,7 +560,16 @@ jobs:
# Use intelligent splitter that groups components with same bus configs # Use intelligent splitter that groups components with same bus configs
components='${{ needs.determine-jobs.outputs.changed-components-with-tests }}' components='${{ needs.determine-jobs.outputs.changed-components-with-tests }}'
directly_changed='${{ needs.determine-jobs.outputs.directly-changed-components-with-tests }}'
# Only isolate directly changed components when targeting dev branch
# For beta/release branches, group everything for faster CI
if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then
directly_changed='[]'
echo "Target branch: ${{ github.base_ref }} - grouping all components"
else
directly_changed='${{ needs.determine-jobs.outputs.directly-changed-components-with-tests }}'
echo "Target branch: ${{ github.base_ref }} - isolating directly changed components"
fi
echo "Splitting components intelligently..." echo "Splitting components intelligently..."
output=$(python3 script/split_components_for_ci.py --components "$components" --directly-changed "$directly_changed" --batch-size 40 --output github) output=$(python3 script/split_components_for_ci.py --components "$components" --directly-changed "$directly_changed" --batch-size 40 --output github)
@@ -396,7 +586,7 @@ jobs:
if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: ${{ (github.base_ref == 'beta' || github.base_ref == 'release') && 8 || 4 }} max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }}
matrix: matrix:
components: ${{ fromJson(needs.test-build-components-splitter.outputs.matrix) }} components: ${{ fromJson(needs.test-build-components-splitter.outputs.matrix) }}
steps: steps:
@@ -424,18 +614,31 @@ jobs:
- name: Validate and compile components with intelligent grouping - name: Validate and compile components with intelligent grouping
run: | run: |
. venv/bin/activate . venv/bin/activate
# Use /mnt for build files (70GB available vs ~29GB on /)
# Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there)
sudo mkdir -p /mnt/platformio
sudo chown $USER:$USER /mnt/platformio
mkdir -p ~/.platformio
sudo mount --bind /mnt/platformio ~/.platformio
# Bind mount test build directory to /mnt # Check if /mnt has more free space than / before bind mounting
sudo mkdir -p /mnt/test_build_components_build # Extract available space in KB for comparison
sudo chown $USER:$USER /mnt/test_build_components_build root_avail=$(df -k / | awk 'NR==2 {print $4}')
mkdir -p tests/test_build_components/build mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}')
sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build
echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB"
# Only use /mnt if it has more space than /
if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then
echo "Using /mnt for build files (more space available)"
# Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there)
sudo mkdir -p /mnt/platformio
sudo chown $USER:$USER /mnt/platformio
mkdir -p ~/.platformio
sudo mount --bind /mnt/platformio ~/.platformio
# Bind mount test build directory to /mnt
sudo mkdir -p /mnt/test_build_components_build
sudo chown $USER:$USER /mnt/test_build_components_build
mkdir -p tests/test_build_components/build
sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build
else
echo "Using / for build files (more space available than /mnt or /mnt unavailable)"
fi
# Convert space-separated components to comma-separated for Python script # Convert space-separated components to comma-separated for Python script
components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',') components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',')
@@ -448,7 +651,7 @@ jobs:
# - This catches pin conflicts and other issues in directly changed code # - This catches pin conflicts and other issues in directly changed code
# - Grouped tests use --testing-mode to allow config merging (disables some checks) # - Grouped tests use --testing-mode to allow config merging (disables some checks)
# - Dependencies are safe to group since they weren't modified in this PR # - Dependencies are safe to group since they weren't modified in this PR
if [ "${{ github.base_ref }}" = "beta" ] || [ "${{ github.base_ref }}" = "release" ]; then if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then
directly_changed_csv="" directly_changed_csv=""
echo "Testing components: $components_csv" echo "Testing components: $components_csv"
echo "Target branch: ${{ github.base_ref }} - grouping all components" echo "Target branch: ${{ github.base_ref }} - grouping all components"
@@ -459,6 +662,11 @@ jobs:
fi fi
echo "" echo ""
# Show disk space before validation (after bind mounts setup)
echo "Disk space before config validation:"
df -h
echo ""
# Run config validation with grouping and isolation # Run config validation with grouping and isolation
python3 script/test_build_components.py -e config -c "$components_csv" -f --isolate "$directly_changed_csv" python3 script/test_build_components.py -e config -c "$components_csv" -f --isolate "$directly_changed_csv"
@@ -466,6 +674,11 @@ jobs:
echo "Config validation passed! Starting compilation..." echo "Config validation passed! Starting compilation..."
echo "" echo ""
# Show disk space before compilation
echo "Disk space before compilation:"
df -h
echo ""
# Run compilation with grouping and isolation # Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv" python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv"
@@ -474,7 +687,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- common - common
if: github.event_name == 'pull_request' && github.base_ref != 'beta' && github.base_ref != 'release' if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -489,6 +702,271 @@ jobs:
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always() if: always()
memory-impact-target-branch:
name: Build target branch for memory impact
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.memory_impact).should_run == 'true'
outputs:
ram_usage: ${{ steps.extract.outputs.ram_usage }}
flash_usage: ${{ steps.extract.outputs.flash_usage }}
cache_hit: ${{ steps.cache-memory-analysis.outputs.cache-hit }}
skip: ${{ steps.check-script.outputs.skip }}
steps:
- name: Check out target branch
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.base_ref }}
# Check if memory impact extraction script exists on target branch
# If not, skip the analysis (this handles older branches that don't have the feature)
- name: Check for memory impact script
id: check-script
run: |
if [ -f "script/ci_memory_impact_extract.py" ]; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
echo "::warning::ci_memory_impact_extract.py not found on target branch, skipping memory impact analysis"
fi
# All remaining steps only run if script exists
- name: Generate cache key
id: cache-key
if: steps.check-script.outputs.skip != 'true'
run: |
# Get the commit SHA of the target branch
target_sha=$(git rev-parse HEAD)
# Hash the build infrastructure files (all files that affect build/analysis)
infra_hash=$(cat \
script/test_build_components.py \
script/ci_memory_impact_extract.py \
script/analyze_component_buses.py \
script/merge_component_configs.py \
script/ci_helpers.py \
.github/workflows/ci.yml \
| sha256sum | cut -d' ' -f1)
# Get platform and components from job inputs
platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}"
components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}'
components_hash=$(echo "$components" | sha256sum | cut -d' ' -f1)
# Combine into cache key
cache_key="memory-analysis-target-${target_sha}-${infra_hash}-${platform}-${components_hash}"
echo "cache-key=${cache_key}" >> $GITHUB_OUTPUT
echo "Cache key: ${cache_key}"
- name: Restore cached memory analysis
id: cache-memory-analysis
if: steps.check-script.outputs.skip != 'true'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
- name: Cache status
if: steps.check-script.outputs.skip != 'true'
run: |
if [ "${{ steps.cache-memory-analysis.outputs.cache-hit }}" == "true" ]; then
echo "✓ Cache hit! Using cached memory analysis results."
echo " Skipping build step to save time."
else
echo "✗ Cache miss. Will build and analyze memory usage."
fi
- name: Restore Python
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
- name: Build, compile, and analyze memory
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
id: build
run: |
. venv/bin/activate
components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}'
platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}"
echo "Building with test_build_components.py for $platform with components:"
echo "$components" | jq -r '.[]' | sed 's/^/ - /'
# Use test_build_components.py which handles grouping automatically
# Pass components as comma-separated list
component_list=$(echo "$components" | jq -r 'join(",")')
echo "Compiling with test_build_components.py..."
# Run build and extract memory with auto-detection of build directory for detailed analysis
# Use tee to show output in CI while also piping to extraction script
python script/test_build_components.py \
-e compile \
-c "$component_list" \
-t "$platform" 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--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
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
- name: Extract memory usage for outputs
id: extract
if: steps.check-script.outputs.skip != 'true'
run: |
if [ -f memory-analysis-target.json ]; then
ram=$(jq -r '.ram_bytes' memory-analysis-target.json)
flash=$(jq -r '.flash_bytes' memory-analysis-target.json)
echo "ram_usage=${ram}" >> $GITHUB_OUTPUT
echo "flash_usage=${flash}" >> $GITHUB_OUTPUT
echo "RAM: ${ram} bytes, Flash: ${flash} bytes"
else
echo "Error: memory-analysis-target.json not found"
exit 1
fi
- name: Upload memory analysis JSON
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: memory-analysis-target
path: memory-analysis-target.json
if-no-files-found: warn
retention-days: 1
memory-impact-pr-branch:
name: Build PR branch for memory impact
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.memory_impact).should_run == 'true'
outputs:
ram_usage: ${{ steps.extract.outputs.ram_usage }}
flash_usage: ${{ steps.extract.outputs.flash_usage }}
steps:
- name: Check out PR branch
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
- name: Build, compile, and analyze memory
id: extract
run: |
. venv/bin/activate
components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}'
platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}"
echo "Building with test_build_components.py for $platform with components:"
echo "$components" | jq -r '.[]' | sed 's/^/ - /'
# Use test_build_components.py which handles grouping automatically
# Pass components as comma-separated list
component_list=$(echo "$components" | jq -r 'join(",")')
echo "Compiling with test_build_components.py..."
# Run build and extract memory with auto-detection of build directory for detailed analysis
# Use tee to show output in CI while also piping to extraction script
python script/test_build_components.py \
-e compile \
-c "$component_list" \
-t "$platform" 2>&1 | \
tee /dev/stderr | \
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:
name: memory-analysis-pr
path: memory-analysis-pr.json
if-no-files-found: warn
retention-days: 1
memory-impact-comment:
name: Comment memory impact
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
- memory-impact-target-branch
- memory-impact-pr-branch
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
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Download target analysis JSON
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: memory-analysis-target
path: ./memory-analysis
continue-on-error: true
- name: Download PR analysis JSON
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: memory-analysis-pr
path: ./memory-analysis
continue-on-error: true
- name: Post or update PR comment
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
. venv/bin/activate
# 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 "$PR_NUMBER" \
--target-json ./memory-analysis/memory-analysis-target.json \
--pr-json ./memory-analysis/memory-analysis-pr.json
ci-status: ci-status:
name: CI Status name: CI Status
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -498,11 +976,16 @@ jobs:
- pylint - pylint
- pytest - pytest
- integration-tests - integration-tests
- clang-tidy - clang-tidy-single
- clang-tidy-nosplit
- clang-tidy-split
- determine-jobs - determine-jobs
- test-build-components-splitter - test-build-components-splitter
- test-build-components-split - test-build-components-split
- pre-commit-ci-lite - pre-commit-ci-lite
- memory-impact-target-branch
- memory-impact-pr-branch
- memory-impact-comment
if: always() if: always()
steps: steps:
- name: Success - name: Success

View File

@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
@@ -86,6 +86,6 @@ jobs:
exit 1 exit 1
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"

View File

@@ -14,6 +14,7 @@ jobs:
label: label:
- needs-docs - needs-docs
- merge-after-release - merge-after-release
- chained-pr
steps: steps:
- name: Check for ${{ matrix.label }} label - name: Check for ${{ matrix.label }} label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0

View File

@@ -11,7 +11,7 @@ ci:
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.14.0 rev: v0.14.1
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff

View File

@@ -62,6 +62,7 @@ esphome/components/bedjet/fan/* @jhansche
esphome/components/bedjet/sensor/* @javawizard @jhansche esphome/components/bedjet/sensor/* @javawizard @jhansche
esphome/components/beken_spi_led_strip/* @Mat931 esphome/components/beken_spi_led_strip/* @Mat931
esphome/components/bh1750/* @OttoWinter esphome/components/bh1750/* @OttoWinter
esphome/components/bh1900nux/* @B48D81EFCC
esphome/components/binary_sensor/* @esphome/core esphome/components/binary_sensor/* @esphome/core
esphome/components/bk72xx/* @kuba2k2 esphome/components/bk72xx/* @kuba2k2
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
@@ -69,6 +70,7 @@ esphome/components/bl0939/* @ziceva
esphome/components/bl0940/* @dan-s-github @tobias- esphome/components/bl0940/* @dan-s-github @tobias-
esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/bl0942/* @dbuezas @dwmw2
esphome/components/ble_client/* @buxtronix @clydebarrow esphome/components/ble_client/* @buxtronix @clydebarrow
esphome/components/ble_nus/* @tomaszduda23
esphome/components/bluetooth_proxy/* @bdraco @jesserockz esphome/components/bluetooth_proxy/* @bdraco @jesserockz
esphome/components/bme280_base/* @esphome/core esphome/components/bme280_base/* @esphome/core
esphome/components/bme280_spi/* @apbodrov esphome/components/bme280_spi/* @apbodrov
@@ -159,6 +161,7 @@ esphome/components/esp32_rmt_led_strip/* @jesserockz
esphome/components/esp8266/* @esphome/core esphome/components/esp8266/* @esphome/core
esphome/components/esp_ldo/* @clydebarrow esphome/components/esp_ldo/* @clydebarrow
esphome/components/espnow/* @jesserockz esphome/components/espnow/* @jesserockz
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
esphome/components/ethernet_info/* @gtjadsonsantos esphome/components/ethernet_info/* @gtjadsonsantos
esphome/components/event/* @nohat esphome/components/event/* @nohat
esphome/components/exposure_notifications/* @OttoWinter esphome/components/exposure_notifications/* @OttoWinter

View File

@@ -62,6 +62,40 @@ from esphome.util import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Special non-component keys that appear in configs
_NON_COMPONENT_KEYS = frozenset(
{
CONF_ESPHOME,
"substitutions",
"packages",
"globals",
"external_components",
"<<",
}
)
def detect_external_components(config: ConfigType) -> set[str]:
"""Detect external/custom components in the configuration.
External components are those that appear in the config but are not
part of ESPHome's built-in components and are not special config keys.
Args:
config: The ESPHome configuration dictionary
Returns:
A set of external component names
"""
from esphome.analyze_memory.helpers import get_esphome_components
builtin_components = get_esphome_components()
return {
key
for key in config
if key not in builtin_components and key not in _NON_COMPONENT_KEYS
}
class ArgsProtocol(Protocol): class ArgsProtocol(Protocol):
device: list[str] | None device: list[str] | None
@@ -117,6 +151,17 @@ class Purpose(StrEnum):
LOGGING = "logging" LOGGING = "logging"
class PortType(StrEnum):
SERIAL = "SERIAL"
NETWORK = "NETWORK"
MQTT = "MQTT"
MQTTIP = "MQTTIP"
# Magic MQTT port types that require special handling
_MQTT_PORT_TYPES = frozenset({PortType.MQTT, PortType.MQTTIP})
def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]: def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]:
"""Resolve an address using cache if available, otherwise return the address itself.""" """Resolve an address using cache if available, otherwise return the address itself."""
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(address)): if CORE.address_cache and (cached := CORE.address_cache.get_addresses(address)):
@@ -174,7 +219,9 @@ def choose_upload_log_host(
else: else:
resolved.append(device) resolved.append(device)
if not resolved: 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 return resolved
# No devices specified, show interactive chooser # No devices specified, show interactive chooser
@@ -280,16 +327,67 @@ def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str
return mqtt.get_esphome_device_ip(config, username, password, client_id) return mqtt.get_esphome_device_ip(config, username, password, client_id)
_PORT_TO_PORT_TYPE = { def _resolve_network_devices(
"MQTT": "MQTT", devices: list[str], config: ConfigType, args: ArgsProtocol
"MQTTIP": "MQTTIP", ) -> list[str]:
} """Resolve device list, converting MQTT magic strings to actual IP addresses.
This function filters the devices list to:
- Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup
- Deduplicate addresses while preserving order
- Only resolve MQTT once even if multiple MQTT strings are present
- If MQTT resolution fails, log a warning and continue with other devices
Args:
devices: List of device identifiers (IPs, hostnames, or magic strings)
config: ESPHome configuration
args: Command-line arguments containing MQTT credentials
Returns:
List of network addresses suitable for connection attempts
"""
network_devices: list[str] = []
mqtt_resolved: bool = False
for device in devices:
port_type = get_port_type(device)
if port_type in _MQTT_PORT_TYPES:
# Only resolve MQTT once, even if multiple MQTT entries
if not mqtt_resolved:
try:
mqtt_ips = mqtt_get_ip(
config, args.username, args.password, args.client_id
)
network_devices.extend(mqtt_ips)
except EsphomeError as err:
_LOGGER.warning(
"MQTT IP discovery failed (%s), will try other devices if available",
err,
)
mqtt_resolved = True
elif device not in network_devices:
# Regular network address or IP - add if not already present
network_devices.append(device)
return network_devices
def get_port_type(port: str) -> str: def get_port_type(port: str) -> PortType:
"""Determine the type of port/device identifier.
Returns:
PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.)
PortType.MQTT for MQTT logging
PortType.MQTTIP for MQTT IP lookup
PortType.NETWORK for IP addresses, hostnames, or mDNS names
"""
if port.startswith("/") or port.startswith("COM"): if port.startswith("/") or port.startswith("COM"):
return "SERIAL" return PortType.SERIAL
return _PORT_TO_PORT_TYPE.get(port, "NETWORK") if port == "MQTT":
return PortType.MQTT
if port == "MQTTIP":
return PortType.MQTTIP
return PortType.NETWORK
def run_miniterm(config: ConfigType, port: str, args) -> int: def run_miniterm(config: ConfigType, port: str, args) -> int:
@@ -404,7 +502,9 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
from esphome import platformio_api from esphome import platformio_api
_LOGGER.info("Compiling app...") # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
rc = platformio_api.run_compile(config, CORE.verbose) rc = platformio_api.run_compile(config, CORE.verbose)
if rc != 0: if rc != 0:
return rc return rc
@@ -489,7 +589,7 @@ def upload_using_platformio(config: ConfigType, port: str):
def check_permissions(port: str): def check_permissions(port: str):
if os.name == "posix" and get_port_type(port) == "SERIAL": if os.name == "posix" and get_port_type(port) == PortType.SERIAL:
# Check if we can open selected serial port # Check if we can open selected serial port
if not os.access(port, os.F_OK): if not os.access(port, os.F_OK):
raise EsphomeError( raise EsphomeError(
@@ -517,7 +617,7 @@ def upload_program(
except AttributeError: except AttributeError:
pass pass
if get_port_type(host) == "SERIAL": if get_port_type(host) == PortType.SERIAL:
check_permissions(host) check_permissions(host)
exit_code = 1 exit_code = 1
@@ -544,17 +644,16 @@ def upload_program(
from esphome import espota2 from esphome import espota2
remote_port = int(ota_conf[CONF_PORT]) remote_port = int(ota_conf[CONF_PORT])
password = ota_conf.get(CONF_PASSWORD, "") password = ota_conf.get(CONF_PASSWORD)
if getattr(args, "file", None) is not None: if getattr(args, "file", None) is not None:
binary = Path(args.file) binary = Path(args.file)
else: else:
binary = CORE.firmware_bin binary = CORE.firmware_bin
# MQTT address resolution # Resolve MQTT magic strings to actual IP addresses
if get_port_type(host) in ("MQTT", "MQTTIP"): network_devices = _resolve_network_devices(devices, config, args)
devices = mqtt_get_ip(config, args.username, args.password, args.client_id)
return espota2.run_ota(devices, remote_port, password, binary) return espota2.run_ota(network_devices, remote_port, password, binary)
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
@@ -569,33 +668,22 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
raise EsphomeError("Logger is not configured!") raise EsphomeError("Logger is not configured!")
port = devices[0] port = devices[0]
port_type = get_port_type(port)
if get_port_type(port) == "SERIAL": if port_type == PortType.SERIAL:
check_permissions(port) check_permissions(port)
return run_miniterm(config, port, args) return run_miniterm(config, port, args)
port_type = get_port_type(port)
# Check if we should use API for logging # Check if we should use API for logging
if has_api(): # Resolve MQTT magic strings to actual IP addresses
addresses_to_use: list[str] | None = None if has_api() and (
network_devices := _resolve_network_devices(devices, config, args)
):
from esphome.components.api.client import run_logs
if port_type == "NETWORK": return run_logs(config, network_devices)
# Network addresses (IPs, mDNS names, or regular DNS hostnames) can be used
# The resolve_ip_address() function in helpers.py handles all types
addresses_to_use = devices
elif port_type in ("MQTT", "MQTTIP") and has_mqtt_ip_lookup():
# Use MQTT IP lookup for MQTT/MQTTIP types
addresses_to_use = mqtt_get_ip(
config, args.username, args.password, args.client_id
)
if addresses_to_use is not None: if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
from esphome.components.api.client import run_logs
return run_logs(config, addresses_to_use)
if port_type in ("NETWORK", "MQTT") and has_mqtt_logging():
from esphome import mqtt from esphome import mqtt
return mqtt.show_logs( return mqtt.show_logs(
@@ -845,6 +933,54 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
return 0 return 0
def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
"""Analyze memory usage by component.
This command compiles the configuration and performs memory analysis.
Compilation is fast if sources haven't changed (just relinking).
"""
from esphome import platformio_api
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
# Always compile to ensure fresh data (fast if no changes - just relinks)
exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
exit_code = compile_program(args, config)
if exit_code != 0:
return exit_code
_LOGGER.info("Successfully compiled program.")
# Get idedata for analysis
idedata = platformio_api.get_idedata(config)
if idedata is None:
_LOGGER.error("Failed to get IDE data for memory analysis")
return 1
firmware_elf = Path(idedata.firmware_elf_path)
# Extract external components from config
external_components = detect_external_components(config)
_LOGGER.debug("Detected external components: %s", external_components)
# Perform memory analysis
_LOGGER.info("Analyzing memory usage...")
analyzer = MemoryAnalyzerCLI(
str(firmware_elf),
idedata.objdump_path,
idedata.readelf_path,
external_components,
)
analyzer.analyze()
# Generate and display report
report = analyzer.generate_report()
print()
print(report)
return 0
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_name = args.name new_name = args.name
for c in new_name: for c in new_name:
@@ -960,6 +1096,7 @@ POST_CONFIG_ACTIONS = {
"idedata": command_idedata, "idedata": command_idedata,
"rename": command_rename, "rename": command_rename,
"discover": command_discover, "discover": command_discover,
"analyze-memory": command_analyze_memory,
} }
SIMPLE_CONFIG_ACTIONS = [ SIMPLE_CONFIG_ACTIONS = [
@@ -1256,6 +1393,14 @@ def parse_args(argv):
) )
parser_rename.add_argument("name", help="The new name for the device.", type=str) parser_rename.add_argument("name", help="The new name for the device.", type=str)
parser_analyze_memory = subparsers.add_parser(
"analyze-memory",
help="Analyze memory usage by component.",
)
parser_analyze_memory.add_argument(
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
# Keep backward compatibility with the old command line format of # Keep backward compatibility with the old command line format of
# esphome <config> <command>. # esphome <config> <command>.
# #

View File

@@ -0,0 +1,502 @@
"""Memory usage analyzer for ESPHome compiled binaries."""
from collections import defaultdict
from dataclasses import dataclass, field
import logging
from pathlib import Path
import re
import subprocess
from typing import TYPE_CHECKING
from .const import (
CORE_SUBCATEGORY_PATTERNS,
DEMANGLED_PATTERNS,
ESPHOME_COMPONENT_PATTERN,
SECTION_TO_ATTR,
SYMBOL_PATTERNS,
)
from .helpers import (
get_component_class_patterns,
get_esphome_components,
map_section_name,
parse_symbol_line,
)
if TYPE_CHECKING:
from esphome.platformio_api import IDEData
_LOGGER = logging.getLogger(__name__)
# GCC global constructor/destructor prefix annotations
_GCC_PREFIX_ANNOTATIONS = {
"_GLOBAL__sub_I_": "global constructor for",
"_GLOBAL__sub_D_": "global destructor for",
}
# GCC optimization suffix pattern (e.g., $isra$0, $part$1, $constprop$2)
_GCC_OPTIMIZATION_SUFFIX_PATTERN = re.compile(r"(\$(?:isra|part|constprop)\$\d+)")
# C++ runtime patterns for categorization
_CPP_RUNTIME_PATTERNS = frozenset(["vtable", "typeinfo", "thunk"])
# libc printf/scanf family base names (used to detect variants like _printf_r, vfprintf, etc.)
_LIBC_PRINTF_SCANF_FAMILY = frozenset(["printf", "fprintf", "sprintf", "scanf"])
# Regex pattern for parsing readelf section headers
# Format: [ #] name type addr off size
_READELF_SECTION_PATTERN = re.compile(
r"\s*\[\s*\d+\]\s+([\.\w]+)\s+\w+\s+[\da-fA-F]+\s+[\da-fA-F]+\s+([\da-fA-F]+)"
)
# Component category prefixes
_COMPONENT_PREFIX_ESPHOME = "[esphome]"
_COMPONENT_PREFIX_EXTERNAL = "[external]"
_COMPONENT_CORE = f"{_COMPONENT_PREFIX_ESPHOME}core"
_COMPONENT_API = f"{_COMPONENT_PREFIX_ESPHOME}api"
# C++ namespace prefixes
_NAMESPACE_ESPHOME = "esphome::"
_NAMESPACE_STD = "std::"
# Type alias for symbol information: (symbol_name, size, component)
SymbolInfoType = tuple[str, int, str]
@dataclass
class MemorySection:
"""Represents a memory section with its symbols."""
name: str
symbols: list[SymbolInfoType] = field(default_factory=list)
total_size: int = 0
@dataclass
class ComponentMemory:
"""Tracks memory usage for a component."""
name: str
text_size: int = 0 # Code in flash
rodata_size: int = 0 # Read-only data in flash
data_size: int = 0 # Initialized data (flash + ram)
bss_size: int = 0 # Uninitialized data (ram only)
symbol_count: int = 0
@property
def flash_total(self) -> int:
"""Total flash usage (text + rodata + data)."""
return self.text_size + self.rodata_size + self.data_size
@property
def ram_total(self) -> int:
"""Total RAM usage (data + bss)."""
return self.data_size + self.bss_size
class MemoryAnalyzer:
"""Analyzes memory usage from ELF files."""
def __init__(
self,
elf_path: str,
objdump_path: str | None = None,
readelf_path: str | None = None,
external_components: set[str] | None = None,
idedata: "IDEData | None" = None,
) -> None:
"""Initialize memory analyzer.
Args:
elf_path: Path to ELF file to analyze
objdump_path: Path to objdump binary (auto-detected from idedata if not provided)
readelf_path: Path to readelf binary (auto-detected from idedata if not provided)
external_components: Set of external component names
idedata: Optional PlatformIO IDEData object to auto-detect toolchain paths
"""
self.elf_path = Path(elf_path)
if not self.elf_path.exists():
raise FileNotFoundError(f"ELF file not found: {elf_path}")
# Auto-detect toolchain paths from idedata if not provided
if idedata is not None and (objdump_path is None or readelf_path is None):
objdump_path = objdump_path or idedata.objdump_path
readelf_path = readelf_path or idedata.readelf_path
_LOGGER.debug("Using toolchain paths from PlatformIO idedata")
self.objdump_path = objdump_path or "objdump"
self.readelf_path = readelf_path or "readelf"
self.external_components = external_components or set()
self.sections: dict[str, MemorySection] = {}
self.components: dict[str, ComponentMemory] = defaultdict(
lambda: ComponentMemory("")
)
self._demangle_cache: dict[str, str] = {}
self._uncategorized_symbols: list[tuple[str, str, int]] = []
self._esphome_core_symbols: list[
tuple[str, str, int]
] = [] # Track core symbols
self._component_symbols: dict[str, list[tuple[str, str, int]]] = defaultdict(
list
) # Track symbols for all components
def analyze(self) -> dict[str, ComponentMemory]:
"""Analyze the ELF file and return component memory usage."""
self._parse_sections()
self._parse_symbols()
self._categorize_symbols()
return dict(self.components)
def _parse_sections(self) -> None:
"""Parse section headers from ELF file."""
result = subprocess.run(
[self.readelf_path, "-S", str(self.elf_path)],
capture_output=True,
text=True,
check=True,
)
# Parse section headers
for line in result.stdout.splitlines():
# Look for section entries
if not (match := _READELF_SECTION_PATTERN.match(line)):
continue
section_name = match.group(1)
size_hex = match.group(2)
size = int(size_hex, 16)
# Map to standard section name
mapped_section = map_section_name(section_name)
if not mapped_section:
continue
if mapped_section not in self.sections:
self.sections[mapped_section] = MemorySection(mapped_section)
self.sections[mapped_section].total_size += size
def _parse_symbols(self) -> None:
"""Parse symbols from ELF file."""
result = subprocess.run(
[self.objdump_path, "-t", str(self.elf_path)],
capture_output=True,
text=True,
check=True,
)
# Track seen addresses to avoid duplicates
seen_addresses: set[str] = set()
for line in result.stdout.splitlines():
if not (symbol_info := parse_symbol_line(line)):
continue
section, name, size, address = symbol_info
# Skip duplicate symbols at the same address (e.g., C1/C2 constructors)
if address in seen_addresses or section not in self.sections:
continue
self.sections[section].symbols.append((name, size, ""))
seen_addresses.add(address)
def _categorize_symbols(self) -> None:
"""Categorize symbols by component."""
# First, collect all unique symbol names for batch demangling
all_symbols = {
symbol_name
for section in self.sections.values()
for symbol_name, _, _ in section.symbols
}
# Batch demangle all symbols at once
self._batch_demangle_symbols(list(all_symbols))
# Now categorize with cached demangled names
for section_name, section in self.sections.items():
for symbol_name, size, _ in section.symbols:
component = self._identify_component(symbol_name)
if component not in self.components:
self.components[component] = ComponentMemory(component)
comp_mem = self.components[component]
comp_mem.symbol_count += 1
# Update the appropriate size attribute based on section
if attr_name := SECTION_TO_ATTR.get(section_name):
setattr(comp_mem, attr_name, getattr(comp_mem, attr_name) + size)
# Track uncategorized symbols
if component == "other" and size > 0:
demangled = self._demangle_symbol(symbol_name)
self._uncategorized_symbols.append((symbol_name, demangled, size))
# Track ESPHome core symbols for detailed analysis
if component == _COMPONENT_CORE and size > 0:
demangled = self._demangle_symbol(symbol_name)
self._esphome_core_symbols.append((symbol_name, demangled, size))
# Track all component symbols for detailed analysis
if size > 0:
demangled = self._demangle_symbol(symbol_name)
self._component_symbols[component].append(
(symbol_name, demangled, size)
)
def _identify_component(self, symbol_name: str) -> str:
"""Identify which component a symbol belongs to."""
# Demangle C++ names if needed
demangled = self._demangle_symbol(symbol_name)
# Check for special component classes first (before namespace pattern)
# This handles cases like esphome::ESPHomeOTAComponent which should map to ota
if _NAMESPACE_ESPHOME in demangled:
# Check for special component classes that include component name in the class
# For example: esphome::ESPHomeOTAComponent -> ota component
for component_name in get_esphome_components():
patterns = get_component_class_patterns(component_name)
if any(pattern in demangled for pattern in patterns):
return f"{_COMPONENT_PREFIX_ESPHOME}{component_name}"
# Check for ESPHome component namespaces
match = ESPHOME_COMPONENT_PATTERN.search(demangled)
if match:
component_name = match.group(1)
# Strip trailing underscore if present (e.g., switch_ -> switch)
component_name = component_name.rstrip("_")
# Check if this is an actual component in the components directory
if component_name in get_esphome_components():
return f"{_COMPONENT_PREFIX_ESPHOME}{component_name}"
# Check if this is a known external component from the config
if component_name in self.external_components:
return f"{_COMPONENT_PREFIX_EXTERNAL}{component_name}"
# Everything else in esphome:: namespace is core
return _COMPONENT_CORE
# Check for esphome core namespace (no component namespace)
if _NAMESPACE_ESPHOME in demangled:
# If no component match found, it's core
return _COMPONENT_CORE
# Check against symbol patterns
for component, patterns in SYMBOL_PATTERNS.items():
if any(pattern in symbol_name for pattern in patterns):
return component
# Check against demangled patterns
for component, patterns in DEMANGLED_PATTERNS.items():
if any(pattern in demangled for pattern in patterns):
return component
# Special cases that need more complex logic
# Check if spi_flash vs spi_driver
if "spi_" in symbol_name or "SPI" in symbol_name:
return "spi_flash" if "spi_flash" in symbol_name else "spi_driver"
# libc special printf variants
if (
symbol_name.startswith("_")
and symbol_name[1:].replace("_r", "").replace("v", "").replace("s", "")
in _LIBC_PRINTF_SCANF_FAMILY
):
return "libc"
# Track uncategorized symbols for analysis
return "other"
def _batch_demangle_symbols(self, symbols: list[str]) -> None:
"""Batch demangle C++ symbol names for efficiency."""
if not symbols:
return
# Try to find the appropriate c++filt for the platform
cppfilt_cmd = "c++filt"
_LOGGER.info("Demangling %d symbols", len(symbols))
_LOGGER.debug("objdump_path = %s", self.objdump_path)
# Check if we have a toolchain-specific c++filt
if self.objdump_path and self.objdump_path != "objdump":
# Replace objdump with c++filt in the path
potential_cppfilt = self.objdump_path.replace("objdump", "c++filt")
_LOGGER.info("Checking for toolchain c++filt at: %s", potential_cppfilt)
if Path(potential_cppfilt).exists():
cppfilt_cmd = potential_cppfilt
_LOGGER.info("✓ Using toolchain c++filt: %s", cppfilt_cmd)
else:
_LOGGER.info(
"✗ Toolchain c++filt not found at %s, using system c++filt",
potential_cppfilt,
)
else:
_LOGGER.info("✗ Using system c++filt (objdump_path=%s)", self.objdump_path)
# Strip GCC optimization suffixes and prefixes before demangling
# Suffixes like $isra$0, $part$0, $constprop$0 confuse c++filt
# Prefixes like _GLOBAL__sub_I_ need to be removed and tracked
symbols_stripped: list[str] = []
symbols_prefixes: list[str] = [] # Track removed prefixes
for symbol in symbols:
# Remove GCC optimization markers
stripped = _GCC_OPTIMIZATION_SUFFIX_PATTERN.sub("", symbol)
# Handle GCC global constructor/initializer prefixes
# _GLOBAL__sub_I_<mangled> -> extract <mangled> for demangling
prefix = ""
for gcc_prefix in _GCC_PREFIX_ANNOTATIONS:
if stripped.startswith(gcc_prefix):
prefix = gcc_prefix
stripped = stripped[len(prefix) :]
break
symbols_stripped.append(stripped)
symbols_prefixes.append(prefix)
try:
# Send all symbols to c++filt at once
result = subprocess.run(
[cppfilt_cmd],
input="\n".join(symbols_stripped),
capture_output=True,
text=True,
check=False,
)
except (subprocess.SubprocessError, OSError, UnicodeDecodeError) as e:
# On error, cache originals
_LOGGER.warning("Failed to batch demangle symbols: %s", e)
for symbol in symbols:
self._demangle_cache[symbol] = symbol
return
if result.returncode != 0:
_LOGGER.warning(
"c++filt exited with code %d: %s",
result.returncode,
result.stderr[:200] if result.stderr else "(no error output)",
)
# Cache originals on failure
for symbol in symbols:
self._demangle_cache[symbol] = symbol
return
# Process demangled output
self._process_demangled_output(
symbols, symbols_stripped, symbols_prefixes, result.stdout, cppfilt_cmd
)
def _process_demangled_output(
self,
symbols: list[str],
symbols_stripped: list[str],
symbols_prefixes: list[str],
demangled_output: str,
cppfilt_cmd: str,
) -> None:
"""Process demangled symbol output and populate cache.
Args:
symbols: Original symbol names
symbols_stripped: Stripped symbol names sent to c++filt
symbols_prefixes: Removed prefixes to restore
demangled_output: Output from c++filt
cppfilt_cmd: Path to c++filt command (for logging)
"""
demangled_lines = demangled_output.strip().split("\n")
failed_count = 0
for original, stripped, prefix, demangled in zip(
symbols, symbols_stripped, symbols_prefixes, demangled_lines
):
# Add back any prefix that was removed
demangled = self._restore_symbol_prefix(prefix, stripped, demangled)
# If we stripped a suffix, add it back to the demangled name for clarity
if original != stripped and not prefix:
demangled = self._restore_symbol_suffix(original, demangled)
self._demangle_cache[original] = demangled
# Log symbols that failed to demangle (stayed the same as stripped version)
if stripped == demangled and stripped.startswith("_Z"):
failed_count += 1
if failed_count <= 5: # Only log first 5 failures
_LOGGER.warning("Failed to demangle: %s", original)
if failed_count == 0:
_LOGGER.info("Successfully demangled all %d symbols", len(symbols))
return
_LOGGER.warning(
"Failed to demangle %d/%d symbols using %s",
failed_count,
len(symbols),
cppfilt_cmd,
)
@staticmethod
def _restore_symbol_prefix(prefix: str, stripped: str, demangled: str) -> str:
"""Restore prefix that was removed before demangling.
Args:
prefix: Prefix that was removed (e.g., "_GLOBAL__sub_I_")
stripped: Stripped symbol name
demangled: Demangled symbol name
Returns:
Demangled name with prefix restored/annotated
"""
if not prefix:
return demangled
# Successfully demangled - add descriptive prefix
if demangled != stripped and (
annotation := _GCC_PREFIX_ANNOTATIONS.get(prefix)
):
return f"[{annotation}: {demangled}]"
# Failed to demangle - restore original prefix
return prefix + demangled
@staticmethod
def _restore_symbol_suffix(original: str, demangled: str) -> str:
"""Restore GCC optimization suffix that was removed before demangling.
Args:
original: Original symbol name with suffix
demangled: Demangled symbol name without suffix
Returns:
Demangled name with suffix annotation
"""
if suffix_match := _GCC_OPTIMIZATION_SUFFIX_PATTERN.search(original):
return f"{demangled} [{suffix_match.group(1)}]"
return demangled
def _demangle_symbol(self, symbol: str) -> str:
"""Get demangled C++ symbol name from cache."""
return self._demangle_cache.get(symbol, symbol)
def _categorize_esphome_core_symbol(self, demangled: str) -> str:
"""Categorize ESPHome core symbols into subcategories."""
# Special patterns that need to be checked separately
if any(pattern in demangled for pattern in _CPP_RUNTIME_PATTERNS):
return "C++ Runtime (vtables/RTTI)"
if demangled.startswith(_NAMESPACE_STD):
return "C++ STL"
# Check against patterns from const.py
for category, patterns in CORE_SUBCATEGORY_PATTERNS.items():
if any(pattern in demangled for pattern in patterns):
return category
return "Other Core"
if __name__ == "__main__":
from .cli import main
main()

View File

@@ -0,0 +1,6 @@
"""Main entry point for running the memory analyzer as a module."""
from .cli import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,421 @@
"""CLI interface for memory analysis with report generation."""
from collections import defaultdict
import sys
from . import (
_COMPONENT_API,
_COMPONENT_CORE,
_COMPONENT_PREFIX_ESPHOME,
_COMPONENT_PREFIX_EXTERNAL,
MemoryAnalyzer,
)
class MemoryAnalyzerCLI(MemoryAnalyzer):
"""Memory analyzer with CLI-specific report generation."""
# Column width constants
COL_COMPONENT: int = 29
COL_FLASH_TEXT: int = 14
COL_FLASH_DATA: int = 14
COL_RAM_DATA: int = 12
COL_RAM_BSS: int = 12
COL_TOTAL_FLASH: int = 15
COL_TOTAL_RAM: int = 12
COL_SEPARATOR: int = 3 # " | "
# Core analysis column widths
COL_CORE_SUBCATEGORY: int = 30
COL_CORE_SIZE: int = 12
COL_CORE_COUNT: int = 6
COL_CORE_PERCENT: int = 10
# Calculate table width once at class level
TABLE_WIDTH: int = (
COL_COMPONENT
+ COL_SEPARATOR
+ COL_FLASH_TEXT
+ COL_SEPARATOR
+ COL_FLASH_DATA
+ COL_SEPARATOR
+ COL_RAM_DATA
+ COL_SEPARATOR
+ COL_RAM_BSS
+ COL_SEPARATOR
+ COL_TOTAL_FLASH
+ COL_SEPARATOR
+ COL_TOTAL_RAM
)
@staticmethod
def _make_separator_line(*widths: int) -> str:
"""Create a separator line with given column widths.
Args:
widths: Column widths to create separators for
Returns:
Separator line like "----+---------+-----"
"""
return "-+-".join("-" * width for width in widths)
# Pre-computed separator lines
MAIN_TABLE_SEPARATOR: str = _make_separator_line(
COL_COMPONENT,
COL_FLASH_TEXT,
COL_FLASH_DATA,
COL_RAM_DATA,
COL_RAM_BSS,
COL_TOTAL_FLASH,
COL_TOTAL_RAM,
)
CORE_TABLE_SEPARATOR: str = _make_separator_line(
COL_CORE_SUBCATEGORY,
COL_CORE_SIZE,
COL_CORE_COUNT,
COL_CORE_PERCENT,
)
def generate_report(self, detailed: bool = False) -> str:
"""Generate a formatted memory report."""
components = sorted(
self.components.items(), key=lambda x: x[1].flash_total, reverse=True
)
# Calculate totals
total_flash = sum(c.flash_total for _, c in components)
total_ram = sum(c.ram_total for _, c in components)
# Build report
lines: list[str] = []
lines.append("=" * self.TABLE_WIDTH)
lines.append("Component Memory Analysis".center(self.TABLE_WIDTH))
lines.append("=" * self.TABLE_WIDTH)
lines.append("")
# Main table - fixed column widths
lines.append(
f"{'Component':<{self.COL_COMPONENT}} | {'Flash (text)':>{self.COL_FLASH_TEXT}} | {'Flash (data)':>{self.COL_FLASH_DATA}} | {'RAM (data)':>{self.COL_RAM_DATA}} | {'RAM (bss)':>{self.COL_RAM_BSS}} | {'Total Flash':>{self.COL_TOTAL_FLASH}} | {'Total RAM':>{self.COL_TOTAL_RAM}}"
)
lines.append(self.MAIN_TABLE_SEPARATOR)
for name, mem in components:
if mem.flash_total > 0 or mem.ram_total > 0:
flash_rodata = mem.rodata_size + mem.data_size
lines.append(
f"{name:<{self.COL_COMPONENT}} | {mem.text_size:>{self.COL_FLASH_TEXT - 2},} B | {flash_rodata:>{self.COL_FLASH_DATA - 2},} B | "
f"{mem.data_size:>{self.COL_RAM_DATA - 2},} B | {mem.bss_size:>{self.COL_RAM_BSS - 2},} B | "
f"{mem.flash_total:>{self.COL_TOTAL_FLASH - 2},} B | {mem.ram_total:>{self.COL_TOTAL_RAM - 2},} B"
)
lines.append(self.MAIN_TABLE_SEPARATOR)
lines.append(
f"{'TOTAL':<{self.COL_COMPONENT}} | {' ':>{self.COL_FLASH_TEXT}} | {' ':>{self.COL_FLASH_DATA}} | "
f"{' ':>{self.COL_RAM_DATA}} | {' ':>{self.COL_RAM_BSS}} | "
f"{total_flash:>{self.COL_TOTAL_FLASH - 2},} B | {total_ram:>{self.COL_TOTAL_RAM - 2},} B"
)
# Top consumers
lines.append("")
lines.append("Top Flash Consumers:")
for i, (name, mem) in enumerate(components[:25]):
if mem.flash_total > 0:
percentage = (
(mem.flash_total / total_flash * 100) if total_flash > 0 else 0
)
lines.append(
f"{i + 1}. {name} ({mem.flash_total:,} B) - {percentage:.1f}% of analyzed flash"
)
lines.append("")
lines.append("Top RAM Consumers:")
ram_components = sorted(components, key=lambda x: x[1].ram_total, reverse=True)
for i, (name, mem) in enumerate(ram_components[:25]):
if mem.ram_total > 0:
percentage = (mem.ram_total / total_ram * 100) if total_ram > 0 else 0
lines.append(
f"{i + 1}. {name} ({mem.ram_total:,} B) - {percentage:.1f}% of analyzed RAM"
)
lines.append("")
lines.append(
"Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included."
)
lines.append("=" * self.TABLE_WIDTH)
# Add ESPHome core detailed analysis if there are core symbols
if self._esphome_core_symbols:
lines.append("")
lines.append("=" * self.TABLE_WIDTH)
lines.append(
f"{_COMPONENT_CORE} Detailed Analysis".center(self.TABLE_WIDTH)
)
lines.append("=" * self.TABLE_WIDTH)
lines.append("")
# Group core symbols by subcategory
core_subcategories: dict[str, list[tuple[str, str, int]]] = defaultdict(
list
)
for symbol, demangled, size in self._esphome_core_symbols:
# Categorize based on demangled name patterns
subcategory = self._categorize_esphome_core_symbol(demangled)
core_subcategories[subcategory].append((symbol, demangled, size))
# Sort subcategories by total size
sorted_subcategories = sorted(
[
(name, symbols, sum(s[2] for s in symbols))
for name, symbols in core_subcategories.items()
],
key=lambda x: x[2],
reverse=True,
)
lines.append(
f"{'Subcategory':<{self.COL_CORE_SUBCATEGORY}} | {'Size':>{self.COL_CORE_SIZE}} | "
f"{'Count':>{self.COL_CORE_COUNT}} | {'% of Core':>{self.COL_CORE_PERCENT}}"
)
lines.append(self.CORE_TABLE_SEPARATOR)
core_total = sum(size for _, _, size in self._esphome_core_symbols)
for subcategory, symbols, total_size in sorted_subcategories:
percentage = (total_size / core_total * 100) if core_total > 0 else 0
lines.append(
f"{subcategory:<{self.COL_CORE_SUBCATEGORY}} | {total_size:>{self.COL_CORE_SIZE - 2},} B | "
f"{len(symbols):>{self.COL_CORE_COUNT}} | {percentage:>{self.COL_CORE_PERCENT - 1}.1f}%"
)
# Top 15 largest core symbols
lines.append("")
lines.append(f"Top 15 Largest {_COMPONENT_CORE} Symbols:")
sorted_core_symbols = sorted(
self._esphome_core_symbols, key=lambda x: x[2], reverse=True
)
for i, (symbol, demangled, size) in enumerate(sorted_core_symbols[:15]):
lines.append(f"{i + 1}. {demangled} ({size:,} B)")
lines.append("=" * self.TABLE_WIDTH)
# Add detailed analysis for top ESPHome and external components
esphome_components = [
(name, mem)
for name, mem in components
if name.startswith(_COMPONENT_PREFIX_ESPHOME) and name != _COMPONENT_CORE
]
external_components = [
(name, mem)
for name, mem in components
if name.startswith(_COMPONENT_PREFIX_EXTERNAL)
]
top_esphome_components = sorted(
esphome_components, key=lambda x: x[1].flash_total, reverse=True
)[:30]
# Include all external components (they're usually important)
top_external_components = sorted(
external_components, key=lambda x: x[1].flash_total, reverse=True
)
# Check if API component exists and ensure it's included
api_component = None
for name, mem in components:
if name == _COMPONENT_API:
api_component = (name, mem)
break
# Also include wifi_stack and other important system components if they exist
system_components_to_include = [
# Empty list - we've finished debugging symbol categorization
# Add component names here if you need to debug their symbols
]
system_components = [
(name, mem)
for name, mem in components
if name in system_components_to_include
]
# Combine all components to analyze: top ESPHome + all external + API if not already included + system components
components_to_analyze = (
list(top_esphome_components)
+ list(top_external_components)
+ system_components
)
if api_component and api_component not in components_to_analyze:
components_to_analyze.append(api_component)
if components_to_analyze:
for comp_name, comp_mem in components_to_analyze:
if not (comp_symbols := self._component_symbols.get(comp_name, [])):
continue
lines.append("")
lines.append("=" * self.TABLE_WIDTH)
lines.append(f"{comp_name} Detailed Analysis".center(self.TABLE_WIDTH))
lines.append("=" * self.TABLE_WIDTH)
lines.append("")
# Sort symbols by size
sorted_symbols = sorted(comp_symbols, key=lambda x: x[2], reverse=True)
lines.append(f"Total symbols: {len(sorted_symbols)}")
lines.append(f"Total size: {comp_mem.flash_total:,} B")
lines.append("")
# Show all symbols > 100 bytes for better visibility
large_symbols = [
(sym, dem, size) for sym, dem, size in sorted_symbols if size > 100
]
lines.append(
f"{comp_name} Symbols > 100 B ({len(large_symbols)} symbols):"
)
for i, (symbol, demangled, size) in enumerate(large_symbols):
lines.append(f"{i + 1}. {demangled} ({size:,} B)")
lines.append("=" * self.TABLE_WIDTH)
return "\n".join(lines)
def dump_uncategorized_symbols(self, output_file: str | None = None) -> None:
"""Dump uncategorized symbols for analysis."""
# Sort by size descending
sorted_symbols = sorted(
self._uncategorized_symbols, key=lambda x: x[2], reverse=True
)
lines = ["Uncategorized Symbols Analysis", "=" * 80]
lines.append(f"Total uncategorized symbols: {len(sorted_symbols)}")
lines.append(
f"Total uncategorized size: {sum(s[2] for s in sorted_symbols):,} bytes"
)
lines.append("")
lines.append(f"{'Size':>10} | {'Symbol':<60} | Demangled")
lines.append("-" * 10 + "-+-" + "-" * 60 + "-+-" + "-" * 40)
for symbol, demangled, size in sorted_symbols[:100]: # Top 100
demangled_display = (
demangled[:100] if symbol != demangled else "[not demangled]"
)
lines.append(f"{size:>10,} | {symbol[:60]:<60} | {demangled_display}")
if len(sorted_symbols) > 100:
lines.append(f"\n... and {len(sorted_symbols) - 100} more symbols")
content = "\n".join(lines)
if output_file:
with open(output_file, "w", encoding="utf-8") as f:
f.write(content)
else:
print(content)
def analyze_elf(
elf_path: str,
objdump_path: str | None = None,
readelf_path: str | None = None,
detailed: bool = False,
external_components: set[str] | None = None,
) -> str:
"""Analyze an ELF file and return a memory report."""
analyzer = MemoryAnalyzerCLI(
elf_path, objdump_path, readelf_path, external_components
)
analyzer.analyze()
return analyzer.generate_report(detailed)
def main():
"""CLI entrypoint for memory analysis."""
if len(sys.argv) < 2:
print("Usage: python -m esphome.analyze_memory <build_directory>")
print("\nAnalyze memory usage from an ESPHome build directory.")
print("The build directory should contain firmware.elf and idedata will be")
print("loaded from ~/.esphome/.internal/idedata/<device>.json")
print("\nExamples:")
print(" python -m esphome.analyze_memory ~/.esphome/build/my-device")
print(" python -m esphome.analyze_memory .esphome/build/my-device")
print(" python -m esphome.analyze_memory my-device # Short form")
sys.exit(1)
build_dir = sys.argv[1]
# Load build directory
import json
from pathlib import Path
from esphome.platformio_api import IDEData
build_path = Path(build_dir)
# If no path separator in name, assume it's a device name
if "/" not in build_dir and not build_path.is_dir():
# Try current directory first
cwd_path = Path.cwd() / ".esphome" / "build" / build_dir
if cwd_path.is_dir():
build_path = cwd_path
print(f"Using build directory: {build_path}", file=sys.stderr)
else:
# Fall back to home directory
build_path = Path.home() / ".esphome" / "build" / build_dir
print(f"Using build directory: {build_path}", file=sys.stderr)
if not build_path.is_dir():
print(f"Error: {build_path} is not a directory", file=sys.stderr)
sys.exit(1)
# Find firmware.elf
elf_file = None
for elf_candidate in [
build_path / "firmware.elf",
build_path / ".pioenvs" / build_path.name / "firmware.elf",
]:
if elf_candidate.exists():
elf_file = str(elf_candidate)
break
if not elf_file:
print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
sys.exit(1)
# Find idedata.json - check current directory first, then home
device_name = build_path.name
idedata_candidates = [
Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
]
idedata = None
for idedata_path in idedata_candidates:
if not idedata_path.exists():
continue
try:
with open(idedata_path, encoding="utf-8") as f:
raw_data = json.load(f)
idedata = IDEData(raw_data)
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
break
except (json.JSONDecodeError, OSError) as e:
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
if not idedata:
print(
f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
file=sys.stderr,
)
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
analyzer.analyze()
report = analyzer.generate_report()
print(report)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
"""Helper functions for memory analysis."""
from functools import cache
from pathlib import Path
from .const import SECTION_MAPPING
# Import namespace constant from parent module
# Note: This would create a circular import if done at module level,
# so we'll define it locally here as well
_NAMESPACE_ESPHOME = "esphome::"
# Get the list of actual ESPHome components by scanning the components directory
@cache
def get_esphome_components():
"""Get set of actual ESPHome components from the components directory."""
# Find the components directory relative to this file
# Go up two levels from analyze_memory/helpers.py to esphome/
current_dir = Path(__file__).parent.parent
components_dir = current_dir / "components"
if not components_dir.exists() or not components_dir.is_dir():
return frozenset()
return frozenset(
item.name
for item in components_dir.iterdir()
if item.is_dir()
and not item.name.startswith(".")
and not item.name.startswith("__")
)
@cache
def get_component_class_patterns(component_name: str) -> list[str]:
"""Generate component class name patterns for symbol matching.
Args:
component_name: The component name (e.g., "ota", "wifi", "api")
Returns:
List of pattern strings to match against demangled symbols
"""
component_upper = component_name.upper()
component_camel = component_name.replace("_", "").title()
return [
f"{_NAMESPACE_ESPHOME}{component_upper}Component", # e.g., esphome::OTAComponent
f"{_NAMESPACE_ESPHOME}ESPHome{component_upper}Component", # e.g., esphome::ESPHomeOTAComponent
f"{_NAMESPACE_ESPHOME}{component_camel}Component", # e.g., esphome::OtaComponent
f"{_NAMESPACE_ESPHOME}ESPHome{component_camel}Component", # e.g., esphome::ESPHomeOtaComponent
]
def map_section_name(raw_section: str) -> str | None:
"""Map raw section name to standard section.
Args:
raw_section: Raw section name from ELF file (e.g., ".iram0.text", ".rodata.str1.1")
Returns:
Standard section name (".text", ".rodata", ".data", ".bss") or None
"""
for standard_section, patterns in SECTION_MAPPING.items():
if any(pattern in raw_section for pattern in patterns):
return standard_section
return None
def parse_symbol_line(line: str) -> tuple[str, str, int, str] | None:
"""Parse a single symbol line from objdump output.
Args:
line: Line from objdump -t output
Returns:
Tuple of (section, name, size, address) or None if not a valid symbol.
Format: address l/g w/d F/O section size name
Example: 40084870 l F .iram0.text 00000000 _xt_user_exc
"""
parts = line.split()
if len(parts) < 5:
return None
try:
# Validate and extract address
address = parts[0]
int(address, 16)
except ValueError:
return None
# Look for F (function) or O (object) flag
if "F" not in parts and "O" not in parts:
return None
# Find section, size, and name
for i, part in enumerate(parts):
if not part.startswith("."):
continue
section = map_section_name(part)
if not section:
break
# Need at least size field after section
if i + 1 >= len(parts):
break
try:
size = int(parts[i + 1], 16)
except ValueError:
break
# Need symbol name and non-zero size
if i + 2 >= len(parts) or size == 0:
break
name = " ".join(parts[i + 2 :])
return (section, name, size, address)
return None

View File

@@ -9,7 +9,7 @@ static const char *const TAG = "adalight_light_effect";
static const uint32_t ADALIGHT_ACK_INTERVAL = 1000; static const uint32_t ADALIGHT_ACK_INTERVAL = 1000;
static const uint32_t ADALIGHT_RECEIVE_TIMEOUT = 1000; static const uint32_t ADALIGHT_RECEIVE_TIMEOUT = 1000;
AdalightLightEffect::AdalightLightEffect(const std::string &name) : AddressableLightEffect(name) {} AdalightLightEffect::AdalightLightEffect(const char *name) : AddressableLightEffect(name) {}
void AdalightLightEffect::start() { void AdalightLightEffect::start() {
AddressableLightEffect::start(); AddressableLightEffect::start();

View File

@@ -11,7 +11,7 @@ namespace adalight {
class AdalightLightEffect : public light::AddressableLightEffect, public uart::UARTDevice { class AdalightLightEffect : public light::AddressableLightEffect, public uart::UARTDevice {
public: public:
AdalightLightEffect(const std::string &name); AdalightLightEffect(const char *name);
void start() override; void start() override;
void stop() override; void stop() override;

View File

@@ -28,7 +28,7 @@ class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode
void dump_config() override; void dump_config() override;
climate::ClimateTraits traits() override { climate::ClimateTraits traits() override {
auto traits = climate::ClimateTraits(); auto traits = climate::ClimateTraits();
traits.set_supports_current_temperature(true); traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::ClimateMode::CLIMATE_MODE_HEAT}); traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::ClimateMode::CLIMATE_MODE_HEAT});
traits.set_visual_min_temperature(25.0); traits.set_visual_min_temperature(25.0);
traits.set_visual_max_temperature(100.0); traits.set_visual_max_temperature(100.0);

View File

@@ -155,6 +155,17 @@ def _validate_api_config(config: ConfigType) -> ConfigType:
return config return config
def _consume_api_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for API component."""
from esphome.components import socket
# API needs 1 listening socket + typically 3 concurrent client connections
# (not max_connections, which is the upper limit rarely reached)
sockets_needed = 1 + 3
socket.consume_sockets(sockets_needed, "api")(config)
return config
CONFIG_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All(
cv.Schema( cv.Schema(
{ {
@@ -222,6 +233,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA), ).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS), cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_validate_api_config, _validate_api_config,
_consume_api_sockets,
) )
@@ -380,12 +392,19 @@ async def homeassistant_service_to_code(
var = cg.new_Pvariable(action_id, template_arg, serv, False) var = cg.new_Pvariable(action_id, template_arg, serv, False)
templ = await cg.templatable(config[CONF_ACTION], args, None) templ = await cg.templatable(config[CONF_ACTION], args, None)
cg.add(var.set_service(templ)) cg.add(var.set_service(templ))
# Initialize FixedVectors with exact sizes from config
cg.add(var.init_data(len(config[CONF_DATA])))
for key, value in config[CONF_DATA].items(): for key, value in config[CONF_DATA].items():
templ = await cg.templatable(value, args, None) templ = await cg.templatable(value, args, None)
cg.add(var.add_data(key, templ)) cg.add(var.add_data(key, templ))
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
for key, value in config[CONF_DATA_TEMPLATE].items(): for key, value in config[CONF_DATA_TEMPLATE].items():
templ = await cg.templatable(value, args, None) templ = await cg.templatable(value, args, None)
cg.add(var.add_data_template(key, templ)) cg.add(var.add_data_template(key, templ))
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items(): for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None) templ = await cg.templatable(value, args, None)
cg.add(var.add_variable(key, templ)) cg.add(var.add_variable(key, templ))
@@ -458,15 +477,23 @@ async def homeassistant_event_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg, serv, True) var = cg.new_Pvariable(action_id, template_arg, serv, True)
templ = await cg.templatable(config[CONF_EVENT], args, None) templ = await cg.templatable(config[CONF_EVENT], args, None)
cg.add(var.set_service(templ)) cg.add(var.set_service(templ))
# Initialize FixedVectors with exact sizes from config
cg.add(var.init_data(len(config[CONF_DATA])))
for key, value in config[CONF_DATA].items(): for key, value in config[CONF_DATA].items():
templ = await cg.templatable(value, args, None) templ = await cg.templatable(value, args, None)
cg.add(var.add_data(key, templ)) cg.add(var.add_data(key, templ))
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
for key, value in config[CONF_DATA_TEMPLATE].items(): for key, value in config[CONF_DATA_TEMPLATE].items():
templ = await cg.templatable(value, args, None) templ = await cg.templatable(value, args, None)
cg.add(var.add_data_template(key, templ)) cg.add(var.add_data_template(key, templ))
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items(): for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None) templ = await cg.templatable(value, args, None)
cg.add(var.add_variable(key, templ)) cg.add(var.add_variable(key, templ))
return var return var
@@ -489,6 +516,8 @@ async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, arg
serv = await cg.get_variable(config[CONF_ID]) serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True) var = cg.new_Pvariable(action_id, template_arg, serv, True)
cg.add(var.set_service("esphome.tag_scanned")) cg.add(var.set_service("esphome.tag_scanned"))
# Initialize FixedVector with exact size (1 data field)
cg.add(var.init_data(1))
templ = await cg.templatable(config[CONF_TAG], args, cg.std_string) templ = await cg.templatable(config[CONF_TAG], args, cg.std_string)
cg.add(var.add_data("tag_id", templ)) cg.add(var.add_data("tag_id", templ))
return var return var

View File

@@ -425,7 +425,7 @@ message ListEntitiesFanResponse {
bool disabled_by_default = 9; bool disabled_by_default = 9;
string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"]; string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"];
EntityCategory entity_category = 11; EntityCategory entity_category = 11;
repeated string supported_preset_modes = 12 [(container_pointer) = "std::set"]; repeated string supported_preset_modes = 12 [(container_pointer) = "std::vector"];
uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"]; uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"];
} }
// Deprecated in API version 1.6 - only used in deprecated fields // Deprecated in API version 1.6 - only used in deprecated fields
@@ -506,7 +506,7 @@ message ListEntitiesLightResponse {
string name = 3; string name = 3;
reserved 4; // Deprecated: was string unique_id 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 // next four supports_* are for legacy clients, newer clients should use color modes
// Deprecated in API version 1.6 // Deprecated in API version 1.6
bool legacy_supports_brightness = 5 [deprecated=true]; bool legacy_supports_brightness = 5 [deprecated=true];
@@ -876,10 +876,10 @@ message ExecuteServiceArgument {
string string_ = 4; string string_ = 4;
// ESPHome 1.14 (api v1.3) make int a signed value // ESPHome 1.14 (api v1.3) make int a signed value
sint32 int_ = 5; sint32 int_ = 5;
repeated bool bool_array = 6 [packed=false]; repeated bool bool_array = 6 [packed=false, (fixed_vector) = true];
repeated sint32 int_array = 7 [packed=false]; repeated sint32 int_array = 7 [packed=false, (fixed_vector) = true];
repeated float float_array = 8 [packed=false]; repeated float float_array = 8 [packed=false, (fixed_vector) = true];
repeated string string_array = 9; repeated string string_array = 9 [(fixed_vector) = true];
} }
message ExecuteServiceRequest { message ExecuteServiceRequest {
option (id) = 42; option (id) = 42;
@@ -888,7 +888,7 @@ message ExecuteServiceRequest {
option (ifdef) = "USE_API_SERVICES"; option (ifdef) = "USE_API_SERVICES";
fixed32 key = 1; fixed32 key = 1;
repeated ExecuteServiceArgument args = 2; repeated ExecuteServiceArgument args = 2 [(fixed_vector) = true];
} }
// ==================== CAMERA ==================== // ==================== CAMERA ====================
@@ -987,8 +987,8 @@ message ListEntitiesClimateResponse {
string name = 3; string name = 3;
reserved 4; // Deprecated: was string unique_id reserved 4; // Deprecated: was string unique_id
bool supports_current_temperature = 5; bool supports_current_temperature = 5; // Deprecated: use feature_flags
bool supports_two_point_target_temperature = 6; bool supports_two_point_target_temperature = 6; // Deprecated: use feature_flags
repeated ClimateMode supported_modes = 7 [(container_pointer) = "std::set<climate::ClimateMode>"]; repeated ClimateMode supported_modes = 7 [(container_pointer) = "std::set<climate::ClimateMode>"];
float visual_min_temperature = 8; float visual_min_temperature = 8;
float visual_max_temperature = 9; float visual_max_temperature = 9;
@@ -997,7 +997,7 @@ message ListEntitiesClimateResponse {
// is if CLIMATE_PRESET_AWAY exists is supported_presets // is if CLIMATE_PRESET_AWAY exists is supported_presets
// Deprecated in API version 1.5 // Deprecated in API version 1.5
bool legacy_supports_away = 11 [deprecated=true]; bool legacy_supports_away = 11 [deprecated=true];
bool supports_action = 12; bool supports_action = 12; // Deprecated: use feature_flags
repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer) = "std::set<climate::ClimateFanMode>"]; repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer) = "std::set<climate::ClimateFanMode>"];
repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer) = "std::set<climate::ClimateSwingMode>"]; repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer) = "std::set<climate::ClimateSwingMode>"];
repeated string supported_custom_fan_modes = 15 [(container_pointer) = "std::set"]; repeated string supported_custom_fan_modes = 15 [(container_pointer) = "std::set"];
@@ -1007,11 +1007,12 @@ message ListEntitiesClimateResponse {
string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON"]; string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON"];
EntityCategory entity_category = 20; EntityCategory entity_category = 20;
float visual_current_temperature_step = 21; float visual_current_temperature_step = 21;
bool supports_current_humidity = 22; bool supports_current_humidity = 22; // Deprecated: use feature_flags
bool supports_target_humidity = 23; bool supports_target_humidity = 23; // Deprecated: use feature_flags
float visual_min_humidity = 24; float visual_min_humidity = 24;
float visual_max_humidity = 25; float visual_max_humidity = 25;
uint32 device_id = 26 [(field_ifdef) = "USE_DEVICES"]; uint32 device_id = 26 [(field_ifdef) = "USE_DEVICES"];
uint32 feature_flags = 27;
} }
message ClimateStateResponse { message ClimateStateResponse {
option (id) = 47; option (id) = 47;

View File

@@ -27,6 +27,9 @@
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
#endif #endif
#ifdef USE_CLIMATE
#include "esphome/components/climate/climate_mode.h"
#endif
#ifdef USE_VOICE_ASSISTANT #ifdef USE_VOICE_ASSISTANT
#include "esphome/components/voice_assistant/voice_assistant.h" #include "esphome/components/voice_assistant/voice_assistant.h"
#endif #endif
@@ -450,7 +453,6 @@ uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection *
bool is_single) { bool is_single) {
auto *light = static_cast<light::LightState *>(entity); auto *light = static_cast<light::LightState *>(entity);
LightStateResponse resp; LightStateResponse resp;
auto traits = light->get_traits();
auto values = light->remote_values; auto values = light->remote_values;
auto color_mode = values.get_color_mode(); auto color_mode = values.get_color_mode();
resp.state = values.is_on(); resp.state = values.is_on();
@@ -474,7 +476,8 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c
auto *light = static_cast<light::LightState *>(entity); auto *light = static_cast<light::LightState *>(entity);
ListEntitiesLightResponse msg; ListEntitiesLightResponse msg;
auto traits = light->get_traits(); 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) || if (traits.supports_color_capability(light::ColorCapability::COLOR_TEMPERATURE) ||
traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) { traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) {
msg.min_mireds = traits.get_min_mireds(); msg.min_mireds = traits.get_min_mireds();
@@ -623,9 +626,10 @@ uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection
auto traits = climate->get_traits(); auto traits = climate->get_traits();
resp.mode = static_cast<enums::ClimateMode>(climate->mode); resp.mode = static_cast<enums::ClimateMode>(climate->mode);
resp.action = static_cast<enums::ClimateAction>(climate->action); resp.action = static_cast<enums::ClimateAction>(climate->action);
if (traits.get_supports_current_temperature()) if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE))
resp.current_temperature = climate->current_temperature; resp.current_temperature = climate->current_temperature;
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)) {
resp.target_temperature_low = climate->target_temperature_low; resp.target_temperature_low = climate->target_temperature_low;
resp.target_temperature_high = climate->target_temperature_high; resp.target_temperature_high = climate->target_temperature_high;
} else { } else {
@@ -644,9 +648,9 @@ uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection
} }
if (traits.get_supports_swing_modes()) if (traits.get_supports_swing_modes())
resp.swing_mode = static_cast<enums::ClimateSwingMode>(climate->swing_mode); resp.swing_mode = static_cast<enums::ClimateSwingMode>(climate->swing_mode);
if (traits.get_supports_current_humidity()) if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY))
resp.current_humidity = climate->current_humidity; resp.current_humidity = climate->current_humidity;
if (traits.get_supports_target_humidity()) if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY))
resp.target_humidity = climate->target_humidity; resp.target_humidity = climate->target_humidity;
return fill_and_encode_entity_state(climate, resp, ClimateStateResponse::MESSAGE_TYPE, conn, remaining_size, return fill_and_encode_entity_state(climate, resp, ClimateStateResponse::MESSAGE_TYPE, conn, remaining_size,
is_single); is_single);
@@ -656,10 +660,15 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection
auto *climate = static_cast<climate::Climate *>(entity); auto *climate = static_cast<climate::Climate *>(entity);
ListEntitiesClimateResponse msg; ListEntitiesClimateResponse msg;
auto traits = climate->get_traits(); auto traits = climate->get_traits();
msg.supports_current_temperature = traits.get_supports_current_temperature(); // Flags set for backward compatibility, deprecated in 2025.11.0
msg.supports_current_humidity = traits.get_supports_current_humidity(); msg.supports_current_temperature = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
msg.supports_two_point_target_temperature = traits.get_supports_two_point_target_temperature(); msg.supports_current_humidity = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY);
msg.supports_target_humidity = traits.get_supports_target_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_(); msg.supported_modes = &traits.get_supported_modes_for_api_();
msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_min_temperature = traits.get_visual_min_temperature();
msg.visual_max_temperature = traits.get_visual_max_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature();
@@ -667,7 +676,6 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection
msg.visual_current_temperature_step = traits.get_visual_current_temperature_step(); msg.visual_current_temperature_step = traits.get_visual_current_temperature_step();
msg.visual_min_humidity = traits.get_visual_min_humidity(); msg.visual_min_humidity = traits.get_visual_min_humidity();
msg.visual_max_humidity = traits.get_visual_max_humidity(); msg.visual_max_humidity = traits.get_visual_max_humidity();
msg.supports_action = traits.get_supports_action();
msg.supported_fan_modes = &traits.get_supported_fan_modes_for_api_(); msg.supported_fan_modes = &traits.get_supported_fan_modes_for_api_();
msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes_for_api_(); msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes_for_api_();
msg.supported_presets = &traits.get_supported_presets_for_api_(); msg.supported_presets = &traits.get_supported_presets_for_api_();
@@ -1074,13 +1082,8 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) {
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
#ifdef USE_TIME_TIMEZONE #ifdef USE_TIME_TIMEZONE
if (value.timezone_len > 0) { if (value.timezone_len > 0) {
const std::string &current_tz = homeassistant::global_homeassistant_time->get_timezone(); homeassistant::global_homeassistant_time->set_timezone(reinterpret_cast<const char *>(value.timezone),
// Compare without allocating a string value.timezone_len);
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));
}
} }
#endif #endif
} }
@@ -1406,7 +1409,7 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) {
HelloResponse resp; HelloResponse resp;
resp.api_version_major = 1; resp.api_version_major = 1;
resp.api_version_minor = 12; resp.api_version_minor = 13;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise // Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.set_server_info(ESPHOME_VERSION_REF); resp.set_server_info(ESPHOME_VERSION_REF);
resp.set_name(StringRef(App.get_name())); resp.set_name(StringRef(App.get_name()));
@@ -1569,7 +1572,13 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption
resp.success = false; resp.success = false;
psk_t psk{}; psk_t psk{};
if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) { if (msg.key.empty()) {
if (this->parent_->clear_noise_psk(true)) {
resp.success = true;
} else {
ESP_LOGW(TAG, "Failed to clear encryption key");
}
} else if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) {
ESP_LOGW(TAG, "Invalid encryption key length"); ESP_LOGW(TAG, "Invalid encryption key length");
} else if (!this->parent_->save_noise_psk(psk, true)) { } else if (!this->parent_->save_noise_psk(psk, true)) {
ESP_LOGW(TAG, "Failed to save encryption key"); ESP_LOGW(TAG, "Failed to save encryption key");

View File

@@ -242,7 +242,6 @@ APIError APINoiseFrameHelper::state_action_() {
const std::string &name = App.get_name(); const std::string &name = App.get_name();
const std::string &mac = get_mac_address(); const std::string &mac = get_mac_address();
std::vector<uint8_t> msg;
// Calculate positions and sizes // Calculate positions and sizes
size_t name_len = name.size() + 1; // including null terminator size_t name_len = name.size() + 1; // including null terminator
size_t mac_len = mac.size() + 1; // including null terminator size_t mac_len = mac.size() + 1; // including null terminator
@@ -250,17 +249,17 @@ APIError APINoiseFrameHelper::state_action_() {
size_t mac_offset = name_offset + name_len; size_t mac_offset = name_offset + name_len;
size_t total_size = 1 + name_len + mac_len; size_t total_size = 1 + name_len + mac_len;
msg.resize(total_size); auto msg = std::make_unique<uint8_t[]>(total_size);
// chosen proto // chosen proto
msg[0] = 0x01; msg[0] = 0x01;
// node name, terminated by null byte // node name, terminated by null byte
std::memcpy(msg.data() + name_offset, name.c_str(), name_len); std::memcpy(msg.get() + name_offset, name.c_str(), name_len);
// node mac, terminated by null byte // node mac, terminated by null byte
std::memcpy(msg.data() + mac_offset, mac.c_str(), mac_len); std::memcpy(msg.get() + mac_offset, mac.c_str(), mac_len);
aerr = write_frame_(msg.data(), msg.size()); aerr = write_frame_(msg.get(), total_size);
if (aerr != APIError::OK) if (aerr != APIError::OK)
return aerr; return aerr;
@@ -339,32 +338,32 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso
#ifdef USE_STORE_LOG_STR_IN_FLASH #ifdef USE_STORE_LOG_STR_IN_FLASH
// On ESP8266 with flash strings, we need to use PROGMEM-aware functions // On ESP8266 with flash strings, we need to use PROGMEM-aware functions
size_t reason_len = strlen_P(reinterpret_cast<PGM_P>(reason)); size_t reason_len = strlen_P(reinterpret_cast<PGM_P>(reason));
std::vector<uint8_t> data; size_t data_size = reason_len + 1;
data.resize(reason_len + 1); auto data = std::make_unique<uint8_t[]>(data_size);
data[0] = 0x01; // failure data[0] = 0x01; // failure
// Copy error message from PROGMEM // Copy error message from PROGMEM
if (reason_len > 0) { if (reason_len > 0) {
memcpy_P(data.data() + 1, reinterpret_cast<PGM_P>(reason), reason_len); memcpy_P(data.get() + 1, reinterpret_cast<PGM_P>(reason), reason_len);
} }
#else #else
// Normal memory access // Normal memory access
const char *reason_str = LOG_STR_ARG(reason); const char *reason_str = LOG_STR_ARG(reason);
size_t reason_len = strlen(reason_str); size_t reason_len = strlen(reason_str);
std::vector<uint8_t> data; size_t data_size = reason_len + 1;
data.resize(reason_len + 1); auto data = std::make_unique<uint8_t[]>(data_size);
data[0] = 0x01; // failure data[0] = 0x01; // failure
// Copy error message in bulk // Copy error message in bulk
if (reason_len > 0) { if (reason_len > 0) {
std::memcpy(data.data() + 1, reason_str, reason_len); std::memcpy(data.get() + 1, reason_str, reason_len);
} }
#endif #endif
// temporarily remove failed state // temporarily remove failed state
auto orig_state = state_; auto orig_state = state_;
state_ = State::EXPLICIT_REJECT; state_ = State::EXPLICIT_REJECT;
write_frame_(data.data(), data.size()); write_frame_(data.get(), data_size);
state_ = orig_state; state_ = orig_state;
} }
APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {

View File

@@ -70,4 +70,14 @@ extend google.protobuf.FieldOptions {
// init(size) before adding elements. This eliminates std::vector template overhead // init(size) before adding elements. This eliminates std::vector template overhead
// and is ideal when the exact size is known before populating the array. // and is ideal when the exact size is known before populating the array.
optional bool fixed_vector = 50013 [default=false]; 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

@@ -1064,6 +1064,17 @@ bool ExecuteServiceArgument::decode_32bit(uint32_t field_id, Proto32Bit value) {
} }
return true; return true;
} }
void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) {
uint32_t count_bool_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 6);
this->bool_array.init(count_bool_array);
uint32_t count_int_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 7);
this->int_array.init(count_int_array);
uint32_t count_float_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 8);
this->float_array.init(count_float_array);
uint32_t count_string_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 9);
this->string_array.init(count_string_array);
ProtoDecodableMessage::decode(buffer, length);
}
bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
switch (field_id) { switch (field_id) {
case 2: case 2:
@@ -1085,6 +1096,11 @@ bool ExecuteServiceRequest::decode_32bit(uint32_t field_id, Proto32Bit value) {
} }
return true; return true;
} }
void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) {
uint32_t count_args = ProtoDecodableMessage::count_repeated_field(buffer, length, 2);
this->args.init(count_args);
ProtoDecodableMessage::decode(buffer, length);
}
#endif #endif
#ifdef USE_CAMERA #ifdef USE_CAMERA
void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const {
@@ -1185,6 +1201,7 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const {
#ifdef USE_DEVICES #ifdef USE_DEVICES
buffer.encode_uint32(26, this->device_id); buffer.encode_uint32(26, this->device_id);
#endif #endif
buffer.encode_uint32(27, this->feature_flags);
} }
void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const {
size.add_length(1, this->object_id_ref_.size()); size.add_length(1, this->object_id_ref_.size());
@@ -1239,6 +1256,7 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const {
#ifdef USE_DEVICES #ifdef USE_DEVICES
size.add_uint32(2, this->device_id); size.add_uint32(2, this->device_id);
#endif #endif
size.add_uint32(2, this->feature_flags);
} }
void ClimateStateResponse::encode(ProtoWriteBuffer buffer) const { void ClimateStateResponse::encode(ProtoWriteBuffer buffer) const {
buffer.encode_fixed32(1, this->key); buffer.encode_fixed32(1, this->key);

View File

@@ -725,7 +725,7 @@ class ListEntitiesFanResponse final : public InfoResponseProtoMessage {
bool supports_speed{false}; bool supports_speed{false};
bool supports_direction{false}; bool supports_direction{false};
int32_t supported_speed_count{0}; int32_t supported_speed_count{0};
const std::set<std::string> *supported_preset_modes{}; const std::vector<std::string> *supported_preset_modes{};
void encode(ProtoWriteBuffer buffer) const override; void encode(ProtoWriteBuffer buffer) const override;
void calculate_size(ProtoSize &size) const override; void calculate_size(ProtoSize &size) const override;
#ifdef HAS_PROTO_MESSAGE_DUMP #ifdef HAS_PROTO_MESSAGE_DUMP
@@ -790,7 +790,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage {
#ifdef HAS_PROTO_MESSAGE_DUMP #ifdef HAS_PROTO_MESSAGE_DUMP
const char *message_name() const override { return "list_entities_light_response"; } const char *message_name() const override { return "list_entities_light_response"; }
#endif #endif
const std::set<light::ColorMode> *supported_color_modes{}; const light::ColorModeMask *supported_color_modes{};
float min_mireds{0.0f}; float min_mireds{0.0f};
float max_mireds{0.0f}; float max_mireds{0.0f};
std::vector<std::string> effects{}; std::vector<std::string> effects{};
@@ -1279,10 +1279,11 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage {
float float_{0.0f}; float float_{0.0f};
std::string string_{}; std::string string_{};
int32_t int_{0}; int32_t int_{0};
std::vector<bool> bool_array{}; FixedVector<bool> bool_array{};
std::vector<int32_t> int_array{}; FixedVector<int32_t> int_array{};
std::vector<float> float_array{}; FixedVector<float> float_array{};
std::vector<std::string> string_array{}; FixedVector<std::string> string_array{};
void decode(const uint8_t *buffer, size_t length) override;
#ifdef HAS_PROTO_MESSAGE_DUMP #ifdef HAS_PROTO_MESSAGE_DUMP
void dump_to(std::string &out) const override; void dump_to(std::string &out) const override;
#endif #endif
@@ -1300,7 +1301,8 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage {
const char *message_name() const override { return "execute_service_request"; } const char *message_name() const override { return "execute_service_request"; }
#endif #endif
uint32_t key{0}; uint32_t key{0};
std::vector<ExecuteServiceArgument> args{}; FixedVector<ExecuteServiceArgument> args{};
void decode(const uint8_t *buffer, size_t length) override;
#ifdef HAS_PROTO_MESSAGE_DUMP #ifdef HAS_PROTO_MESSAGE_DUMP
void dump_to(std::string &out) const override; void dump_to(std::string &out) const override;
#endif #endif
@@ -1369,7 +1371,7 @@ class CameraImageRequest final : public ProtoDecodableMessage {
class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
public: public:
static constexpr uint8_t MESSAGE_TYPE = 46; static constexpr uint8_t MESSAGE_TYPE = 46;
static constexpr uint8_t ESTIMATED_SIZE = 145; static constexpr uint8_t ESTIMATED_SIZE = 150;
#ifdef HAS_PROTO_MESSAGE_DUMP #ifdef HAS_PROTO_MESSAGE_DUMP
const char *message_name() const override { return "list_entities_climate_response"; } const char *message_name() const override { return "list_entities_climate_response"; }
#endif #endif
@@ -1390,6 +1392,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
bool supports_target_humidity{false}; bool supports_target_humidity{false};
float visual_min_humidity{0.0f}; float visual_min_humidity{0.0f};
float visual_max_humidity{0.0f}; float visual_max_humidity{0.0f};
uint32_t feature_flags{0};
void encode(ProtoWriteBuffer buffer) const override; void encode(ProtoWriteBuffer buffer) const override;
void calculate_size(ProtoSize &size) const override; void calculate_size(ProtoSize &size) const override;
#ifdef HAS_PROTO_MESSAGE_DUMP #ifdef HAS_PROTO_MESSAGE_DUMP

View File

@@ -1292,6 +1292,7 @@ void ListEntitiesClimateResponse::dump_to(std::string &out) const {
#ifdef USE_DEVICES #ifdef USE_DEVICES
dump_field(out, "device_id", this->device_id); dump_field(out, "device_id", this->device_id);
#endif #endif
dump_field(out, "feature_flags", this->feature_flags);
} }
void ClimateStateResponse::dump_to(std::string &out) const { void ClimateStateResponse::dump_to(std::string &out) const {
MessageDumpHelper helper(out, "ClimateStateResponse"); MessageDumpHelper helper(out, "ClimateStateResponse");

View File

@@ -468,6 +468,31 @@ uint16_t APIServer::get_port() const { return this->port_; }
void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
#ifdef USE_API_NOISE #ifdef USE_API_NOISE
bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg,
const LogString *fail_log_msg, const psk_t &active_psk, bool make_active) {
if (!this->noise_pref_.save(&new_psk)) {
ESP_LOGW(TAG, "%s", LOG_STR_ARG(fail_log_msg));
return false;
}
// ensure it's written immediately
if (!global_preferences->sync()) {
ESP_LOGW(TAG, "Failed to sync preferences");
return false;
}
ESP_LOGD(TAG, "%s", LOG_STR_ARG(save_log_msg));
if (make_active) {
this->set_timeout(100, [this, active_psk]() {
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
this->set_noise_psk(active_psk);
for (auto &c : this->clients_) {
DisconnectRequest req;
c->send_message(req, DisconnectRequest::MESSAGE_TYPE);
}
});
}
return true;
}
bool APIServer::save_noise_psk(psk_t psk, bool make_active) { bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
#ifdef USE_API_NOISE_PSK_FROM_YAML #ifdef USE_API_NOISE_PSK_FROM_YAML
// When PSK is set from YAML, this function should never be called // When PSK is set from YAML, this function should never be called
@@ -482,27 +507,21 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
} }
SavedNoisePsk new_saved_psk{psk}; SavedNoisePsk new_saved_psk{psk};
if (!this->noise_pref_.save(&new_saved_psk)) { return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"), psk,
ESP_LOGW(TAG, "Failed to save Noise PSK"); make_active);
return false; #endif
} }
// ensure it's written immediately bool APIServer::clear_noise_psk(bool make_active) {
if (!global_preferences->sync()) { #ifdef USE_API_NOISE_PSK_FROM_YAML
ESP_LOGW(TAG, "Failed to sync preferences"); // When PSK is set from YAML, this function should never be called
return false; // but if it is, reject the change
} ESP_LOGW(TAG, "Key set in YAML");
ESP_LOGD(TAG, "Noise PSK saved"); return false;
if (make_active) { #else
this->set_timeout(100, [this, psk]() { SavedNoisePsk empty_psk{};
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); psk_t empty{};
this->set_noise_psk(psk); return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), empty,
for (auto &c : this->clients_) { make_active);
DisconnectRequest req;
c->send_message(req, DisconnectRequest::MESSAGE_TYPE);
}
});
}
return true;
#endif #endif
} }
#endif #endif

View File

@@ -53,6 +53,7 @@ class APIServer : public Component, public Controller {
#ifdef USE_API_NOISE #ifdef USE_API_NOISE
bool save_noise_psk(psk_t psk, bool make_active = true); bool save_noise_psk(psk_t psk, bool make_active = true);
bool clear_noise_psk(bool make_active = true);
void set_noise_psk(psk_t psk) { noise_ctx_->set_psk(psk); } void set_noise_psk(psk_t psk) { noise_ctx_->set_psk(psk); }
std::shared_ptr<APINoiseContext> get_noise_ctx() { return noise_ctx_; } std::shared_ptr<APINoiseContext> get_noise_ctx() { return noise_ctx_; }
#endif // USE_API_NOISE #endif // USE_API_NOISE
@@ -174,6 +175,10 @@ class APIServer : public Component, public Controller {
protected: protected:
void schedule_reboot_timeout_(); void schedule_reboot_timeout_();
#ifdef USE_API_NOISE
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
const psk_t &active_psk, bool make_active);
#endif // USE_API_NOISE
// Pointers and pointer-like types first (4 bytes each) // Pointers and pointer-like types first (4 bytes each)
std::unique_ptr<socket::Socket> socket_ = nullptr; std::unique_ptr<socket::Socket> socket_ = nullptr;
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER #ifdef USE_API_CLIENT_CONNECTED_TRIGGER

View File

@@ -41,10 +41,14 @@ template<typename... X> class TemplatableStringValue : public TemplatableValue<s
template<typename... Ts> class TemplatableKeyValuePair { template<typename... Ts> class TemplatableKeyValuePair {
public: public:
// Default constructor needed for FixedVector::emplace_back()
TemplatableKeyValuePair() = default;
// Keys are always string literals from YAML dictionary keys (e.g., "code", "event") // Keys are always string literals from YAML dictionary keys (e.g., "code", "event")
// and never templatable values or lambdas. Only the value parameter can be a lambda/template. // and never templatable values or lambdas. Only the value parameter can be a lambda/template.
// Using pass-by-value with std::move allows optimal performance for both lvalues and rvalues. // Using pass-by-value with std::move allows optimal performance for both lvalues and rvalues.
template<typename T> TemplatableKeyValuePair(std::string key, T value) : key(std::move(key)), value(value) {} template<typename T> TemplatableKeyValuePair(std::string key, T value) : key(std::move(key)), value(value) {}
std::string key; std::string key;
TemplatableStringValue<Ts...> value; TemplatableStringValue<Ts...> value;
}; };
@@ -93,15 +97,22 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
template<typename T> void set_service(T service) { this->service_ = service; } template<typename T> void set_service(T service) { this->service_ = service; }
// Initialize FixedVector members - called from Python codegen with compile-time known sizes.
// Must be called before any add_* methods; capacity must match the number of subsequent add_* calls.
void init_data(size_t count) { this->data_.init(count); }
void init_data_template(size_t count) { this->data_template_.init(count); }
void init_variables(size_t count) { this->variables_.init(count); }
// Keys are always string literals from the Python code generation (e.g., cg.add(var.add_data("tag_id", templ))). // Keys are always string literals from the Python code generation (e.g., cg.add(var.add_data("tag_id", templ))).
// The value parameter can be a lambda/template, but keys are never templatable. // The value parameter can be a lambda/template, but keys are never templatable.
// Using pass-by-value allows the compiler to optimize for both lvalues and rvalues. template<typename K, typename V> void add_data(K &&key, V &&value) {
template<typename T> void add_data(std::string key, T value) { this->data_.emplace_back(std::move(key), value); } this->add_kv_(this->data_, std::forward<K>(key), std::forward<V>(value));
template<typename T> void add_data_template(std::string key, T value) {
this->data_template_.emplace_back(std::move(key), value);
} }
template<typename T> void add_variable(std::string key, T value) { template<typename K, typename V> void add_data_template(K &&key, V &&value) {
this->variables_.emplace_back(std::move(key), value); this->add_kv_(this->data_template_, std::forward<K>(key), std::forward<V>(value));
}
template<typename K, typename V> void add_variable(K &&key, V &&value) {
this->add_kv_(this->variables_, std::forward<K>(key), std::forward<V>(value));
} }
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
@@ -174,6 +185,13 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
} }
protected: protected:
// Helper to add key-value pairs to FixedVectors with perfect forwarding to avoid copies
template<typename K, typename V> void add_kv_(FixedVector<TemplatableKeyValuePair<Ts...>> &vec, K &&key, V &&value) {
auto &kv = vec.emplace_back();
kv.key = std::forward<K>(key);
kv.value = std::forward<V>(value);
}
template<typename VectorType, typename SourceType> template<typename VectorType, typename SourceType>
static void populate_service_map(VectorType &dest, SourceType &source, Ts... x) { static void populate_service_map(VectorType &dest, SourceType &source, Ts... x) {
dest.init(source.size()); dest.init(source.size());
@@ -186,9 +204,9 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
APIServer *parent_; APIServer *parent_;
TemplatableStringValue<Ts...> service_{}; TemplatableStringValue<Ts...> service_{};
std::vector<TemplatableKeyValuePair<Ts...>> data_; FixedVector<TemplatableKeyValuePair<Ts...>> data_;
std::vector<TemplatableKeyValuePair<Ts...>> data_template_; FixedVector<TemplatableKeyValuePair<Ts...>> data_template_;
std::vector<TemplatableKeyValuePair<Ts...>> variables_; FixedVector<TemplatableKeyValuePair<Ts...>> variables_;
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
TemplatableStringValue<Ts...> response_template_{""}; TemplatableStringValue<Ts...> response_template_{""};

View File

@@ -7,6 +7,69 @@ namespace esphome::api {
static const char *const TAG = "api.proto"; static const char *const TAG = "api.proto";
uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size_t length, uint32_t target_field_id) {
uint32_t count = 0;
const uint8_t *ptr = buffer;
const uint8_t *end = buffer + length;
while (ptr < end) {
uint32_t consumed;
// Parse field header (tag)
auto res = ProtoVarInt::parse(ptr, end - ptr, &consumed);
if (!res.has_value()) {
break; // Invalid data, stop counting
}
uint32_t tag = res->as_uint32();
uint32_t field_type = tag & WIRE_TYPE_MASK;
uint32_t field_id = tag >> 3;
ptr += consumed;
// Count if this is the target field
if (field_id == target_field_id) {
count++;
}
// Skip field data based on wire type
switch (field_type) {
case WIRE_TYPE_VARINT: { // VarInt - parse and skip
res = ProtoVarInt::parse(ptr, end - ptr, &consumed);
if (!res.has_value()) {
return count; // Invalid data, return what we have
}
ptr += consumed;
break;
}
case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited - parse length and skip data
res = ProtoVarInt::parse(ptr, end - ptr, &consumed);
if (!res.has_value()) {
return count;
}
uint32_t field_length = res->as_uint32();
ptr += consumed;
if (ptr + field_length > end) {
return count; // Out of bounds
}
ptr += field_length;
break;
}
case WIRE_TYPE_FIXED32: { // 32-bit - skip 4 bytes
if (ptr + 4 > end) {
return count;
}
ptr += 4;
break;
}
default:
// Unknown wire type, can't continue
return count;
}
}
return count;
}
void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) {
const uint8_t *ptr = buffer; const uint8_t *ptr = buffer;
const uint8_t *end = buffer + length; const uint8_t *end = buffer + length;
@@ -22,12 +85,12 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) {
} }
uint32_t tag = res->as_uint32(); uint32_t tag = res->as_uint32();
uint32_t field_type = tag & 0b111; uint32_t field_type = tag & WIRE_TYPE_MASK;
uint32_t field_id = tag >> 3; uint32_t field_id = tag >> 3;
ptr += consumed; ptr += consumed;
switch (field_type) { switch (field_type) {
case 0: { // VarInt case WIRE_TYPE_VARINT: { // VarInt
res = ProtoVarInt::parse(ptr, end - ptr, &consumed); res = ProtoVarInt::parse(ptr, end - ptr, &consumed);
if (!res.has_value()) { if (!res.has_value()) {
ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer)); ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer));
@@ -39,7 +102,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) {
ptr += consumed; ptr += consumed;
break; break;
} }
case 2: { // Length-delimited case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited
res = ProtoVarInt::parse(ptr, end - ptr, &consumed); res = ProtoVarInt::parse(ptr, end - ptr, &consumed);
if (!res.has_value()) { if (!res.has_value()) {
ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer)); ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer));
@@ -57,7 +120,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) {
ptr += field_length; ptr += field_length;
break; break;
} }
case 5: { // 32-bit case WIRE_TYPE_FIXED32: { // 32-bit
if (ptr + 4 > end) { if (ptr + 4 > end) {
ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer)); ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer));
return; return;

View File

@@ -15,6 +15,13 @@
namespace esphome::api { namespace esphome::api {
// Protocol Buffer wire type constants
// See https://protobuf.dev/programming-guides/encoding/#structure
constexpr uint8_t WIRE_TYPE_VARINT = 0; // int32, int64, uint32, uint64, sint32, sint64, bool, enum
constexpr uint8_t WIRE_TYPE_LENGTH_DELIMITED = 2; // string, bytes, embedded messages, packed repeated fields
constexpr uint8_t WIRE_TYPE_FIXED32 = 5; // fixed32, sfixed32, float
constexpr uint8_t WIRE_TYPE_MASK = 0b111; // Mask to extract wire type from tag
// Helper functions for ZigZag encoding/decoding // Helper functions for ZigZag encoding/decoding
inline constexpr uint32_t encode_zigzag32(int32_t value) { inline constexpr uint32_t encode_zigzag32(int32_t value) {
return (static_cast<uint32_t>(value) << 1) ^ (static_cast<uint32_t>(value >> 31)); return (static_cast<uint32_t>(value) << 1) ^ (static_cast<uint32_t>(value >> 31));
@@ -241,7 +248,7 @@ class ProtoWriteBuffer {
* Following https://protobuf.dev/programming-guides/encoding/#structure * Following https://protobuf.dev/programming-guides/encoding/#structure
*/ */
void encode_field_raw(uint32_t field_id, uint32_t type) { void encode_field_raw(uint32_t field_id, uint32_t type) {
uint32_t val = (field_id << 3) | (type & 0b111); uint32_t val = (field_id << 3) | (type & WIRE_TYPE_MASK);
this->encode_varint_raw(val); this->encode_varint_raw(val);
} }
void encode_string(uint32_t field_id, const char *string, size_t len, bool force = false) { void encode_string(uint32_t field_id, const char *string, size_t len, bool force = false) {
@@ -354,7 +361,18 @@ class ProtoMessage {
// Base class for messages that support decoding // Base class for messages that support decoding
class ProtoDecodableMessage : public ProtoMessage { class ProtoDecodableMessage : public ProtoMessage {
public: public:
void decode(const uint8_t *buffer, size_t length); virtual void decode(const uint8_t *buffer, size_t length);
/**
* Count occurrences of a repeated field in a protobuf buffer.
* This is a lightweight scan that only parses tags and skips field data.
*
* @param buffer Pointer to the protobuf buffer
* @param length Length of the buffer in bytes
* @param target_field_id The field ID to count
* @return Number of times the field appears in the buffer
*/
static uint32_t count_repeated_field(const uint8_t *buffer, size_t length, uint32_t target_field_id);
protected: protected:
virtual bool decode_varint(uint32_t field_id, ProtoVarInt value) { return false; } virtual bool decode_varint(uint32_t field_id, ProtoVarInt value) { return false; }
@@ -482,7 +500,7 @@ class ProtoSize {
* @return The number of bytes needed to encode the field ID and wire type * @return The number of bytes needed to encode the field ID and wire type
*/ */
static constexpr uint32_t field(uint32_t field_id, uint32_t type) { static constexpr uint32_t field(uint32_t field_id, uint32_t type) {
uint32_t tag = (field_id << 3) | (type & 0b111); uint32_t tag = (field_id << 3) | (type & WIRE_TYPE_MASK);
return varint(tag); return varint(tag);
} }

View File

@@ -12,16 +12,16 @@ template<> int32_t get_execute_arg_value<int32_t>(const ExecuteServiceArgument &
template<> float get_execute_arg_value<float>(const ExecuteServiceArgument &arg) { return arg.float_; } template<> float get_execute_arg_value<float>(const ExecuteServiceArgument &arg) { return arg.float_; }
template<> std::string get_execute_arg_value<std::string>(const ExecuteServiceArgument &arg) { return arg.string_; } template<> std::string get_execute_arg_value<std::string>(const ExecuteServiceArgument &arg) { return arg.string_; }
template<> std::vector<bool> get_execute_arg_value<std::vector<bool>>(const ExecuteServiceArgument &arg) { template<> std::vector<bool> get_execute_arg_value<std::vector<bool>>(const ExecuteServiceArgument &arg) {
return arg.bool_array; return std::vector<bool>(arg.bool_array.begin(), arg.bool_array.end());
} }
template<> std::vector<int32_t> get_execute_arg_value<std::vector<int32_t>>(const ExecuteServiceArgument &arg) { template<> std::vector<int32_t> get_execute_arg_value<std::vector<int32_t>>(const ExecuteServiceArgument &arg) {
return arg.int_array; return std::vector<int32_t>(arg.int_array.begin(), arg.int_array.end());
} }
template<> std::vector<float> get_execute_arg_value<std::vector<float>>(const ExecuteServiceArgument &arg) { template<> std::vector<float> get_execute_arg_value<std::vector<float>>(const ExecuteServiceArgument &arg) {
return arg.float_array; return std::vector<float>(arg.float_array.begin(), arg.float_array.end());
} }
template<> std::vector<std::string> get_execute_arg_value<std::vector<std::string>>(const ExecuteServiceArgument &arg) { template<> std::vector<std::string> get_execute_arg_value<std::vector<std::string>>(const ExecuteServiceArgument &arg) {
return arg.string_array; return std::vector<std::string>(arg.string_array.begin(), arg.string_array.end());
} }
template<> enums::ServiceArgType to_service_arg_type<bool>() { return enums::SERVICE_ARG_TYPE_BOOL; } template<> enums::ServiceArgType to_service_arg_type<bool>() { return enums::SERVICE_ARG_TYPE_BOOL; }

View File

@@ -55,7 +55,7 @@ template<typename... Ts> class UserServiceBase : public UserServiceDescriptor {
protected: protected:
virtual void execute(Ts... x) = 0; virtual void execute(Ts... x) = 0;
template<int... S> void execute_(const std::vector<ExecuteServiceArgument> &args, seq<S...> type) { template<typename ArgsContainer, int... S> void execute_(const ArgsContainer &args, seq<S...> type) {
this->execute((get_execute_arg_value<Ts>(args[S]))...); this->execute((get_execute_arg_value<Ts>(args[S]))...);
} }

View File

@@ -6,6 +6,9 @@ namespace bang_bang {
static const char *const TAG = "bang_bang.climate"; static const char *const TAG = "bang_bang.climate";
BangBangClimate::BangBangClimate()
: idle_trigger_(new Trigger<>()), cool_trigger_(new Trigger<>()), heat_trigger_(new Trigger<>()) {}
void BangBangClimate::setup() { void BangBangClimate::setup() {
this->sensor_->add_on_state_callback([this](float state) { this->sensor_->add_on_state_callback([this](float state) {
this->current_temperature = state; this->current_temperature = state;
@@ -31,53 +34,63 @@ void BangBangClimate::setup() {
restore->to_call(this).perform(); restore->to_call(this).perform();
} else { } else {
// restore from defaults, change_away handles those for us // restore from defaults, change_away handles those for us
if (supports_cool_ && supports_heat_) { if (this->supports_cool_ && this->supports_heat_) {
this->mode = climate::CLIMATE_MODE_HEAT_COOL; this->mode = climate::CLIMATE_MODE_HEAT_COOL;
} else if (supports_cool_) { } else if (this->supports_cool_) {
this->mode = climate::CLIMATE_MODE_COOL; this->mode = climate::CLIMATE_MODE_COOL;
} else if (supports_heat_) { } else if (this->supports_heat_) {
this->mode = climate::CLIMATE_MODE_HEAT; this->mode = climate::CLIMATE_MODE_HEAT;
} }
this->change_away_(false); this->change_away_(false);
} }
} }
void BangBangClimate::control(const climate::ClimateCall &call) { void BangBangClimate::control(const climate::ClimateCall &call) {
if (call.get_mode().has_value()) if (call.get_mode().has_value()) {
this->mode = *call.get_mode(); this->mode = *call.get_mode();
if (call.get_target_temperature_low().has_value()) }
if (call.get_target_temperature_low().has_value()) {
this->target_temperature_low = *call.get_target_temperature_low(); this->target_temperature_low = *call.get_target_temperature_low();
if (call.get_target_temperature_high().has_value()) }
if (call.get_target_temperature_high().has_value()) {
this->target_temperature_high = *call.get_target_temperature_high(); this->target_temperature_high = *call.get_target_temperature_high();
if (call.get_preset().has_value()) }
if (call.get_preset().has_value()) {
this->change_away_(*call.get_preset() == climate::CLIMATE_PRESET_AWAY); this->change_away_(*call.get_preset() == climate::CLIMATE_PRESET_AWAY);
}
this->compute_state_(); this->compute_state_();
this->publish_state(); this->publish_state();
} }
climate::ClimateTraits BangBangClimate::traits() { climate::ClimateTraits BangBangClimate::traits() {
auto traits = climate::ClimateTraits(); auto traits = climate::ClimateTraits();
traits.set_supports_current_temperature(true); traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE |
if (this->humidity_sensor_ != nullptr) climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION);
traits.set_supports_current_humidity(true); if (this->humidity_sensor_ != nullptr) {
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY);
}
traits.set_supported_modes({ traits.set_supported_modes({
climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_OFF,
}); });
if (supports_cool_) if (this->supports_cool_) {
traits.add_supported_mode(climate::CLIMATE_MODE_COOL); traits.add_supported_mode(climate::CLIMATE_MODE_COOL);
if (supports_heat_) }
if (this->supports_heat_) {
traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); traits.add_supported_mode(climate::CLIMATE_MODE_HEAT);
if (supports_cool_ && supports_heat_) }
if (this->supports_cool_ && this->supports_heat_) {
traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL);
traits.set_supports_two_point_target_temperature(true); }
if (supports_away_) { if (this->supports_away_) {
traits.set_supported_presets({ traits.set_supported_presets({
climate::CLIMATE_PRESET_HOME, climate::CLIMATE_PRESET_HOME,
climate::CLIMATE_PRESET_AWAY, climate::CLIMATE_PRESET_AWAY,
}); });
} }
traits.set_supports_action(true);
return traits; return traits;
} }
void BangBangClimate::compute_state_() { void BangBangClimate::compute_state_() {
if (this->mode == climate::CLIMATE_MODE_OFF) { if (this->mode == climate::CLIMATE_MODE_OFF) {
this->switch_to_action_(climate::CLIMATE_ACTION_OFF); this->switch_to_action_(climate::CLIMATE_ACTION_OFF);
@@ -122,6 +135,7 @@ void BangBangClimate::compute_state_() {
this->switch_to_action_(target_action); this->switch_to_action_(target_action);
} }
void BangBangClimate::switch_to_action_(climate::ClimateAction action) { void BangBangClimate::switch_to_action_(climate::ClimateAction action) {
if (action == this->action) { if (action == this->action) {
// already in target mode // already in target mode
@@ -166,6 +180,7 @@ void BangBangClimate::switch_to_action_(climate::ClimateAction action) {
this->prev_trigger_ = trig; this->prev_trigger_ = trig;
this->publish_state(); this->publish_state();
} }
void BangBangClimate::change_away_(bool away) { void BangBangClimate::change_away_(bool away) {
if (!away) { if (!away) {
this->target_temperature_low = this->normal_config_.default_temperature_low; this->target_temperature_low = this->normal_config_.default_temperature_low;
@@ -176,22 +191,26 @@ void BangBangClimate::change_away_(bool away) {
} }
this->preset = away ? climate::CLIMATE_PRESET_AWAY : climate::CLIMATE_PRESET_HOME; this->preset = away ? climate::CLIMATE_PRESET_AWAY : climate::CLIMATE_PRESET_HOME;
} }
void BangBangClimate::set_normal_config(const BangBangClimateTargetTempConfig &normal_config) { void BangBangClimate::set_normal_config(const BangBangClimateTargetTempConfig &normal_config) {
this->normal_config_ = normal_config; this->normal_config_ = normal_config;
} }
void BangBangClimate::set_away_config(const BangBangClimateTargetTempConfig &away_config) { void BangBangClimate::set_away_config(const BangBangClimateTargetTempConfig &away_config) {
this->supports_away_ = true; this->supports_away_ = true;
this->away_config_ = away_config; this->away_config_ = away_config;
} }
BangBangClimate::BangBangClimate()
: idle_trigger_(new Trigger<>()), cool_trigger_(new Trigger<>()), heat_trigger_(new Trigger<>()) {}
void BangBangClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } void BangBangClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; }
void BangBangClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } void BangBangClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; }
Trigger<> *BangBangClimate::get_idle_trigger() const { return this->idle_trigger_; } Trigger<> *BangBangClimate::get_idle_trigger() const { return this->idle_trigger_; }
Trigger<> *BangBangClimate::get_cool_trigger() const { return this->cool_trigger_; } Trigger<> *BangBangClimate::get_cool_trigger() const { return this->cool_trigger_; }
void BangBangClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; }
Trigger<> *BangBangClimate::get_heat_trigger() const { return this->heat_trigger_; } Trigger<> *BangBangClimate::get_heat_trigger() const { return this->heat_trigger_; }
void BangBangClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; }
void BangBangClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void BangBangClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; }
void BangBangClimate::dump_config() { void BangBangClimate::dump_config() {
LOG_CLIMATE("", "Bang Bang Climate", this); LOG_CLIMATE("", "Bang Bang Climate", this);
ESP_LOGCONFIG(TAG, ESP_LOGCONFIG(TAG,

View File

@@ -25,14 +25,15 @@ class BangBangClimate : public climate::Climate, public Component {
void set_sensor(sensor::Sensor *sensor); void set_sensor(sensor::Sensor *sensor);
void set_humidity_sensor(sensor::Sensor *humidity_sensor); void set_humidity_sensor(sensor::Sensor *humidity_sensor);
Trigger<> *get_idle_trigger() const;
Trigger<> *get_cool_trigger() const;
void set_supports_cool(bool supports_cool); void set_supports_cool(bool supports_cool);
Trigger<> *get_heat_trigger() const;
void set_supports_heat(bool supports_heat); void set_supports_heat(bool supports_heat);
void set_normal_config(const BangBangClimateTargetTempConfig &normal_config); void set_normal_config(const BangBangClimateTargetTempConfig &normal_config);
void set_away_config(const BangBangClimateTargetTempConfig &away_config); void set_away_config(const BangBangClimateTargetTempConfig &away_config);
Trigger<> *get_idle_trigger() const;
Trigger<> *get_cool_trigger() const;
Trigger<> *get_heat_trigger() const;
protected: protected:
/// Override control to change settings of the climate device. /// Override control to change settings of the climate device.
void control(const climate::ClimateCall &call) override; void control(const climate::ClimateCall &call) override;
@@ -56,16 +57,10 @@ class BangBangClimate : public climate::Climate, public Component {
* *
* In idle mode, the controller is assumed to have both heating and cooling disabled. * In idle mode, the controller is assumed to have both heating and cooling disabled.
*/ */
Trigger<> *idle_trigger_; Trigger<> *idle_trigger_{nullptr};
/** The trigger to call when the controller should switch to cooling mode. /** The trigger to call when the controller should switch to cooling mode.
*/ */
Trigger<> *cool_trigger_; Trigger<> *cool_trigger_{nullptr};
/** Whether the controller supports cooling.
*
* A false value for this attribute means that the controller has no cooling action
* (for example a thermostat, where only heating and not-heating is possible).
*/
bool supports_cool_{false};
/** The trigger to call when the controller should switch to heating mode. /** The trigger to call when the controller should switch to heating mode.
* *
* A null value for this attribute means that the controller has no heating action * A null value for this attribute means that the controller has no heating action
@@ -73,15 +68,23 @@ class BangBangClimate : public climate::Climate, public Component {
* (blinds open) is possible. * (blinds open) is possible.
*/ */
Trigger<> *heat_trigger_{nullptr}; Trigger<> *heat_trigger_{nullptr};
bool supports_heat_{false};
/** A reference to the trigger that was previously active. /** A reference to the trigger that was previously active.
* *
* This is so that the previous trigger can be stopped before enabling a new one. * This is so that the previous trigger can be stopped before enabling a new one.
*/ */
Trigger<> *prev_trigger_{nullptr}; Trigger<> *prev_trigger_{nullptr};
BangBangClimateTargetTempConfig normal_config_{}; /** Whether the controller supports cooling/heating
*
* A false value for this attribute means that the controller has no respective action
* (for example a thermostat, where only heating and not-heating is possible).
*/
bool supports_cool_{false};
bool supports_heat_{false};
bool supports_away_{false}; bool supports_away_{false};
BangBangClimateTargetTempConfig normal_config_{};
BangBangClimateTargetTempConfig away_config_{}; BangBangClimateTargetTempConfig away_config_{};
}; };

View File

@@ -33,8 +33,7 @@ class BedJetClimate : public climate::Climate, public BedJetClient, public Polli
climate::ClimateTraits traits() override { climate::ClimateTraits traits() override {
auto traits = climate::ClimateTraits(); auto traits = climate::ClimateTraits();
traits.set_supports_action(true); traits.add_feature_flags(climate::CLIMATE_SUPPORTS_ACTION | climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
traits.set_supports_current_temperature(true);
traits.set_supported_modes({ traits.set_supported_modes({
climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_OFF,
climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT,

View File

View File

@@ -0,0 +1,54 @@
#include "esphome/core/log.h"
#include "bh1900nux.h"
namespace esphome {
namespace bh1900nux {
static const char *const TAG = "bh1900nux.sensor";
// I2C Registers
static const uint8_t TEMPERATURE_REG = 0x00;
static const uint8_t CONFIG_REG = 0x01; // Not used and supported yet
static const uint8_t TEMPERATURE_LOW_REG = 0x02; // Not used and supported yet
static const uint8_t TEMPERATURE_HIGH_REG = 0x03; // Not used and supported yet
static const uint8_t SOFT_RESET_REG = 0x04;
// I2C Command payloads
static const uint8_t SOFT_RESET_PAYLOAD = 0x01; // Soft Reset value
static const float SENSOR_RESOLUTION = 0.0625f; // Sensor resolution per bit in degrees celsius
void BH1900NUXSensor::setup() {
// Initialize I2C device
i2c::ErrorCode result_code =
this->write_register(SOFT_RESET_REG, &SOFT_RESET_PAYLOAD, 1); // Software Reset to check communication
if (result_code != i2c::ERROR_OK) {
this->mark_failed(ESP_LOG_MSG_COMM_FAIL);
return;
}
}
void BH1900NUXSensor::update() {
uint8_t temperature_raw[2];
if (this->read_register(TEMPERATURE_REG, temperature_raw, 2) != i2c::ERROR_OK) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
return;
}
// Combined raw value, unsigned and unaligned 16 bit
// Temperature is represented in just 12 bits, shift needed
int16_t raw_temperature_register_value = encode_uint16(temperature_raw[0], temperature_raw[1]);
raw_temperature_register_value >>= 4;
float temperature_value = raw_temperature_register_value * SENSOR_RESOLUTION; // Apply sensor resolution
this->publish_state(temperature_value);
}
void BH1900NUXSensor::dump_config() {
LOG_SENSOR("", "BH1900NUX", this);
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
}
} // namespace bh1900nux
} // namespace esphome

View File

@@ -0,0 +1,18 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome {
namespace bh1900nux {
class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void update() override;
void dump_config() override;
};
} // namespace bh1900nux
} // namespace esphome

View File

@@ -0,0 +1,34 @@
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_TEMPERATURE,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
)
DEPENDENCIES = ["i2c"]
CODEOWNERS = ["@B48D81EFCC"]
sensor_ns = cg.esphome_ns.namespace("bh1900nux")
BH1900NUXSensor = sensor_ns.class_(
"BH1900NUXSensor", cg.PollingComponent, i2c.I2CDevice
)
CONFIG_SCHEMA = (
sensor.sensor_schema(
BH1900NUXSensor,
accuracy_decimals=1,
unit_of_measurement=UNIT_CELSIUS,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x48))
)
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)

View File

@@ -264,20 +264,31 @@ async def delayed_off_filter_to_code(config, filter_id):
), ),
) )
async def autorepeat_filter_to_code(config, filter_id): async def autorepeat_filter_to_code(config, filter_id):
timings = []
if len(config) > 0: if len(config) > 0:
timings.extend( timings = [
(conf[CONF_DELAY], conf[CONF_TIME_OFF], conf[CONF_TIME_ON]) cg.StructInitializer(
for conf in config cg.MockObj("AutorepeatFilterTiming", "esphome::binary_sensor::"),
) ("delay", conf[CONF_DELAY]),
else: ("time_off", conf[CONF_TIME_OFF]),
timings.append( ("time_on", conf[CONF_TIME_ON]),
(
cv.time_period_str_unit(DEFAULT_DELAY).total_milliseconds,
cv.time_period_str_unit(DEFAULT_TIME_OFF).total_milliseconds,
cv.time_period_str_unit(DEFAULT_TIME_ON).total_milliseconds,
) )
) for conf in config
]
else:
timings = [
cg.StructInitializer(
cg.MockObj("AutorepeatFilterTiming", "esphome::binary_sensor::"),
("delay", cv.time_period_str_unit(DEFAULT_DELAY).total_milliseconds),
(
"time_off",
cv.time_period_str_unit(DEFAULT_TIME_OFF).total_milliseconds,
),
(
"time_on",
cv.time_period_str_unit(DEFAULT_TIME_ON).total_milliseconds,
),
)
]
var = cg.new_Pvariable(filter_id, timings) var = cg.new_Pvariable(filter_id, timings)
await cg.register_component(var, {}) await cg.register_component(var, {})
return var return var

View File

@@ -2,11 +2,11 @@
#include <cinttypes> #include <cinttypes>
#include <utility> #include <utility>
#include <vector>
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/core/automation.h" #include "esphome/core/automation.h"
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome { namespace esphome {
@@ -92,8 +92,8 @@ class DoubleClickTrigger : public Trigger<> {
class MultiClickTrigger : public Trigger<>, public Component { class MultiClickTrigger : public Trigger<>, public Component {
public: public:
explicit MultiClickTrigger(BinarySensor *parent, std::vector<MultiClickTriggerEvent> timing) explicit MultiClickTrigger(BinarySensor *parent, std::initializer_list<MultiClickTriggerEvent> timing)
: parent_(parent), timing_(std::move(timing)) {} : parent_(parent), timing_(timing) {}
void setup() override { void setup() override {
this->last_state_ = this->parent_->get_state_default(false); this->last_state_ = this->parent_->get_state_default(false);
@@ -115,7 +115,7 @@ class MultiClickTrigger : public Trigger<>, public Component {
void trigger_(); void trigger_();
BinarySensor *parent_; BinarySensor *parent_;
std::vector<MultiClickTriggerEvent> timing_; FixedVector<MultiClickTriggerEvent> timing_;
uint32_t invalid_cooldown_{1000}; uint32_t invalid_cooldown_{1000};
optional<size_t> at_index_{}; optional<size_t> at_index_{};
bool last_state_{false}; bool last_state_{false};

View File

@@ -51,7 +51,7 @@ void BinarySensor::add_filter(Filter *filter) {
last_filter->next_ = filter; last_filter->next_ = filter;
} }
} }
void BinarySensor::add_filters(const std::vector<Filter *> &filters) { void BinarySensor::add_filters(std::initializer_list<Filter *> filters) {
for (Filter *filter : filters) { for (Filter *filter : filters) {
this->add_filter(filter); this->add_filter(filter);
} }

View File

@@ -4,7 +4,7 @@
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/components/binary_sensor/filter.h" #include "esphome/components/binary_sensor/filter.h"
#include <vector> #include <initializer_list>
namespace esphome { namespace esphome {
@@ -48,7 +48,7 @@ class BinarySensor : public StatefulEntityBase<bool>, public EntityBase_DeviceCl
void publish_initial_state(bool new_state); void publish_initial_state(bool new_state);
void add_filter(Filter *filter); void add_filter(Filter *filter);
void add_filters(const std::vector<Filter *> &filters); void add_filters(std::initializer_list<Filter *> filters);
// ========== INTERNAL METHODS ========== // ========== INTERNAL METHODS ==========
// (In most use cases you won't need these) // (In most use cases you won't need these)

View File

@@ -1,7 +1,6 @@
#include "filter.h" #include "filter.h"
#include "binary_sensor.h" #include "binary_sensor.h"
#include <utility>
namespace esphome { namespace esphome {
@@ -68,7 +67,7 @@ float DelayedOffFilter::get_setup_priority() const { return setup_priority::HARD
optional<bool> InvertFilter::new_value(bool value) { return !value; } optional<bool> InvertFilter::new_value(bool value) { return !value; }
AutorepeatFilter::AutorepeatFilter(std::vector<AutorepeatFilterTiming> timings) : timings_(std::move(timings)) {} AutorepeatFilter::AutorepeatFilter(std::initializer_list<AutorepeatFilterTiming> timings) : timings_(timings) {}
optional<bool> AutorepeatFilter::new_value(bool value) { optional<bool> AutorepeatFilter::new_value(bool value) {
if (value) { if (value) {

View File

@@ -4,8 +4,6 @@
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include <vector>
namespace esphome { namespace esphome {
namespace binary_sensor { namespace binary_sensor {
@@ -82,11 +80,6 @@ class InvertFilter : public Filter {
}; };
struct AutorepeatFilterTiming { struct AutorepeatFilterTiming {
AutorepeatFilterTiming(uint32_t delay, uint32_t off, uint32_t on) {
this->delay = delay;
this->time_off = off;
this->time_on = on;
}
uint32_t delay; uint32_t delay;
uint32_t time_off; uint32_t time_off;
uint32_t time_on; uint32_t time_on;
@@ -94,7 +87,7 @@ struct AutorepeatFilterTiming {
class AutorepeatFilter : public Filter, public Component { class AutorepeatFilter : public Filter, public Component {
public: public:
explicit AutorepeatFilter(std::vector<AutorepeatFilterTiming> timings); explicit AutorepeatFilter(std::initializer_list<AutorepeatFilterTiming> timings);
optional<bool> new_value(bool value) override; optional<bool> new_value(bool value) override;
@@ -104,7 +97,7 @@ class AutorepeatFilter : public Filter, public Component {
void next_timing_(); void next_timing_();
void next_value_(bool val); void next_value_(bool val);
std::vector<AutorepeatFilterTiming> timings_; FixedVector<AutorepeatFilterTiming> timings_;
uint8_t active_timing_{0}; uint8_t active_timing_{0};
}; };

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

View File

@@ -41,7 +41,7 @@ CONFIG_SCHEMA = cv.All(
cv.Schema( cv.Schema(
{ {
cv.GenerateID(): cv.declare_id(BME680BSECComponent), 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( cv.Optional(CONF_IAQ_MODE, default="STATIC"): cv.enum(
IAQ_MODE_OPTIONS, upper=True IAQ_MODE_OPTIONS, upper=True
), ),

View File

@@ -139,7 +139,7 @@ CONFIG_SCHEMA_BASE = (
cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum( cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum(
VOLTAGE_OPTIONS, upper=True 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( cv.Optional(
CONF_STATE_SAVE_INTERVAL, default="6hours" CONF_STATE_SAVE_INTERVAL, default="6hours"
): cv.positive_time_period_minutes, ): cv.positive_time_period_minutes,

View File

@@ -8,17 +8,30 @@ namespace cap1188 {
static const char *const TAG = "cap1188"; static const char *const TAG = "cap1188";
void CAP1188Component::setup() { void CAP1188Component::setup() {
// Reset device using the reset pin this->disable_loop();
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup(); // no reset pin
this->reset_pin_->digital_write(false); if (this->reset_pin_ == nullptr) {
delay(100); // NOLINT this->finish_setup_();
this->reset_pin_->digital_write(true); return;
delay(100); // NOLINT
this->reset_pin_->digital_write(false);
delay(100); // NOLINT
} }
// reset pin configured so reset before finishing setup
this->reset_pin_->setup();
this->reset_pin_->digital_write(false);
// delay after reset pin write
this->set_timeout(100, [this]() {
this->reset_pin_->digital_write(true);
// delay after reset pin write
this->set_timeout(100, [this]() {
this->reset_pin_->digital_write(false);
// delay after reset pin write
this->set_timeout(100, [this]() { this->finish_setup_(); });
});
});
}
void CAP1188Component::finish_setup_() {
// Check if CAP1188 is actually connected // Check if CAP1188 is actually connected
this->read_byte(CAP1188_PRODUCT_ID, &this->cap1188_product_id_); this->read_byte(CAP1188_PRODUCT_ID, &this->cap1188_product_id_);
this->read_byte(CAP1188_MANUFACTURE_ID, &this->cap1188_manufacture_id_); this->read_byte(CAP1188_MANUFACTURE_ID, &this->cap1188_manufacture_id_);
@@ -44,6 +57,9 @@ void CAP1188Component::setup() {
// Speed up a bit // Speed up a bit
this->write_byte(CAP1188_STAND_BY_CONFIGURATION, 0x30); this->write_byte(CAP1188_STAND_BY_CONFIGURATION, 0x30);
// Setup successful, so enable loop
this->enable_loop();
} }
void CAP1188Component::dump_config() { void CAP1188Component::dump_config() {

View File

@@ -49,6 +49,8 @@ class CAP1188Component : public Component, public i2c::I2CDevice {
void loop() override; void loop() override;
protected: protected:
void finish_setup_();
std::vector<CAP1188Channel *> channels_{}; std::vector<CAP1188Channel *> channels_{};
uint8_t touch_threshold_{0x20}; uint8_t touch_threshold_{0x20};
uint8_t allow_multiple_touches_{0x80}; uint8_t allow_multiple_touches_{0x80};

View File

@@ -6,6 +6,42 @@ namespace climate {
static const char *const TAG = "climate"; static const char *const TAG = "climate";
// Memory-efficient lookup tables
struct StringToUint8 {
const char *str;
const uint8_t value;
};
constexpr StringToUint8 CLIMATE_MODES_BY_STR[] = {
{"OFF", CLIMATE_MODE_OFF},
{"AUTO", CLIMATE_MODE_AUTO},
{"COOL", CLIMATE_MODE_COOL},
{"HEAT", CLIMATE_MODE_HEAT},
{"FAN_ONLY", CLIMATE_MODE_FAN_ONLY},
{"DRY", CLIMATE_MODE_DRY},
{"HEAT_COOL", CLIMATE_MODE_HEAT_COOL},
};
constexpr StringToUint8 CLIMATE_FAN_MODES_BY_STR[] = {
{"ON", CLIMATE_FAN_ON}, {"OFF", CLIMATE_FAN_OFF}, {"AUTO", CLIMATE_FAN_AUTO},
{"LOW", CLIMATE_FAN_LOW}, {"MEDIUM", CLIMATE_FAN_MEDIUM}, {"HIGH", CLIMATE_FAN_HIGH},
{"MIDDLE", CLIMATE_FAN_MIDDLE}, {"FOCUS", CLIMATE_FAN_FOCUS}, {"DIFFUSE", CLIMATE_FAN_DIFFUSE},
{"QUIET", CLIMATE_FAN_QUIET},
};
constexpr StringToUint8 CLIMATE_PRESETS_BY_STR[] = {
{"ECO", CLIMATE_PRESET_ECO}, {"AWAY", CLIMATE_PRESET_AWAY}, {"BOOST", CLIMATE_PRESET_BOOST},
{"COMFORT", CLIMATE_PRESET_COMFORT}, {"HOME", CLIMATE_PRESET_HOME}, {"SLEEP", CLIMATE_PRESET_SLEEP},
{"ACTIVITY", CLIMATE_PRESET_ACTIVITY}, {"NONE", CLIMATE_PRESET_NONE},
};
constexpr StringToUint8 CLIMATE_SWING_MODES_BY_STR[] = {
{"OFF", CLIMATE_SWING_OFF},
{"BOTH", CLIMATE_SWING_BOTH},
{"VERTICAL", CLIMATE_SWING_VERTICAL},
{"HORIZONTAL", CLIMATE_SWING_HORIZONTAL},
};
void ClimateCall::perform() { void ClimateCall::perform() {
this->parent_->control_callback_.call(*this); this->parent_->control_callback_.call(*this);
ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str());
@@ -50,206 +86,175 @@ void ClimateCall::perform() {
} }
this->parent_->control(*this); this->parent_->control(*this);
} }
void ClimateCall::validate_() { void ClimateCall::validate_() {
auto traits = this->parent_->get_traits(); auto traits = this->parent_->get_traits();
if (this->mode_.has_value()) { if (this->mode_.has_value()) {
auto mode = *this->mode_; auto mode = *this->mode_;
if (!traits.supports_mode(mode)) { if (!traits.supports_mode(mode)) {
ESP_LOGW(TAG, " Mode %s is not supported by this device!", LOG_STR_ARG(climate_mode_to_string(mode))); ESP_LOGW(TAG, " Mode %s not supported", LOG_STR_ARG(climate_mode_to_string(mode)));
this->mode_.reset(); this->mode_.reset();
} }
} }
if (this->custom_fan_mode_.has_value()) { if (this->custom_fan_mode_.has_value()) {
auto custom_fan_mode = *this->custom_fan_mode_; auto custom_fan_mode = *this->custom_fan_mode_;
if (!traits.supports_custom_fan_mode(custom_fan_mode)) { if (!traits.supports_custom_fan_mode(custom_fan_mode)) {
ESP_LOGW(TAG, " Fan Mode %s is not supported by this device!", custom_fan_mode.c_str()); ESP_LOGW(TAG, " Fan Mode %s not supported", custom_fan_mode.c_str());
this->custom_fan_mode_.reset(); this->custom_fan_mode_.reset();
} }
} else if (this->fan_mode_.has_value()) { } else if (this->fan_mode_.has_value()) {
auto fan_mode = *this->fan_mode_; auto fan_mode = *this->fan_mode_;
if (!traits.supports_fan_mode(fan_mode)) { if (!traits.supports_fan_mode(fan_mode)) {
ESP_LOGW(TAG, " Fan Mode %s is not supported by this device!", ESP_LOGW(TAG, " Fan Mode %s not supported", LOG_STR_ARG(climate_fan_mode_to_string(fan_mode)));
LOG_STR_ARG(climate_fan_mode_to_string(fan_mode)));
this->fan_mode_.reset(); this->fan_mode_.reset();
} }
} }
if (this->custom_preset_.has_value()) { if (this->custom_preset_.has_value()) {
auto custom_preset = *this->custom_preset_; auto custom_preset = *this->custom_preset_;
if (!traits.supports_custom_preset(custom_preset)) { if (!traits.supports_custom_preset(custom_preset)) {
ESP_LOGW(TAG, " Preset %s is not supported by this device!", custom_preset.c_str()); ESP_LOGW(TAG, " Preset %s not supported", custom_preset.c_str());
this->custom_preset_.reset(); this->custom_preset_.reset();
} }
} else if (this->preset_.has_value()) { } else if (this->preset_.has_value()) {
auto preset = *this->preset_; auto preset = *this->preset_;
if (!traits.supports_preset(preset)) { if (!traits.supports_preset(preset)) {
ESP_LOGW(TAG, " Preset %s is not supported by this device!", LOG_STR_ARG(climate_preset_to_string(preset))); ESP_LOGW(TAG, " Preset %s not supported", LOG_STR_ARG(climate_preset_to_string(preset)));
this->preset_.reset(); this->preset_.reset();
} }
} }
if (this->swing_mode_.has_value()) { if (this->swing_mode_.has_value()) {
auto swing_mode = *this->swing_mode_; auto swing_mode = *this->swing_mode_;
if (!traits.supports_swing_mode(swing_mode)) { if (!traits.supports_swing_mode(swing_mode)) {
ESP_LOGW(TAG, " Swing Mode %s is not supported by this device!", ESP_LOGW(TAG, " Swing Mode %s not supported", LOG_STR_ARG(climate_swing_mode_to_string(swing_mode)));
LOG_STR_ARG(climate_swing_mode_to_string(swing_mode)));
this->swing_mode_.reset(); this->swing_mode_.reset();
} }
} }
if (this->target_temperature_.has_value()) { if (this->target_temperature_.has_value()) {
auto target = *this->target_temperature_; auto target = *this->target_temperature_;
if (traits.get_supports_two_point_target_temperature()) { if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
ESP_LOGW(TAG, " Cannot set target temperature for climate device " ESP_LOGW(TAG, " Cannot set target temperature for climate device "
"with two-point target temperature!"); "with two-point target temperature");
this->target_temperature_.reset(); this->target_temperature_.reset();
} else if (std::isnan(target)) { } else if (std::isnan(target)) {
ESP_LOGW(TAG, " Target temperature must not be NAN!"); ESP_LOGW(TAG, " Target temperature must not be NAN");
this->target_temperature_.reset(); this->target_temperature_.reset();
} }
} }
if (this->target_temperature_low_.has_value() || this->target_temperature_high_.has_value()) { if (this->target_temperature_low_.has_value() || this->target_temperature_high_.has_value()) {
if (!traits.get_supports_two_point_target_temperature()) { if (!traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
ESP_LOGW(TAG, " Cannot set low/high target temperature for this device!"); CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
ESP_LOGW(TAG, " Cannot set low/high target temperature");
this->target_temperature_low_.reset(); this->target_temperature_low_.reset();
this->target_temperature_high_.reset(); this->target_temperature_high_.reset();
} }
} }
if (this->target_temperature_low_.has_value() && std::isnan(*this->target_temperature_low_)) { if (this->target_temperature_low_.has_value() && std::isnan(*this->target_temperature_low_)) {
ESP_LOGW(TAG, " Target temperature low must not be NAN!"); ESP_LOGW(TAG, " Target temperature low must not be NAN");
this->target_temperature_low_.reset(); this->target_temperature_low_.reset();
} }
if (this->target_temperature_high_.has_value() && std::isnan(*this->target_temperature_high_)) { if (this->target_temperature_high_.has_value() && std::isnan(*this->target_temperature_high_)) {
ESP_LOGW(TAG, " Target temperature low must not be NAN!"); ESP_LOGW(TAG, " Target temperature high must not be NAN");
this->target_temperature_high_.reset(); this->target_temperature_high_.reset();
} }
if (this->target_temperature_low_.has_value() && this->target_temperature_high_.has_value()) { if (this->target_temperature_low_.has_value() && this->target_temperature_high_.has_value()) {
float low = *this->target_temperature_low_; float low = *this->target_temperature_low_;
float high = *this->target_temperature_high_; float high = *this->target_temperature_high_;
if (low > high) { if (low > high) {
ESP_LOGW(TAG, " Target temperature low %.2f must be smaller than target temperature high %.2f!", low, high); ESP_LOGW(TAG, " Target temperature low %.2f must be less than target temperature high %.2f", low, high);
this->target_temperature_low_.reset(); this->target_temperature_low_.reset();
this->target_temperature_high_.reset(); this->target_temperature_high_.reset();
} }
} }
} }
ClimateCall &ClimateCall::set_mode(ClimateMode mode) { ClimateCall &ClimateCall::set_mode(ClimateMode mode) {
this->mode_ = mode; this->mode_ = mode;
return *this; return *this;
} }
ClimateCall &ClimateCall::set_mode(const std::string &mode) { ClimateCall &ClimateCall::set_mode(const std::string &mode) {
if (str_equals_case_insensitive(mode, "OFF")) { for (const auto &mode_entry : CLIMATE_MODES_BY_STR) {
this->set_mode(CLIMATE_MODE_OFF); if (str_equals_case_insensitive(mode, mode_entry.str)) {
} else if (str_equals_case_insensitive(mode, "AUTO")) { this->set_mode(static_cast<ClimateMode>(mode_entry.value));
this->set_mode(CLIMATE_MODE_AUTO); return *this;
} else if (str_equals_case_insensitive(mode, "COOL")) { }
this->set_mode(CLIMATE_MODE_COOL);
} else if (str_equals_case_insensitive(mode, "HEAT")) {
this->set_mode(CLIMATE_MODE_HEAT);
} else if (str_equals_case_insensitive(mode, "FAN_ONLY")) {
this->set_mode(CLIMATE_MODE_FAN_ONLY);
} else if (str_equals_case_insensitive(mode, "DRY")) {
this->set_mode(CLIMATE_MODE_DRY);
} else if (str_equals_case_insensitive(mode, "HEAT_COOL")) {
this->set_mode(CLIMATE_MODE_HEAT_COOL);
} else {
ESP_LOGW(TAG, "'%s' - Unrecognized mode %s", this->parent_->get_name().c_str(), mode.c_str());
} }
ESP_LOGW(TAG, "'%s' - Unrecognized mode %s", this->parent_->get_name().c_str(), mode.c_str());
return *this; return *this;
} }
ClimateCall &ClimateCall::set_fan_mode(ClimateFanMode fan_mode) { ClimateCall &ClimateCall::set_fan_mode(ClimateFanMode fan_mode) {
this->fan_mode_ = fan_mode; this->fan_mode_ = fan_mode;
this->custom_fan_mode_.reset(); this->custom_fan_mode_.reset();
return *this; return *this;
} }
ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) { ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) {
if (str_equals_case_insensitive(fan_mode, "ON")) { for (const auto &mode_entry : CLIMATE_FAN_MODES_BY_STR) {
this->set_fan_mode(CLIMATE_FAN_ON); if (str_equals_case_insensitive(fan_mode, mode_entry.str)) {
} else if (str_equals_case_insensitive(fan_mode, "OFF")) { this->set_fan_mode(static_cast<ClimateFanMode>(mode_entry.value));
this->set_fan_mode(CLIMATE_FAN_OFF); return *this;
} else if (str_equals_case_insensitive(fan_mode, "AUTO")) {
this->set_fan_mode(CLIMATE_FAN_AUTO);
} else if (str_equals_case_insensitive(fan_mode, "LOW")) {
this->set_fan_mode(CLIMATE_FAN_LOW);
} else if (str_equals_case_insensitive(fan_mode, "MEDIUM")) {
this->set_fan_mode(CLIMATE_FAN_MEDIUM);
} else if (str_equals_case_insensitive(fan_mode, "HIGH")) {
this->set_fan_mode(CLIMATE_FAN_HIGH);
} else if (str_equals_case_insensitive(fan_mode, "MIDDLE")) {
this->set_fan_mode(CLIMATE_FAN_MIDDLE);
} else if (str_equals_case_insensitive(fan_mode, "FOCUS")) {
this->set_fan_mode(CLIMATE_FAN_FOCUS);
} else if (str_equals_case_insensitive(fan_mode, "DIFFUSE")) {
this->set_fan_mode(CLIMATE_FAN_DIFFUSE);
} else if (str_equals_case_insensitive(fan_mode, "QUIET")) {
this->set_fan_mode(CLIMATE_FAN_QUIET);
} else {
if (this->parent_->get_traits().supports_custom_fan_mode(fan_mode)) {
this->custom_fan_mode_ = fan_mode;
this->fan_mode_.reset();
} else {
ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %s", this->parent_->get_name().c_str(), fan_mode.c_str());
} }
} }
if (this->parent_->get_traits().supports_custom_fan_mode(fan_mode)) {
this->custom_fan_mode_ = fan_mode;
this->fan_mode_.reset();
} else {
ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %s", this->parent_->get_name().c_str(), fan_mode.c_str());
}
return *this; return *this;
} }
ClimateCall &ClimateCall::set_fan_mode(optional<std::string> fan_mode) { ClimateCall &ClimateCall::set_fan_mode(optional<std::string> fan_mode) {
if (fan_mode.has_value()) { if (fan_mode.has_value()) {
this->set_fan_mode(fan_mode.value()); this->set_fan_mode(fan_mode.value());
} }
return *this; return *this;
} }
ClimateCall &ClimateCall::set_preset(ClimatePreset preset) { ClimateCall &ClimateCall::set_preset(ClimatePreset preset) {
this->preset_ = preset; this->preset_ = preset;
this->custom_preset_.reset(); this->custom_preset_.reset();
return *this; return *this;
} }
ClimateCall &ClimateCall::set_preset(const std::string &preset) { ClimateCall &ClimateCall::set_preset(const std::string &preset) {
if (str_equals_case_insensitive(preset, "ECO")) { for (const auto &preset_entry : CLIMATE_PRESETS_BY_STR) {
this->set_preset(CLIMATE_PRESET_ECO); if (str_equals_case_insensitive(preset, preset_entry.str)) {
} else if (str_equals_case_insensitive(preset, "AWAY")) { this->set_preset(static_cast<ClimatePreset>(preset_entry.value));
this->set_preset(CLIMATE_PRESET_AWAY); return *this;
} else if (str_equals_case_insensitive(preset, "BOOST")) {
this->set_preset(CLIMATE_PRESET_BOOST);
} else if (str_equals_case_insensitive(preset, "COMFORT")) {
this->set_preset(CLIMATE_PRESET_COMFORT);
} else if (str_equals_case_insensitive(preset, "HOME")) {
this->set_preset(CLIMATE_PRESET_HOME);
} else if (str_equals_case_insensitive(preset, "SLEEP")) {
this->set_preset(CLIMATE_PRESET_SLEEP);
} else if (str_equals_case_insensitive(preset, "ACTIVITY")) {
this->set_preset(CLIMATE_PRESET_ACTIVITY);
} else if (str_equals_case_insensitive(preset, "NONE")) {
this->set_preset(CLIMATE_PRESET_NONE);
} else {
if (this->parent_->get_traits().supports_custom_preset(preset)) {
this->custom_preset_ = preset;
this->preset_.reset();
} else {
ESP_LOGW(TAG, "'%s' - Unrecognized preset %s", this->parent_->get_name().c_str(), preset.c_str());
} }
} }
if (this->parent_->get_traits().supports_custom_preset(preset)) {
this->custom_preset_ = preset;
this->preset_.reset();
} else {
ESP_LOGW(TAG, "'%s' - Unrecognized preset %s", this->parent_->get_name().c_str(), preset.c_str());
}
return *this; return *this;
} }
ClimateCall &ClimateCall::set_preset(optional<std::string> preset) { ClimateCall &ClimateCall::set_preset(optional<std::string> preset) {
if (preset.has_value()) { if (preset.has_value()) {
this->set_preset(preset.value()); this->set_preset(preset.value());
} }
return *this; return *this;
} }
ClimateCall &ClimateCall::set_swing_mode(ClimateSwingMode swing_mode) { ClimateCall &ClimateCall::set_swing_mode(ClimateSwingMode swing_mode) {
this->swing_mode_ = swing_mode; this->swing_mode_ = swing_mode;
return *this; return *this;
} }
ClimateCall &ClimateCall::set_swing_mode(const std::string &swing_mode) { ClimateCall &ClimateCall::set_swing_mode(const std::string &swing_mode) {
if (str_equals_case_insensitive(swing_mode, "OFF")) { for (const auto &mode_entry : CLIMATE_SWING_MODES_BY_STR) {
this->set_swing_mode(CLIMATE_SWING_OFF); if (str_equals_case_insensitive(swing_mode, mode_entry.str)) {
} else if (str_equals_case_insensitive(swing_mode, "BOTH")) { this->set_swing_mode(static_cast<ClimateSwingMode>(mode_entry.value));
this->set_swing_mode(CLIMATE_SWING_BOTH); return *this;
} else if (str_equals_case_insensitive(swing_mode, "VERTICAL")) { }
this->set_swing_mode(CLIMATE_SWING_VERTICAL);
} else if (str_equals_case_insensitive(swing_mode, "HORIZONTAL")) {
this->set_swing_mode(CLIMATE_SWING_HORIZONTAL);
} else {
ESP_LOGW(TAG, "'%s' - Unrecognized swing mode %s", this->parent_->get_name().c_str(), swing_mode.c_str());
} }
ESP_LOGW(TAG, "'%s' - Unrecognized swing mode %s", this->parent_->get_name().c_str(), swing_mode.c_str());
return *this; return *this;
} }
@@ -257,59 +262,71 @@ ClimateCall &ClimateCall::set_target_temperature(float target_temperature) {
this->target_temperature_ = target_temperature; this->target_temperature_ = target_temperature;
return *this; return *this;
} }
ClimateCall &ClimateCall::set_target_temperature_low(float target_temperature_low) { ClimateCall &ClimateCall::set_target_temperature_low(float target_temperature_low) {
this->target_temperature_low_ = target_temperature_low; this->target_temperature_low_ = target_temperature_low;
return *this; return *this;
} }
ClimateCall &ClimateCall::set_target_temperature_high(float target_temperature_high) { ClimateCall &ClimateCall::set_target_temperature_high(float target_temperature_high) {
this->target_temperature_high_ = target_temperature_high; this->target_temperature_high_ = target_temperature_high;
return *this; return *this;
} }
ClimateCall &ClimateCall::set_target_humidity(float target_humidity) { ClimateCall &ClimateCall::set_target_humidity(float target_humidity) {
this->target_humidity_ = target_humidity; this->target_humidity_ = target_humidity;
return *this; return *this;
} }
const optional<ClimateMode> &ClimateCall::get_mode() const { return this->mode_; }
const optional<float> &ClimateCall::get_target_temperature() const { return this->target_temperature_; } const optional<float> &ClimateCall::get_target_temperature() const { return this->target_temperature_; }
const optional<float> &ClimateCall::get_target_temperature_low() const { return this->target_temperature_low_; } const optional<float> &ClimateCall::get_target_temperature_low() const { return this->target_temperature_low_; }
const optional<float> &ClimateCall::get_target_temperature_high() const { return this->target_temperature_high_; } const optional<float> &ClimateCall::get_target_temperature_high() const { return this->target_temperature_high_; }
const optional<float> &ClimateCall::get_target_humidity() const { return this->target_humidity_; } const optional<float> &ClimateCall::get_target_humidity() const { return this->target_humidity_; }
const optional<ClimateMode> &ClimateCall::get_mode() const { return this->mode_; }
const optional<ClimateFanMode> &ClimateCall::get_fan_mode() const { return this->fan_mode_; } const optional<ClimateFanMode> &ClimateCall::get_fan_mode() const { return this->fan_mode_; }
const optional<std::string> &ClimateCall::get_custom_fan_mode() const { return this->custom_fan_mode_; }
const optional<ClimatePreset> &ClimateCall::get_preset() const { return this->preset_; }
const optional<std::string> &ClimateCall::get_custom_preset() const { return this->custom_preset_; }
const optional<ClimateSwingMode> &ClimateCall::get_swing_mode() const { return this->swing_mode_; } const optional<ClimateSwingMode> &ClimateCall::get_swing_mode() const { return this->swing_mode_; }
const optional<ClimatePreset> &ClimateCall::get_preset() const { return this->preset_; }
const optional<std::string> &ClimateCall::get_custom_fan_mode() const { return this->custom_fan_mode_; }
const optional<std::string> &ClimateCall::get_custom_preset() const { return this->custom_preset_; }
ClimateCall &ClimateCall::set_target_temperature_high(optional<float> target_temperature_high) { ClimateCall &ClimateCall::set_target_temperature_high(optional<float> target_temperature_high) {
this->target_temperature_high_ = target_temperature_high; this->target_temperature_high_ = target_temperature_high;
return *this; return *this;
} }
ClimateCall &ClimateCall::set_target_temperature_low(optional<float> target_temperature_low) { ClimateCall &ClimateCall::set_target_temperature_low(optional<float> target_temperature_low) {
this->target_temperature_low_ = target_temperature_low; this->target_temperature_low_ = target_temperature_low;
return *this; return *this;
} }
ClimateCall &ClimateCall::set_target_temperature(optional<float> target_temperature) { ClimateCall &ClimateCall::set_target_temperature(optional<float> target_temperature) {
this->target_temperature_ = target_temperature; this->target_temperature_ = target_temperature;
return *this; return *this;
} }
ClimateCall &ClimateCall::set_target_humidity(optional<float> target_humidity) { ClimateCall &ClimateCall::set_target_humidity(optional<float> target_humidity) {
this->target_humidity_ = target_humidity; this->target_humidity_ = target_humidity;
return *this; return *this;
} }
ClimateCall &ClimateCall::set_mode(optional<ClimateMode> mode) { ClimateCall &ClimateCall::set_mode(optional<ClimateMode> mode) {
this->mode_ = mode; this->mode_ = mode;
return *this; return *this;
} }
ClimateCall &ClimateCall::set_fan_mode(optional<ClimateFanMode> fan_mode) { ClimateCall &ClimateCall::set_fan_mode(optional<ClimateFanMode> fan_mode) {
this->fan_mode_ = fan_mode; this->fan_mode_ = fan_mode;
this->custom_fan_mode_.reset(); this->custom_fan_mode_.reset();
return *this; return *this;
} }
ClimateCall &ClimateCall::set_preset(optional<ClimatePreset> preset) { ClimateCall &ClimateCall::set_preset(optional<ClimatePreset> preset) {
this->preset_ = preset; this->preset_ = preset;
this->custom_preset_.reset(); this->custom_preset_.reset();
return *this; return *this;
} }
ClimateCall &ClimateCall::set_swing_mode(optional<ClimateSwingMode> swing_mode) { ClimateCall &ClimateCall::set_swing_mode(optional<ClimateSwingMode> swing_mode) {
this->swing_mode_ = swing_mode; this->swing_mode_ = swing_mode;
return *this; return *this;
@@ -334,6 +351,7 @@ optional<ClimateDeviceRestoreState> Climate::restore_state_() {
return {}; return {};
return recovered; return recovered;
} }
void Climate::save_state_() { void Climate::save_state_() {
#if (defined(USE_ESP_IDF) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \ #if (defined(USE_ESP_IDF) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \
!defined(CLANG_TIDY) !defined(CLANG_TIDY)
@@ -350,13 +368,14 @@ void Climate::save_state_() {
state.mode = this->mode; state.mode = this->mode;
auto traits = this->get_traits(); auto traits = this->get_traits();
if (traits.get_supports_two_point_target_temperature()) { if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
state.target_temperature_low = this->target_temperature_low; state.target_temperature_low = this->target_temperature_low;
state.target_temperature_high = this->target_temperature_high; state.target_temperature_high = this->target_temperature_high;
} else { } else {
state.target_temperature = this->target_temperature; state.target_temperature = this->target_temperature;
} }
if (traits.get_supports_target_humidity()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
state.target_humidity = this->target_humidity; state.target_humidity = this->target_humidity;
} }
if (traits.get_supports_fan_modes() && fan_mode.has_value()) { if (traits.get_supports_fan_modes() && fan_mode.has_value()) {
@@ -366,12 +385,14 @@ void Climate::save_state_() {
if (!traits.get_supported_custom_fan_modes().empty() && custom_fan_mode.has_value()) { if (!traits.get_supported_custom_fan_modes().empty() && custom_fan_mode.has_value()) {
state.uses_custom_fan_mode = true; state.uses_custom_fan_mode = true;
const auto &supported = traits.get_supported_custom_fan_modes(); const auto &supported = traits.get_supported_custom_fan_modes();
std::vector<std::string> vec{supported.begin(), supported.end()}; // std::set has consistent order (lexicographic for strings)
for (size_t i = 0; i < vec.size(); i++) { size_t i = 0;
if (vec[i] == custom_fan_mode) { for (const auto &mode : supported) {
if (mode == custom_fan_mode) {
state.custom_fan_mode = i; state.custom_fan_mode = i;
break; break;
} }
i++;
} }
} }
if (traits.get_supports_presets() && preset.has_value()) { if (traits.get_supports_presets() && preset.has_value()) {
@@ -381,12 +402,14 @@ void Climate::save_state_() {
if (!traits.get_supported_custom_presets().empty() && custom_preset.has_value()) { if (!traits.get_supported_custom_presets().empty() && custom_preset.has_value()) {
state.uses_custom_preset = true; state.uses_custom_preset = true;
const auto &supported = traits.get_supported_custom_presets(); const auto &supported = traits.get_supported_custom_presets();
std::vector<std::string> vec{supported.begin(), supported.end()}; // std::set has consistent order (lexicographic for strings)
for (size_t i = 0; i < vec.size(); i++) { size_t i = 0;
if (vec[i] == custom_preset) { for (const auto &preset : supported) {
if (preset == custom_preset) {
state.custom_preset = i; state.custom_preset = i;
break; break;
} }
i++;
} }
} }
if (traits.get_supports_swing_modes()) { if (traits.get_supports_swing_modes()) {
@@ -395,12 +418,13 @@ void Climate::save_state_() {
this->rtc_.save(&state); this->rtc_.save(&state);
} }
void Climate::publish_state() { void Climate::publish_state() {
ESP_LOGD(TAG, "'%s' - Sending state:", this->name_.c_str()); ESP_LOGD(TAG, "'%s' - Sending state:", this->name_.c_str());
auto traits = this->get_traits(); auto traits = this->get_traits();
ESP_LOGD(TAG, " Mode: %s", LOG_STR_ARG(climate_mode_to_string(this->mode))); ESP_LOGD(TAG, " Mode: %s", LOG_STR_ARG(climate_mode_to_string(this->mode)));
if (traits.get_supports_action()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
ESP_LOGD(TAG, " Action: %s", LOG_STR_ARG(climate_action_to_string(this->action))); ESP_LOGD(TAG, " Action: %s", LOG_STR_ARG(climate_action_to_string(this->action)));
} }
if (traits.get_supports_fan_modes() && this->fan_mode.has_value()) { if (traits.get_supports_fan_modes() && this->fan_mode.has_value()) {
@@ -418,19 +442,20 @@ void Climate::publish_state() {
if (traits.get_supports_swing_modes()) { if (traits.get_supports_swing_modes()) {
ESP_LOGD(TAG, " Swing Mode: %s", LOG_STR_ARG(climate_swing_mode_to_string(this->swing_mode))); ESP_LOGD(TAG, " Swing Mode: %s", LOG_STR_ARG(climate_swing_mode_to_string(this->swing_mode)));
} }
if (traits.get_supports_current_temperature()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
ESP_LOGD(TAG, " Current Temperature: %.2f°C", this->current_temperature); ESP_LOGD(TAG, " Current Temperature: %.2f°C", this->current_temperature);
} }
if (traits.get_supports_two_point_target_temperature()) { if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
ESP_LOGD(TAG, " Target Temperature: Low: %.2f°C High: %.2f°C", this->target_temperature_low, ESP_LOGD(TAG, " Target Temperature: Low: %.2f°C High: %.2f°C", this->target_temperature_low,
this->target_temperature_high); this->target_temperature_high);
} else { } else {
ESP_LOGD(TAG, " Target Temperature: %.2f°C", this->target_temperature); ESP_LOGD(TAG, " Target Temperature: %.2f°C", this->target_temperature);
} }
if (traits.get_supports_current_humidity()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) {
ESP_LOGD(TAG, " Current Humidity: %.0f%%", this->current_humidity); ESP_LOGD(TAG, " Current Humidity: %.0f%%", this->current_humidity);
} }
if (traits.get_supports_target_humidity()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
ESP_LOGD(TAG, " Target Humidity: %.0f%%", this->target_humidity); ESP_LOGD(TAG, " Target Humidity: %.0f%%", this->target_humidity);
} }
@@ -465,16 +490,20 @@ ClimateTraits Climate::get_traits() {
void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) { void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) {
this->visual_min_temperature_override_ = visual_min_temperature_override; this->visual_min_temperature_override_ = visual_min_temperature_override;
} }
void Climate::set_visual_max_temperature_override(float visual_max_temperature_override) { void Climate::set_visual_max_temperature_override(float visual_max_temperature_override) {
this->visual_max_temperature_override_ = visual_max_temperature_override; this->visual_max_temperature_override_ = visual_max_temperature_override;
} }
void Climate::set_visual_temperature_step_override(float target, float current) { void Climate::set_visual_temperature_step_override(float target, float current) {
this->visual_target_temperature_step_override_ = target; this->visual_target_temperature_step_override_ = target;
this->visual_current_temperature_step_override_ = current; this->visual_current_temperature_step_override_ = current;
} }
void Climate::set_visual_min_humidity_override(float visual_min_humidity_override) { void Climate::set_visual_min_humidity_override(float visual_min_humidity_override) {
this->visual_min_humidity_override_ = visual_min_humidity_override; this->visual_min_humidity_override_ = visual_min_humidity_override;
} }
void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) { void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) {
this->visual_max_humidity_override_ = visual_max_humidity_override; this->visual_max_humidity_override_ = visual_max_humidity_override;
} }
@@ -485,61 +514,70 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) {
auto call = climate->make_call(); auto call = climate->make_call();
auto traits = climate->get_traits(); auto traits = climate->get_traits();
call.set_mode(this->mode); call.set_mode(this->mode);
if (traits.get_supports_two_point_target_temperature()) { if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
call.set_target_temperature_low(this->target_temperature_low); call.set_target_temperature_low(this->target_temperature_low);
call.set_target_temperature_high(this->target_temperature_high); call.set_target_temperature_high(this->target_temperature_high);
} else { } else {
call.set_target_temperature(this->target_temperature); call.set_target_temperature(this->target_temperature);
} }
if (traits.get_supports_target_humidity()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
call.set_target_humidity(this->target_humidity); call.set_target_humidity(this->target_humidity);
} }
if (traits.get_supports_fan_modes() || !traits.get_supported_custom_fan_modes().empty()) { if (this->uses_custom_fan_mode) {
if (this->custom_fan_mode < traits.get_supported_custom_fan_modes().size()) {
call.fan_mode_.reset();
call.custom_fan_mode_ = *std::next(traits.get_supported_custom_fan_modes().cbegin(), this->custom_fan_mode);
}
} else if (traits.supports_fan_mode(this->fan_mode)) {
call.set_fan_mode(this->fan_mode); call.set_fan_mode(this->fan_mode);
} }
if (traits.get_supports_presets() || !traits.get_supported_custom_presets().empty()) { if (this->uses_custom_preset) {
if (this->custom_preset < traits.get_supported_custom_presets().size()) {
call.preset_.reset();
call.custom_preset_ = *std::next(traits.get_supported_custom_presets().cbegin(), this->custom_preset);
}
} else if (traits.supports_preset(this->preset)) {
call.set_preset(this->preset); call.set_preset(this->preset);
} }
if (traits.get_supports_swing_modes()) { if (traits.supports_swing_mode(this->swing_mode)) {
call.set_swing_mode(this->swing_mode); call.set_swing_mode(this->swing_mode);
} }
return call; return call;
} }
void ClimateDeviceRestoreState::apply(Climate *climate) { void ClimateDeviceRestoreState::apply(Climate *climate) {
auto traits = climate->get_traits(); auto traits = climate->get_traits();
climate->mode = this->mode; climate->mode = this->mode;
if (traits.get_supports_two_point_target_temperature()) { if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
climate->target_temperature_low = this->target_temperature_low; climate->target_temperature_low = this->target_temperature_low;
climate->target_temperature_high = this->target_temperature_high; climate->target_temperature_high = this->target_temperature_high;
} else { } else {
climate->target_temperature = this->target_temperature; climate->target_temperature = this->target_temperature;
} }
if (traits.get_supports_target_humidity()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
climate->target_humidity = this->target_humidity; climate->target_humidity = this->target_humidity;
} }
if (traits.get_supports_fan_modes() && !this->uses_custom_fan_mode) { if (this->uses_custom_fan_mode) {
if (this->custom_fan_mode < traits.get_supported_custom_fan_modes().size()) {
climate->fan_mode.reset();
climate->custom_fan_mode = *std::next(traits.get_supported_custom_fan_modes().cbegin(), this->custom_fan_mode);
}
} else if (traits.supports_fan_mode(this->fan_mode)) {
climate->fan_mode = this->fan_mode; climate->fan_mode = this->fan_mode;
climate->custom_fan_mode.reset();
} }
if (!traits.get_supported_custom_fan_modes().empty() && this->uses_custom_fan_mode) { if (this->uses_custom_preset) {
// std::set has consistent order (lexicographic for strings), so this is ok if (this->custom_preset < traits.get_supported_custom_presets().size()) {
const auto &modes = traits.get_supported_custom_fan_modes(); climate->preset.reset();
std::vector<std::string> modes_vec{modes.begin(), modes.end()}; climate->custom_preset = *std::next(traits.get_supported_custom_presets().cbegin(), this->custom_preset);
if (custom_fan_mode < modes_vec.size()) {
climate->custom_fan_mode = modes_vec[this->custom_fan_mode];
} }
} } else if (traits.supports_preset(this->preset)) {
if (traits.get_supports_presets() && !this->uses_custom_preset) {
climate->preset = this->preset; climate->preset = this->preset;
climate->custom_preset.reset();
} }
if (!traits.get_supported_custom_presets().empty() && uses_custom_preset) { if (traits.supports_swing_mode(this->swing_mode)) {
// std::set has consistent order (lexicographic for strings), so this is ok
const auto &presets = traits.get_supported_custom_presets();
std::vector<std::string> presets_vec{presets.begin(), presets.end()};
if (custom_preset < presets_vec.size()) {
climate->custom_preset = presets_vec[this->custom_preset];
}
}
if (traits.get_supports_swing_modes()) {
climate->swing_mode = this->swing_mode; climate->swing_mode = this->swing_mode;
} }
climate->publish_state(); climate->publish_state();
@@ -573,66 +611,68 @@ void Climate::dump_traits_(const char *tag) {
auto traits = this->get_traits(); auto traits = this->get_traits();
ESP_LOGCONFIG(tag, "ClimateTraits:"); ESP_LOGCONFIG(tag, "ClimateTraits:");
ESP_LOGCONFIG(tag, ESP_LOGCONFIG(tag,
" [x] Visual settings:\n" " Visual settings:\n"
" - Min temperature: %.1f\n" " - Min temperature: %.1f\n"
" - Max temperature: %.1f\n" " - Max temperature: %.1f\n"
" - Temperature step:\n" " - Temperature step:\n"
" Target: %.1f", " Target: %.1f",
traits.get_visual_min_temperature(), traits.get_visual_max_temperature(), traits.get_visual_min_temperature(), traits.get_visual_max_temperature(),
traits.get_visual_target_temperature_step()); traits.get_visual_target_temperature_step());
if (traits.get_supports_current_temperature()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
ESP_LOGCONFIG(tag, " Current: %.1f", traits.get_visual_current_temperature_step()); ESP_LOGCONFIG(tag, " Current: %.1f", traits.get_visual_current_temperature_step());
} }
if (traits.get_supports_target_humidity() || traits.get_supports_current_humidity()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY |
climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) {
ESP_LOGCONFIG(tag, ESP_LOGCONFIG(tag,
" - Min humidity: %.0f\n" " - Min humidity: %.0f\n"
" - Max humidity: %.0f", " - Max humidity: %.0f",
traits.get_visual_min_humidity(), traits.get_visual_max_humidity()); traits.get_visual_min_humidity(), traits.get_visual_max_humidity());
} }
if (traits.get_supports_two_point_target_temperature()) { if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
ESP_LOGCONFIG(tag, " [x] Supports two-point target temperature"); CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
ESP_LOGCONFIG(tag, " Supports two-point target temperature");
} }
if (traits.get_supports_current_temperature()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
ESP_LOGCONFIG(tag, " [x] Supports current temperature"); ESP_LOGCONFIG(tag, " Supports current temperature");
} }
if (traits.get_supports_target_humidity()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
ESP_LOGCONFIG(tag, " [x] Supports target humidity"); ESP_LOGCONFIG(tag, " Supports target humidity");
} }
if (traits.get_supports_current_humidity()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) {
ESP_LOGCONFIG(tag, " [x] Supports current humidity"); ESP_LOGCONFIG(tag, " Supports current humidity");
} }
if (traits.get_supports_action()) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
ESP_LOGCONFIG(tag, " [x] Supports action"); ESP_LOGCONFIG(tag, " Supports action");
} }
if (!traits.get_supported_modes().empty()) { if (!traits.get_supported_modes().empty()) {
ESP_LOGCONFIG(tag, " [x] Supported modes:"); ESP_LOGCONFIG(tag, " Supported modes:");
for (ClimateMode m : traits.get_supported_modes()) for (ClimateMode m : traits.get_supported_modes())
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_mode_to_string(m))); ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_mode_to_string(m)));
} }
if (!traits.get_supported_fan_modes().empty()) { if (!traits.get_supported_fan_modes().empty()) {
ESP_LOGCONFIG(tag, " [x] Supported fan modes:"); ESP_LOGCONFIG(tag, " Supported fan modes:");
for (ClimateFanMode m : traits.get_supported_fan_modes()) for (ClimateFanMode m : traits.get_supported_fan_modes())
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_fan_mode_to_string(m))); ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_fan_mode_to_string(m)));
} }
if (!traits.get_supported_custom_fan_modes().empty()) { if (!traits.get_supported_custom_fan_modes().empty()) {
ESP_LOGCONFIG(tag, " [x] Supported custom fan modes:"); ESP_LOGCONFIG(tag, " Supported custom fan modes:");
for (const std::string &s : traits.get_supported_custom_fan_modes()) for (const std::string &s : traits.get_supported_custom_fan_modes())
ESP_LOGCONFIG(tag, " - %s", s.c_str()); ESP_LOGCONFIG(tag, " - %s", s.c_str());
} }
if (!traits.get_supported_presets().empty()) { if (!traits.get_supported_presets().empty()) {
ESP_LOGCONFIG(tag, " [x] Supported presets:"); ESP_LOGCONFIG(tag, " Supported presets:");
for (ClimatePreset p : traits.get_supported_presets()) for (ClimatePreset p : traits.get_supported_presets())
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_preset_to_string(p))); ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_preset_to_string(p)));
} }
if (!traits.get_supported_custom_presets().empty()) { if (!traits.get_supported_custom_presets().empty()) {
ESP_LOGCONFIG(tag, " [x] Supported custom presets:"); ESP_LOGCONFIG(tag, " Supported custom presets:");
for (const std::string &s : traits.get_supported_custom_presets()) for (const std::string &s : traits.get_supported_custom_presets())
ESP_LOGCONFIG(tag, " - %s", s.c_str()); ESP_LOGCONFIG(tag, " - %s", s.c_str());
} }
if (!traits.get_supported_swing_modes().empty()) { if (!traits.get_supported_swing_modes().empty()) {
ESP_LOGCONFIG(tag, " [x] Supported swing modes:"); ESP_LOGCONFIG(tag, " Supported swing modes:");
for (ClimateSwingMode m : traits.get_supported_swing_modes()) for (ClimateSwingMode m : traits.get_supported_swing_modes())
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_swing_mode_to_string(m))); ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_swing_mode_to_string(m)));
} }
} }

View File

@@ -33,6 +33,7 @@ class Climate;
class ClimateCall { class ClimateCall {
public: public:
explicit ClimateCall(Climate *parent) : parent_(parent) {} explicit ClimateCall(Climate *parent) : parent_(parent) {}
friend struct ClimateDeviceRestoreState;
/// Set the mode of the climate device. /// Set the mode of the climate device.
ClimateCall &set_mode(ClimateMode mode); ClimateCall &set_mode(ClimateMode mode);
@@ -93,30 +94,31 @@ class ClimateCall {
void perform(); void perform();
const optional<ClimateMode> &get_mode() const;
const optional<float> &get_target_temperature() const; const optional<float> &get_target_temperature() const;
const optional<float> &get_target_temperature_low() const; const optional<float> &get_target_temperature_low() const;
const optional<float> &get_target_temperature_high() const; const optional<float> &get_target_temperature_high() const;
const optional<float> &get_target_humidity() const; const optional<float> &get_target_humidity() const;
const optional<ClimateMode> &get_mode() const;
const optional<ClimateFanMode> &get_fan_mode() const; const optional<ClimateFanMode> &get_fan_mode() const;
const optional<ClimateSwingMode> &get_swing_mode() const; const optional<ClimateSwingMode> &get_swing_mode() const;
const optional<std::string> &get_custom_fan_mode() const;
const optional<ClimatePreset> &get_preset() const; const optional<ClimatePreset> &get_preset() const;
const optional<std::string> &get_custom_fan_mode() const;
const optional<std::string> &get_custom_preset() const; const optional<std::string> &get_custom_preset() const;
protected: protected:
void validate_(); void validate_();
Climate *const parent_; Climate *const parent_;
optional<ClimateMode> mode_;
optional<float> target_temperature_; optional<float> target_temperature_;
optional<float> target_temperature_low_; optional<float> target_temperature_low_;
optional<float> target_temperature_high_; optional<float> target_temperature_high_;
optional<float> target_humidity_; optional<float> target_humidity_;
optional<ClimateMode> mode_;
optional<ClimateFanMode> fan_mode_; optional<ClimateFanMode> fan_mode_;
optional<ClimateSwingMode> swing_mode_; optional<ClimateSwingMode> swing_mode_;
optional<std::string> custom_fan_mode_;
optional<ClimatePreset> preset_; optional<ClimatePreset> preset_;
optional<std::string> custom_fan_mode_;
optional<std::string> custom_preset_; optional<std::string> custom_preset_;
}; };
@@ -169,47 +171,6 @@ class Climate : public EntityBase {
public: public:
Climate() {} Climate() {}
/// The active mode of the climate device.
ClimateMode mode{CLIMATE_MODE_OFF};
/// The active state of the climate device.
ClimateAction action{CLIMATE_ACTION_OFF};
/// The current temperature of the climate device, as reported from the integration.
float current_temperature{NAN};
/// The current humidity of the climate device, as reported from the integration.
float current_humidity{NAN};
union {
/// The target temperature of the climate device.
float target_temperature;
struct {
/// The minimum target temperature of the climate device, for climate devices with split target temperature.
float target_temperature_low{NAN};
/// The maximum target temperature of the climate device, for climate devices with split target temperature.
float target_temperature_high{NAN};
};
};
/// The target humidity of the climate device.
float target_humidity;
/// The active fan mode of the climate device.
optional<ClimateFanMode> fan_mode;
/// The active swing mode of the climate device.
ClimateSwingMode swing_mode;
/// The active custom fan mode of the climate device.
optional<std::string> custom_fan_mode;
/// The active preset of the climate device.
optional<ClimatePreset> preset;
/// The active custom preset mode of the climate device.
optional<std::string> custom_preset;
/** Add a callback for the climate device state, each time the state of the climate device is updated /** Add a callback for the climate device state, each time the state of the climate device is updated
* (using publish_state), this callback will be called. * (using publish_state), this callback will be called.
* *
@@ -251,6 +212,47 @@ class Climate : public EntityBase {
void set_visual_min_humidity_override(float visual_min_humidity_override); void set_visual_min_humidity_override(float visual_min_humidity_override);
void set_visual_max_humidity_override(float visual_max_humidity_override); void set_visual_max_humidity_override(float visual_max_humidity_override);
/// The current temperature of the climate device, as reported from the integration.
float current_temperature{NAN};
/// The current humidity of the climate device, as reported from the integration.
float current_humidity{NAN};
union {
/// The target temperature of the climate device.
float target_temperature;
struct {
/// The minimum target temperature of the climate device, for climate devices with split target temperature.
float target_temperature_low{NAN};
/// The maximum target temperature of the climate device, for climate devices with split target temperature.
float target_temperature_high{NAN};
};
};
/// The target humidity of the climate device.
float target_humidity;
/// The active fan mode of the climate device.
optional<ClimateFanMode> fan_mode;
/// The active preset of the climate device.
optional<ClimatePreset> preset;
/// The active custom fan mode of the climate device.
optional<std::string> custom_fan_mode;
/// The active custom preset mode of the climate device.
optional<std::string> custom_preset;
/// The active mode of the climate device.
ClimateMode mode{CLIMATE_MODE_OFF};
/// The active state of the climate device.
ClimateAction action{CLIMATE_ACTION_OFF};
/// The active swing mode of the climate device.
ClimateSwingMode swing_mode{CLIMATE_SWING_OFF};
protected: protected:
friend ClimateCall; friend ClimateCall;

View File

@@ -98,6 +98,21 @@ enum ClimatePreset : uint8_t {
CLIMATE_PRESET_ACTIVITY = 7, CLIMATE_PRESET_ACTIVITY = 7,
}; };
enum ClimateFeature : uint32_t {
// Reporting current temperature is supported
CLIMATE_SUPPORTS_CURRENT_TEMPERATURE = 1 << 0,
// Setting two target temperatures is supported (used in conjunction with CLIMATE_MODE_HEAT_COOL)
CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = 1 << 1,
// Single-point mode is NOT supported (UI always displays two handles, setting 'target_temperature' is not supported)
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE = 1 << 2,
// Reporting current humidity is supported
CLIMATE_SUPPORTS_CURRENT_HUMIDITY = 1 << 3,
// Setting a target humidity is supported
CLIMATE_SUPPORTS_TARGET_HUMIDITY = 1 << 4,
// Reporting current climate action is supported
CLIMATE_SUPPORTS_ACTION = 1 << 5,
};
/// Convert the given ClimateMode to a human-readable string. /// Convert the given ClimateMode to a human-readable string.
const LogString *climate_mode_to_string(ClimateMode mode); const LogString *climate_mode_to_string(ClimateMode mode);

View File

@@ -1,8 +1,8 @@
#pragma once #pragma once
#include "esphome/core/helpers.h"
#include "climate_mode.h"
#include <set> #include <set>
#include "climate_mode.h"
#include "esphome/core/helpers.h"
namespace esphome { namespace esphome {
@@ -21,91 +21,100 @@ namespace climate {
* - Target Temperature * - Target Temperature
* *
* All other properties and modes are optional and the integration must mark * All other properties and modes are optional and the integration must mark
* each of them as supported by setting the appropriate flag here. * each of them as supported by setting the appropriate flag(s) here.
* *
* - supports current temperature - if the climate device supports reporting a current temperature * - feature flags: see ClimateFeatures enum in climate_mode.h
* - supports two point target temperature - if the climate device's target temperature should be
* split in target_temperature_low and target_temperature_high instead of just the single target_temperature
* - supports modes: * - supports modes:
* - auto mode (automatic control) * - auto mode (automatic control)
* - cool mode (lowers current temperature) * - cool mode (lowers current temperature)
* - heat mode (increases current temperature) * - heat mode (increases current temperature)
* - dry mode (removes humidity from air) * - dry mode (removes humidity from air)
* - fan mode (only turns on fan) * - fan mode (only turns on fan)
* - supports action - if the climate device supports reporting the active
* current action of the device with the action property.
* - supports fan modes - optionally, if it has a fan which can be configured in different ways: * - supports fan modes - optionally, if it has a fan which can be configured in different ways:
* - on, off, auto, high, medium, low, middle, focus, diffuse, quiet * - on, off, auto, high, medium, low, middle, focus, diffuse, quiet
* - supports swing modes - optionally, if it has a swing which can be configured in different ways: * - supports swing modes - optionally, if it has a swing which can be configured in different ways:
* - off, both, vertical, horizontal * - off, both, vertical, horizontal
* *
* This class also contains static data for the climate device display: * This class also contains static data for the climate device display:
* - visual min/max temperature - tells the frontend what range of temperatures the climate device * - visual min/max temperature/humidity - tells the frontend what range of temperature/humidity the
* should display (gauge min/max values) * climate device should display (gauge min/max values)
* - temperature step - the step with which to increase/decrease target temperature. * - temperature step - the step with which to increase/decrease target temperature.
* This also affects with how many decimal places the temperature is shown * This also affects with how many decimal places the temperature is shown
*/ */
class ClimateTraits { class ClimateTraits {
public: public:
bool get_supports_current_temperature() const { return this->supports_current_temperature_; } /// Get/set feature flags (see ClimateFeatures enum in climate_mode.h)
uint32_t get_feature_flags() const { return this->feature_flags_; }
void add_feature_flags(uint32_t feature_flags) { this->feature_flags_ |= feature_flags; }
void clear_feature_flags(uint32_t feature_flags) { this->feature_flags_ &= ~feature_flags; }
bool has_feature_flags(uint32_t feature_flags) const { return this->feature_flags_ & feature_flags; }
void set_feature_flags(uint32_t feature_flags) { this->feature_flags_ = feature_flags; }
ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0")
bool get_supports_current_temperature() const {
return this->has_feature_flags(CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
}
ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0")
void set_supports_current_temperature(bool supports_current_temperature) { void set_supports_current_temperature(bool supports_current_temperature) {
this->supports_current_temperature_ = supports_current_temperature; if (supports_current_temperature) {
this->add_feature_flags(CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
} else {
this->clear_feature_flags(CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
}
} }
bool get_supports_current_humidity() const { return this->supports_current_humidity_; } ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0")
bool get_supports_current_humidity() const { return this->has_feature_flags(CLIMATE_SUPPORTS_CURRENT_HUMIDITY); }
ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0")
void set_supports_current_humidity(bool supports_current_humidity) { void set_supports_current_humidity(bool supports_current_humidity) {
this->supports_current_humidity_ = supports_current_humidity; if (supports_current_humidity) {
this->add_feature_flags(CLIMATE_SUPPORTS_CURRENT_HUMIDITY);
} else {
this->clear_feature_flags(CLIMATE_SUPPORTS_CURRENT_HUMIDITY);
}
} }
bool get_supports_two_point_target_temperature() const { return this->supports_two_point_target_temperature_; } ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0")
bool get_supports_two_point_target_temperature() const {
return this->has_feature_flags(CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE);
}
ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0")
void set_supports_two_point_target_temperature(bool supports_two_point_target_temperature) { void set_supports_two_point_target_temperature(bool supports_two_point_target_temperature) {
this->supports_two_point_target_temperature_ = supports_two_point_target_temperature; if (supports_two_point_target_temperature)
// Use CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE to mimic previous behavior
{
this->add_feature_flags(CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE);
} else {
this->clear_feature_flags(CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE);
}
} }
bool get_supports_target_humidity() const { return this->supports_target_humidity_; } ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0")
bool get_supports_target_humidity() const { return this->has_feature_flags(CLIMATE_SUPPORTS_TARGET_HUMIDITY); }
ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0")
void set_supports_target_humidity(bool supports_target_humidity) { void set_supports_target_humidity(bool supports_target_humidity) {
this->supports_target_humidity_ = supports_target_humidity; if (supports_target_humidity) {
this->add_feature_flags(CLIMATE_SUPPORTS_TARGET_HUMIDITY);
} else {
this->clear_feature_flags(CLIMATE_SUPPORTS_TARGET_HUMIDITY);
}
} }
ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0")
bool get_supports_action() const { return this->has_feature_flags(CLIMATE_SUPPORTS_ACTION); }
ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0")
void set_supports_action(bool supports_action) {
if (supports_action) {
this->add_feature_flags(CLIMATE_SUPPORTS_ACTION);
} else {
this->clear_feature_flags(CLIMATE_SUPPORTS_ACTION);
}
}
void set_supported_modes(std::set<ClimateMode> modes) { this->supported_modes_ = std::move(modes); } 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); } 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); } bool supports_mode(ClimateMode mode) const { return this->supported_modes_.count(mode); }
const std::set<ClimateMode> &get_supported_modes() const { return this->supported_modes_; } const std::set<ClimateMode> &get_supported_modes() const { return this->supported_modes_; }
void set_supports_action(bool supports_action) { this->supports_action_ = supports_action; }
bool get_supports_action() const { return this->supports_action_; }
void set_supported_fan_modes(std::set<ClimateFanMode> modes) { this->supported_fan_modes_ = std::move(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_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); } 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 supports_fan_mode(ClimateFanMode fan_mode) const { return this->supported_fan_modes_.count(fan_mode); }
bool get_supports_fan_modes() const { bool get_supports_fan_modes() const {
return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty(); return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty();
@@ -137,16 +146,6 @@ class ClimateTraits {
void set_supported_swing_modes(std::set<ClimateSwingMode> modes) { this->supported_swing_modes_ = std::move(modes); } 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); } 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 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(); } 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_; } const std::set<ClimateSwingMode> &get_supported_swing_modes() const { return this->supported_swing_modes_; }
@@ -219,24 +218,20 @@ class ClimateTraits {
} }
} }
bool supports_current_temperature_{false}; uint32_t feature_flags_{0};
bool supports_current_humidity_{false};
bool supports_two_point_target_temperature_{false};
bool supports_target_humidity_{false};
std::set<climate::ClimateMode> supported_modes_ = {climate::CLIMATE_MODE_OFF};
bool supports_action_{false};
std::set<climate::ClimateFanMode> supported_fan_modes_;
std::set<climate::ClimateSwingMode> supported_swing_modes_;
std::set<climate::ClimatePreset> supported_presets_;
std::set<std::string> supported_custom_fan_modes_;
std::set<std::string> supported_custom_presets_;
float visual_min_temperature_{10}; float visual_min_temperature_{10};
float visual_max_temperature_{30}; float visual_max_temperature_{30};
float visual_target_temperature_step_{0.1}; float visual_target_temperature_step_{0.1};
float visual_current_temperature_step_{0.1}; float visual_current_temperature_step_{0.1};
float visual_min_humidity_{30}; float visual_min_humidity_{30};
float visual_max_humidity_{99}; float visual_max_humidity_{99};
std::set<climate::ClimateMode> supported_modes_ = {climate::CLIMATE_MODE_OFF};
std::set<climate::ClimateFanMode> supported_fan_modes_;
std::set<climate::ClimateSwingMode> supported_swing_modes_;
std::set<climate::ClimatePreset> supported_presets_;
std::set<std::string> supported_custom_fan_modes_;
std::set<std::string> supported_custom_presets_;
}; };
} // namespace climate } // namespace climate

View File

@@ -8,7 +8,10 @@ static const char *const TAG = "climate_ir";
climate::ClimateTraits ClimateIR::traits() { climate::ClimateTraits ClimateIR::traits() {
auto traits = climate::ClimateTraits(); auto traits = climate::ClimateTraits();
traits.set_supports_current_temperature(this->sensor_ != nullptr); if (this->sensor_ != nullptr) {
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
}
traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL}); traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL});
if (this->supports_cool_) if (this->supports_cool_)
traits.add_supported_mode(climate::CLIMATE_MODE_COOL); traits.add_supported_mode(climate::CLIMATE_MODE_COOL);
@@ -19,7 +22,6 @@ climate::ClimateTraits ClimateIR::traits() {
if (this->supports_fan_only_) if (this->supports_fan_only_)
traits.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY); traits.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY);
traits.set_supports_two_point_target_temperature(false);
traits.set_visual_min_temperature(this->minimum_temperature_); traits.set_visual_min_temperature(this->minimum_temperature_);
traits.set_visual_max_temperature(this->maximum_temperature_); traits.set_visual_max_temperature(this->maximum_temperature_);
traits.set_visual_temperature_step(this->temperature_step_); traits.set_visual_temperature_step(this->temperature_step_);

View File

@@ -1,6 +1,6 @@
#include "cover.h" #include "cover.h"
#include "esphome/core/log.h"
#include <strings.h> #include <strings.h>
#include "esphome/core/log.h"
namespace esphome { namespace esphome {
namespace cover { namespace cover {
@@ -144,21 +144,7 @@ CoverCall &CoverCall::set_stop(bool stop) {
bool CoverCall::get_stop() const { return this->stop_; } bool CoverCall::get_stop() const { return this->stop_; }
CoverCall Cover::make_call() { return {this}; } CoverCall Cover::make_call() { return {this}; }
void Cover::open() {
auto call = this->make_call();
call.set_command_open();
call.perform();
}
void Cover::close() {
auto call = this->make_call();
call.set_command_close();
call.perform();
}
void Cover::stop() {
auto call = this->make_call();
call.set_command_stop();
call.perform();
}
void Cover::add_on_state_callback(std::function<void()> &&f) { this->state_callback_.add(std::move(f)); } void Cover::add_on_state_callback(std::function<void()> &&f) { this->state_callback_.add(std::move(f)); }
void Cover::publish_state(bool save) { void Cover::publish_state(bool save) {
this->position = clamp(this->position, 0.0f, 1.0f); this->position = clamp(this->position, 0.0f, 1.0f);

View File

@@ -4,6 +4,7 @@
#include "esphome/core/entity_base.h" #include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/preferences.h" #include "esphome/core/preferences.h"
#include "cover_traits.h" #include "cover_traits.h"
namespace esphome { namespace esphome {
@@ -125,25 +126,6 @@ class Cover : public EntityBase, public EntityBase_DeviceClass {
/// Construct a new cover call used to control the cover. /// Construct a new cover call used to control the cover.
CoverCall make_call(); CoverCall make_call();
/** Open the cover.
*
* This is a legacy method and may be removed later, please use `.make_call()` instead.
*/
ESPDEPRECATED("open() is deprecated, use make_call().set_command_open().perform() instead.", "2021.9")
void open();
/** Close the cover.
*
* This is a legacy method and may be removed later, please use `.make_call()` instead.
*/
ESPDEPRECATED("close() is deprecated, use make_call().set_command_close().perform() instead.", "2021.9")
void close();
/** Stop the cover.
*
* This is a legacy method and may be removed later, please use `.make_call()` instead.
* As per solution from issue #2885 the call should include perform()
*/
ESPDEPRECATED("stop() is deprecated, use make_call().set_command_stop().perform() instead.", "2021.9")
void stop();
void add_on_state_callback(std::function<void()> &&f); void add_on_state_callback(std::function<void()> &&f);

View File

@@ -241,9 +241,7 @@ uint8_t DaikinArcClimate::humidity_() {
climate::ClimateTraits DaikinArcClimate::traits() { climate::ClimateTraits DaikinArcClimate::traits() {
climate::ClimateTraits traits = climate_ir::ClimateIR::traits(); climate::ClimateTraits traits = climate_ir::ClimateIR::traits();
traits.set_supports_current_temperature(true); traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY);
traits.set_supports_current_humidity(false);
traits.set_supports_target_humidity(true);
traits.set_visual_min_humidity(38); traits.set_visual_min_humidity(38);
traits.set_visual_max_humidity(52); traits.set_visual_max_humidity(52);
return traits; return traits;

View File

@@ -30,14 +30,12 @@ class DateTimeBase : public EntityBase {
#endif #endif
}; };
#ifdef USE_TIME
class DateTimeStateTrigger : public Trigger<ESPTime> { class DateTimeStateTrigger : public Trigger<ESPTime> {
public: public:
explicit DateTimeStateTrigger(DateTimeBase *parent) { explicit DateTimeStateTrigger(DateTimeBase *parent) {
parent->add_on_state_callback([this, parent]() { this->trigger(parent->state_as_esptime()); }); parent->add_on_state_callback([this, parent]() { this->trigger(parent->state_as_esptime()); });
} }
}; };
#endif
} // namespace datetime } // namespace datetime
} // namespace esphome } // namespace esphome

View File

@@ -11,8 +11,6 @@
#include <esp_chip_info.h> #include <esp_chip_info.h>
#include <esp_partition.h> #include <esp_partition.h>
#include <map>
#ifdef USE_ARDUINO #ifdef USE_ARDUINO
#include <Esp.h> #include <Esp.h>
#endif #endif
@@ -125,7 +123,12 @@ void DebugComponent::log_partition_info_() {
uint32_t DebugComponent::get_free_heap_() { return heap_caps_get_free_size(MALLOC_CAP_INTERNAL); } uint32_t DebugComponent::get_free_heap_() { return heap_caps_get_free_size(MALLOC_CAP_INTERNAL); }
static const std::map<int, const char *> CHIP_FEATURES = { struct ChipFeature {
int bit;
const char *name;
};
static constexpr ChipFeature CHIP_FEATURES[] = {
{CHIP_FEATURE_BLE, "BLE"}, {CHIP_FEATURE_BLE, "BLE"},
{CHIP_FEATURE_BT, "BT"}, {CHIP_FEATURE_BT, "BT"},
{CHIP_FEATURE_EMB_FLASH, "EMB Flash"}, {CHIP_FEATURE_EMB_FLASH, "EMB Flash"},
@@ -170,11 +173,13 @@ void DebugComponent::get_device_info_(std::string &device_info) {
esp_chip_info(&info); esp_chip_info(&info);
const char *model = ESPHOME_VARIANT; const char *model = ESPHOME_VARIANT;
std::string features; std::string features;
for (auto feature : CHIP_FEATURES) {
if (info.features & feature.first) { // Check each known feature bit
features += feature.second; for (const auto &feature : CHIP_FEATURES) {
if (info.features & feature.bit) {
features += feature.name;
features += ", "; features += ", ";
info.features &= ~feature.first; info.features &= ~feature.bit;
} }
} }
if (info.features != 0) if (info.features != 0)

View File

@@ -25,10 +25,37 @@ static void show_reset_reason(std::string &reset_reason, bool set, const char *r
reset_reason += reason; reset_reason += reason;
} }
inline uint32_t read_mem_u32(uintptr_t addr) { static inline uint32_t read_mem_u32(uintptr_t addr) {
return *reinterpret_cast<volatile uint32_t *>(addr); // NOLINT(performance-no-int-to-ptr) return *reinterpret_cast<volatile uint32_t *>(addr); // NOLINT(performance-no-int-to-ptr)
} }
static inline uint8_t read_mem_u8(uintptr_t addr) {
return *reinterpret_cast<volatile uint8_t *>(addr); // NOLINT(performance-no-int-to-ptr)
}
// defines from https://github.com/adafruit/Adafruit_nRF52_Bootloader which prints those information
constexpr uint32_t SD_MAGIC_NUMBER = 0x51B1E5DB;
constexpr uintptr_t MBR_SIZE = 0x1000;
constexpr uintptr_t SOFTDEVICE_INFO_STRUCT_OFFSET = 0x2000;
constexpr uintptr_t SD_ID_OFFSET = SOFTDEVICE_INFO_STRUCT_OFFSET + 0x10;
constexpr uintptr_t SD_VERSION_OFFSET = SOFTDEVICE_INFO_STRUCT_OFFSET + 0x14;
static inline bool is_sd_present() {
return read_mem_u32(SOFTDEVICE_INFO_STRUCT_OFFSET + MBR_SIZE + 4) == SD_MAGIC_NUMBER;
}
static inline uint32_t sd_id_get() {
if (read_mem_u8(MBR_SIZE + SOFTDEVICE_INFO_STRUCT_OFFSET) > (SD_ID_OFFSET - SOFTDEVICE_INFO_STRUCT_OFFSET)) {
return read_mem_u32(MBR_SIZE + SD_ID_OFFSET);
}
return 0;
}
static inline uint32_t sd_version_get() {
if (read_mem_u8(MBR_SIZE + SOFTDEVICE_INFO_STRUCT_OFFSET) > (SD_VERSION_OFFSET - SOFTDEVICE_INFO_STRUCT_OFFSET)) {
return read_mem_u32(MBR_SIZE + SD_VERSION_OFFSET);
}
return 0;
}
std::string DebugComponent::get_reset_reason_() { std::string DebugComponent::get_reset_reason_() {
uint32_t cause; uint32_t cause;
auto ret = hwinfo_get_reset_cause(&cause); auto ret = hwinfo_get_reset_cause(&cause);
@@ -271,6 +298,29 @@ void DebugComponent::get_device_info_(std::string &device_info) {
NRF_UICR->NRFFW[0]); NRF_UICR->NRFFW[0]);
ESP_LOGD(TAG, "MBR param page addr 0x%08x, UICR param page addr 0x%08x", read_mem_u32(MBR_PARAM_PAGE_ADDR), ESP_LOGD(TAG, "MBR param page addr 0x%08x, UICR param page addr 0x%08x", read_mem_u32(MBR_PARAM_PAGE_ADDR),
NRF_UICR->NRFFW[1]); NRF_UICR->NRFFW[1]);
if (is_sd_present()) {
uint32_t const sd_id = sd_id_get();
uint32_t const sd_version = sd_version_get();
uint32_t ver[3];
ver[0] = sd_version / 1000000;
ver[1] = (sd_version - ver[0] * 1000000) / 1000;
ver[2] = (sd_version - ver[0] * 1000000 - ver[1] * 1000);
ESP_LOGD(TAG, "SoftDevice: S%u %u.%u.%u", sd_id, ver[0], ver[1], ver[2]);
#ifdef USE_SOFTDEVICE_ID
#ifdef USE_SOFTDEVICE_VERSION
if (USE_SOFTDEVICE_ID != sd_id || USE_SOFTDEVICE_VERSION != ver[0]) {
ESP_LOGE(TAG, "Built for SoftDevice S%u %u.x.y. It may crash due to mismatch of bootloader version.",
USE_SOFTDEVICE_ID, USE_SOFTDEVICE_VERSION);
}
#else
if (USE_SOFTDEVICE_ID != sd_id) {
ESP_LOGE(TAG, "Built for SoftDevice S%u. It may crash due to mismatch of bootloader version.", USE_SOFTDEVICE_ID);
}
#endif
#endif
}
#endif #endif
} }

View File

@@ -82,16 +82,14 @@ class DemoClimate : public climate::Climate, public Component {
climate::ClimateTraits traits{}; climate::ClimateTraits traits{};
switch (type_) { switch (type_) {
case DemoClimateType::TYPE_1: case DemoClimateType::TYPE_1:
traits.set_supports_current_temperature(true); traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION);
traits.set_supported_modes({ traits.set_supported_modes({
climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_OFF,
climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT,
}); });
traits.set_supports_action(true);
traits.set_visual_temperature_step(0.5); traits.set_visual_temperature_step(0.5);
break; break;
case DemoClimateType::TYPE_2: case DemoClimateType::TYPE_2:
traits.set_supports_current_temperature(false);
traits.set_supported_modes({ traits.set_supported_modes({
climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_OFF,
climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT,
@@ -100,7 +98,7 @@ class DemoClimate : public climate::Climate, public Component {
climate::CLIMATE_MODE_DRY, climate::CLIMATE_MODE_DRY,
climate::CLIMATE_MODE_FAN_ONLY, climate::CLIMATE_MODE_FAN_ONLY,
}); });
traits.set_supports_action(true); traits.add_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
traits.set_supported_fan_modes({ traits.set_supported_fan_modes({
climate::CLIMATE_FAN_ON, climate::CLIMATE_FAN_ON,
climate::CLIMATE_FAN_OFF, climate::CLIMATE_FAN_OFF,
@@ -123,8 +121,8 @@ class DemoClimate : public climate::Climate, public Component {
traits.set_supported_custom_presets({"My Preset"}); traits.set_supported_custom_presets({"My Preset"});
break; break;
case DemoClimateType::TYPE_3: case DemoClimateType::TYPE_3:
traits.set_supports_current_temperature(true); traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE |
traits.set_supports_two_point_target_temperature(true); climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE);
traits.set_supported_modes({ traits.set_supported_modes({
climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_OFF,
climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_COOL,

View File

@@ -775,7 +775,7 @@ void Display::test_card() {
int shift_y = (h - image_h) / 2; int shift_y = (h - image_h) / 2;
int line_w = (image_w - 6) / 6; int line_w = (image_w - 6) / 6;
int image_c = image_w / 2; int image_c = image_w / 2;
for (auto i = 0; i <= image_h; i++) { for (auto i = 0; i != image_h; i++) {
int c = esp_scale(i, image_h); int c = esp_scale(i, image_h);
this->horizontal_line(shift_x + 0, shift_y + i, line_w, r.fade_to_white(c)); this->horizontal_line(shift_x + 0, shift_y + i, line_w, r.fade_to_white(c));
this->horizontal_line(shift_x + line_w, shift_y + i, line_w, r.fade_to_black(c)); // this->horizontal_line(shift_x + line_w, shift_y + i, line_w, r.fade_to_black(c)); //
@@ -809,8 +809,11 @@ void Display::test_card() {
} }
} }
} }
this->rectangle(0, 0, w, h, Color(127, 0, 127));
this->filled_rectangle(0, 0, 10, 10, Color(255, 0, 255)); this->filled_rectangle(0, 0, 10, 10, Color(255, 0, 255));
this->filled_rectangle(w - 10, 0, 10, 10, Color(255, 0, 255));
this->filled_rectangle(0, h - 10, 10, 10, Color(255, 0, 255));
this->filled_rectangle(w - 10, h - 10, 10, 10, Color(255, 0, 255));
this->rectangle(0, 0, w, h, Color(255, 255, 255));
this->stop_poller(); this->stop_poller();
} }

View File

@@ -80,8 +80,8 @@ void E131Component::add_effect(E131AddressableLightEffect *light_effect) {
return; return;
} }
ESP_LOGD(TAG, "Registering '%s' for universes %d-%d.", light_effect->get_name().c_str(), ESP_LOGD(TAG, "Registering '%s' for universes %d-%d.", light_effect->get_name(), light_effect->get_first_universe(),
light_effect->get_first_universe(), light_effect->get_last_universe()); light_effect->get_last_universe());
light_effects_.insert(light_effect); light_effects_.insert(light_effect);
@@ -95,8 +95,8 @@ void E131Component::remove_effect(E131AddressableLightEffect *light_effect) {
return; return;
} }
ESP_LOGD(TAG, "Unregistering '%s' for universes %d-%d.", light_effect->get_name().c_str(), ESP_LOGD(TAG, "Unregistering '%s' for universes %d-%d.", light_effect->get_name(), light_effect->get_first_universe(),
light_effect->get_first_universe(), light_effect->get_last_universe()); light_effect->get_last_universe());
light_effects_.erase(light_effect); light_effects_.erase(light_effect);

View File

@@ -9,7 +9,7 @@ namespace e131 {
static const char *const TAG = "e131_addressable_light_effect"; static const char *const TAG = "e131_addressable_light_effect";
static const int MAX_DATA_SIZE = (sizeof(E131Packet::values) - 1); static const int MAX_DATA_SIZE = (sizeof(E131Packet::values) - 1);
E131AddressableLightEffect::E131AddressableLightEffect(const std::string &name) : AddressableLightEffect(name) {} E131AddressableLightEffect::E131AddressableLightEffect(const char *name) : AddressableLightEffect(name) {}
int E131AddressableLightEffect::get_data_per_universe() const { return get_lights_per_universe() * channels_; } int E131AddressableLightEffect::get_data_per_universe() const { return get_lights_per_universe() * channels_; }
@@ -58,8 +58,8 @@ bool E131AddressableLightEffect::process_(int universe, const E131Packet &packet
std::min(it->size(), std::min(output_offset + get_lights_per_universe(), output_offset + packet.count - 1)); std::min(it->size(), std::min(output_offset + get_lights_per_universe(), output_offset + packet.count - 1));
auto *input_data = packet.values + 1; auto *input_data = packet.values + 1;
ESP_LOGV(TAG, "Applying data for '%s' on %d universe, for %" PRId32 "-%d.", get_name().c_str(), universe, ESP_LOGV(TAG, "Applying data for '%s' on %d universe, for %" PRId32 "-%d.", get_name(), universe, output_offset,
output_offset, output_end); output_end);
switch (channels_) { switch (channels_) {
case E131_MONO: case E131_MONO:

View File

@@ -13,7 +13,7 @@ enum E131LightChannels { E131_MONO = 1, E131_RGB = 3, E131_RGBW = 4 };
class E131AddressableLightEffect : public light::AddressableLightEffect { class E131AddressableLightEffect : public light::AddressableLightEffect {
public: public:
E131AddressableLightEffect(const std::string &name); E131AddressableLightEffect(const char *name);
void start() override; void start() override;
void stop() override; void stop() override;

View File

@@ -103,7 +103,7 @@ bool EPaperBase::is_idle_() {
if (this->busy_pin_ == nullptr) { if (this->busy_pin_ == nullptr) {
return true; return true;
} }
return !this->busy_pin_->digital_read(); return this->busy_pin_->digital_read();
} }
void EPaperBase::reset() { void EPaperBase::reset() {

View File

@@ -1,3 +1,4 @@
import contextlib
from dataclasses import dataclass from dataclasses import dataclass
import itertools import itertools
import logging import logging
@@ -102,6 +103,10 @@ COMPILER_OPTIMIZATIONS = {
"SIZE": "CONFIG_COMPILER_OPTIMIZATION_SIZE", "SIZE": "CONFIG_COMPILER_OPTIMIZATION_SIZE",
} }
# Socket limit configuration for ESP-IDF
# ESP-IDF CONFIG_LWIP_MAX_SOCKETS has range 1-253, default 10
DEFAULT_MAX_SOCKETS = 10 # ESP-IDF default
ARDUINO_ALLOWED_VARIANTS = [ ARDUINO_ALLOWED_VARIANTS = [
VARIANT_ESP32, VARIANT_ESP32,
VARIANT_ESP32C3, VARIANT_ESP32C3,
@@ -545,6 +550,32 @@ CONF_ENABLE_LWIP_BRIDGE_INTERFACE = "enable_lwip_bridge_interface"
CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING = "enable_lwip_tcpip_core_locking" CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING = "enable_lwip_tcpip_core_locking"
CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY = "enable_lwip_check_thread_safety" CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY = "enable_lwip_check_thread_safety"
CONF_DISABLE_LIBC_LOCKS_IN_IRAM = "disable_libc_locks_in_iram" CONF_DISABLE_LIBC_LOCKS_IN_IRAM = "disable_libc_locks_in_iram"
CONF_DISABLE_VFS_SUPPORT_TERMIOS = "disable_vfs_support_termios"
CONF_DISABLE_VFS_SUPPORT_SELECT = "disable_vfs_support_select"
CONF_DISABLE_VFS_SUPPORT_DIR = "disable_vfs_support_dir"
# VFS requirement tracking
# Components that need VFS features can call require_vfs_select() or require_vfs_dir()
KEY_VFS_SELECT_REQUIRED = "vfs_select_required"
KEY_VFS_DIR_REQUIRED = "vfs_dir_required"
def require_vfs_select() -> None:
"""Mark that VFS select support is required by a component.
Call this from components that use esp_vfs_eventfd or other VFS select features.
This prevents CONFIG_VFS_SUPPORT_SELECT from being disabled.
"""
CORE.data[KEY_VFS_SELECT_REQUIRED] = True
def require_vfs_dir() -> None:
"""Mark that VFS directory support is required by a component.
Call this from components that use directory functions (opendir, readdir, mkdir, etc.).
This prevents CONFIG_VFS_SUPPORT_DIR from being disabled.
"""
CORE.data[KEY_VFS_DIR_REQUIRED] = True
def _validate_idf_component(config: ConfigType) -> ConfigType: def _validate_idf_component(config: ConfigType) -> ConfigType:
@@ -610,6 +641,13 @@ FRAMEWORK_SCHEMA = cv.All(
cv.Optional( cv.Optional(
CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True
): cv.boolean, ): cv.boolean,
cv.Optional(
CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True
): cv.boolean,
cv.Optional(
CONF_DISABLE_VFS_SUPPORT_SELECT, default=True
): cv.boolean,
cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean,
cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean,
} }
), ),
@@ -746,6 +784,72 @@ CONFIG_SCHEMA = cv.All(
FINAL_VALIDATE_SCHEMA = cv.Schema(final_validate) FINAL_VALIDATE_SCHEMA = cv.Schema(final_validate)
def _configure_lwip_max_sockets(conf: dict) -> None:
"""Calculate and set CONFIG_LWIP_MAX_SOCKETS based on component needs.
Socket component tracks consumer needs via consume_sockets() called during config validation.
This function runs in to_code() after all components have registered their socket needs.
User-provided sdkconfig_options take precedence.
"""
from esphome.components.socket import KEY_SOCKET_CONSUMERS
# Check if user manually specified CONFIG_LWIP_MAX_SOCKETS
user_max_sockets = conf.get(CONF_SDKCONFIG_OPTIONS, {}).get(
"CONFIG_LWIP_MAX_SOCKETS"
)
socket_consumers: dict[str, int] = CORE.data.get(KEY_SOCKET_CONSUMERS, {})
total_sockets = sum(socket_consumers.values())
# Early return if no sockets registered and no user override
if total_sockets == 0 and user_max_sockets is None:
return
components_list = ", ".join(
f"{name}={count}" for name, count in sorted(socket_consumers.items())
)
# User specified their own value - respect it but warn if insufficient
if user_max_sockets is not None:
_LOGGER.info(
"Using user-provided CONFIG_LWIP_MAX_SOCKETS: %s",
user_max_sockets,
)
# Warn if user's value is less than what components need
if total_sockets > 0:
user_sockets_int = 0
with contextlib.suppress(ValueError, TypeError):
user_sockets_int = int(user_max_sockets)
if user_sockets_int < total_sockets:
_LOGGER.warning(
"CONFIG_LWIP_MAX_SOCKETS is set to %d but your configuration "
"needs %d sockets (registered: %s). You may experience socket "
"exhaustion errors. Consider increasing to at least %d.",
user_sockets_int,
total_sockets,
components_list,
total_sockets,
)
# User's value already added via sdkconfig_options processing
return
# Auto-calculate based on component needs
# Use at least the ESP-IDF default (10), or the total needed by components
max_sockets = max(DEFAULT_MAX_SOCKETS, total_sockets)
log_level = logging.INFO if max_sockets > DEFAULT_MAX_SOCKETS else logging.DEBUG
_LOGGER.log(
log_level,
"Setting CONFIG_LWIP_MAX_SOCKETS to %d (registered: %s)",
max_sockets,
components_list,
)
add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets)
async def to_code(config): async def to_code(config):
cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
@@ -773,12 +877,27 @@ async def to_code(config):
for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"): for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"):
os.environ.pop(clean_var, None) os.environ.pop(clean_var, None)
# Set the location of the IDF component manager cache
os.environ["IDF_COMPONENT_CACHE_PATH"] = str(
CORE.relative_internal_path(".espressif")
)
add_extra_script( add_extra_script(
"post", "post",
"post_build.py", "post_build.py",
Path(__file__).parent / "post_build.py.script", Path(__file__).parent / "post_build.py.script",
) )
# In testing mode, add IRAM fix script to allow linking grouped component tests
# Similar to ESP8266's approach but for ESP-IDF
if CORE.testing_mode:
cg.add_build_flag("-DESPHOME_TESTING_MODE")
add_extra_script(
"pre",
"iram_fix.py",
Path(__file__).parent / "iram_fix.py.script",
)
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_platformio_option("framework", "espidf") cg.add_platformio_option("framework", "espidf")
cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP_IDF")
@@ -805,6 +924,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True) add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", 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") cg.add_build_flag("-Wno-nonnull-compare")
@@ -828,6 +948,9 @@ async def to_code(config):
# Disable dynamic log level control to save memory # Disable dynamic log level control to save memory
add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False) add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False)
# Reduce PHY TX power in the event of a brownout
add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True)
# Set default CPU frequency # Set default CPU frequency
add_idf_sdkconfig_option( add_idf_sdkconfig_option(
f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{config[CONF_CPU_FREQUENCY][:-3]}", True f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{config[CONF_CPU_FREQUENCY][:-3]}", True
@@ -852,6 +975,9 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False): if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False):
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
_configure_lwip_max_sockets(conf)
if advanced.get(CONF_EXECUTE_FROM_PSRAM, False): if advanced.get(CONF_EXECUTE_FROM_PSRAM, False):
add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True)
add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True)
@@ -874,6 +1000,43 @@ async def to_code(config):
if advanced.get(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, True): if advanced.get(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, True):
add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False)
# Disable VFS support for termios (terminal I/O functions)
# ESPHome doesn't use termios functions on ESP32 (only used in host UART driver).
# Saves approximately 1.8KB of flash when disabled (default).
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_TERMIOS",
not advanced.get(CONF_DISABLE_VFS_SUPPORT_TERMIOS, True),
)
# Disable VFS support for select() with file descriptors
# ESPHome only uses select() with sockets via lwip_select(), which still works.
# VFS select is only needed for UART/eventfd file descriptors.
# Components that need it (e.g., openthread) call require_vfs_select().
# Saves approximately 2.7KB of flash when disabled (default).
if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False):
# Component requires VFS select - force enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_SELECT", True)
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_SELECT",
not advanced.get(CONF_DISABLE_VFS_SUPPORT_SELECT, True),
)
# Disable VFS support for directory functions (opendir, readdir, mkdir, etc.)
# ESPHome doesn't use directory functions on ESP32.
# Components that need it (e.g., storage components) call require_vfs_dir().
# Saves approximately 0.5KB+ of flash when disabled (default).
if CORE.data.get(KEY_VFS_DIR_REQUIRED, False):
# Component requires VFS directory support - force enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_DIR", True)
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_DIR",
not advanced.get(CONF_DISABLE_VFS_SUPPORT_DIR, True),
)
cg.add_platformio_option("board_build.partitions", "partitions.csv") cg.add_platformio_option("board_build.partitions", "partitions.csv")
if CONF_PARTITIONS in config: if CONF_PARTITIONS in config:
add_extra_build_file( add_extra_build_file(

View File

@@ -6,6 +6,7 @@
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/task.h> #include <freertos/task.h>
#include <esp_idf_version.h> #include <esp_idf_version.h>
#include <esp_ota_ops.h>
#include <esp_task_wdt.h> #include <esp_task_wdt.h>
#include <esp_timer.h> #include <esp_timer.h>
#include <soc/rtc.h> #include <soc/rtc.h>
@@ -52,6 +53,16 @@ void arch_init() {
disableCore1WDT(); disableCore1WDT();
#endif #endif
#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(); } void IRAM_ATTR HOT arch_feed_wdt() { esp_task_wdt_reset(); }

View File

@@ -0,0 +1,71 @@
import os
import re
# pylint: disable=E0602
Import("env") # noqa
# IRAM size for testing mode (2MB - large enough to accommodate grouped tests)
TESTING_IRAM_SIZE = 0x200000
def patch_idf_linker_script(source, target, env):
"""Patch ESP-IDF linker script to increase IRAM size for testing mode."""
# Check if we're in testing mode by looking for the define
build_flags = env.get("BUILD_FLAGS", [])
testing_mode = any("-DESPHOME_TESTING_MODE" in flag for flag in build_flags)
if not testing_mode:
return
# For ESP-IDF, the linker scripts are generated in the build directory
build_dir = env.subst("$BUILD_DIR")
# The memory.ld file is directly in the build directory
memory_ld = os.path.join(build_dir, "memory.ld")
if not os.path.exists(memory_ld):
print(f"ESPHome: Warning - could not find linker script at {memory_ld}")
return
try:
with open(memory_ld, "r") as f:
content = f.read()
except OSError as e:
print(f"ESPHome: Error reading linker script: {e}")
return
# Check if this file contains iram0_0_seg
if 'iram0_0_seg' not in content:
print(f"ESPHome: Warning - iram0_0_seg not found in {memory_ld}")
return
# Look for iram0_0_seg definition and increase its length
# ESP-IDF format can be:
# iram0_0_seg (RX) : org = 0x40080000, len = 0x20000 + 0x0
# or more complex with nested parentheses:
# iram0_0_seg (RX) : org = (0x40370000 + 0x4000), len = (((0x403CB700 - (0x40378000 - 0x3FC88000)) - 0x3FC88000) + 0x8000 - 0x4000)
# We want to change len to TESTING_IRAM_SIZE for testing
# Use a more robust approach: find the line and manually parse it
lines = content.split('\n')
for i, line in enumerate(lines):
if 'iram0_0_seg' in line and 'len' in line:
# Find the position of "len = " and replace everything after it until the end of the statement
match = re.search(r'(iram0_0_seg\s*\([^)]*\)\s*:\s*org\s*=\s*(?:\([^)]+\)|0x[0-9a-fA-F]+)\s*,\s*len\s*=\s*)(.+?)(\s*)$', line)
if match:
lines[i] = f"{match.group(1)}{TESTING_IRAM_SIZE:#x}{match.group(3)}"
break
updated = '\n'.join(lines)
if updated != content:
with open(memory_ld, "w") as f:
f.write(updated)
print(f"ESPHome: Patched IRAM size to {TESTING_IRAM_SIZE:#x} in {memory_ld} for testing mode")
else:
print(f"ESPHome: Warning - could not patch iram0_0_seg in {memory_ld}")
# Hook into the build process before linking
# For ESP-IDF, we need to run this after the linker scripts are generated
env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", patch_idf_linker_script)

View File

@@ -61,12 +61,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
this->address_str_ = ""; this->address_str_ = "";
} else { } else {
char buf[18]; char buf[18];
uint8_t mac[6] = { format_mac_addr_upper(this->remote_bda_, buf);
(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);
this->address_str_ = buf; this->address_str_ = buf;
} }
} }

View File

@@ -1,6 +1,7 @@
import esphome.codegen as cg import esphome.codegen as cg
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_MODE, CONF_PORT from esphome.const import CONF_ID, CONF_MODE, CONF_PORT
from esphome.types import ConfigType
CODEOWNERS = ["@ayufan"] CODEOWNERS = ["@ayufan"]
AUTO_LOAD = ["camera"] AUTO_LOAD = ["camera"]
@@ -13,13 +14,27 @@ Mode = esp32_camera_web_server_ns.enum("Mode")
MODES = {"STREAM": Mode.STREAM, "SNAPSHOT": Mode.SNAPSHOT} MODES = {"STREAM": Mode.STREAM, "SNAPSHOT": Mode.SNAPSHOT}
CONFIG_SCHEMA = cv.Schema(
{ def _consume_camera_web_server_sockets(config: ConfigType) -> ConfigType:
cv.GenerateID(): cv.declare_id(CameraWebServer), """Register socket needs for camera web server."""
cv.Required(CONF_PORT): cv.port, from esphome.components import socket
cv.Required(CONF_MODE): cv.enum(MODES, upper=True),
}, # Each camera web server instance needs 1 listening socket + 2 client connections
).extend(cv.COMPONENT_SCHEMA) sockets_needed = 3
socket.consume_sockets(sockets_needed, "esp32_camera_web_server")(config)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CameraWebServer),
cv.Required(CONF_PORT): cv.port,
cv.Required(CONF_MODE): cv.enum(MODES, upper=True),
},
).extend(cv.COMPONENT_SCHEMA),
_consume_camera_web_server_sockets,
)
async def to_code(config): async def to_code(config):

View File

@@ -95,7 +95,7 @@ async def to_code(config):
if framework_ver >= cv.Version(5, 5, 0): if framework_ver >= cv.Version(5, 5, 0):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.1.5") esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.1.5")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.3") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.3")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.5.11") esp32.add_idf_component(name="espressif/esp_hosted", ref="2.6.1")
else: else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")

View File

@@ -1,11 +1,11 @@
from esphome import automation from esphome import automation
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import binary_sensor, esp32_ble, output from esphome.components import binary_sensor, esp32_ble, improv_base, output
from esphome.components.esp32_ble import BTLoggers from esphome.components.esp32_ble import BTLoggers
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TRIGGER_ID from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TRIGGER_ID
AUTO_LOAD = ["esp32_ble_server"] AUTO_LOAD = ["esp32_ble_server", "improv_base"]
CODEOWNERS = ["@jesserockz"] CODEOWNERS = ["@jesserockz"]
DEPENDENCIES = ["wifi", "esp32"] DEPENDENCIES = ["wifi", "esp32"]
@@ -20,6 +20,7 @@ CONF_ON_STOP = "on_stop"
CONF_STATUS_INDICATOR = "status_indicator" CONF_STATUS_INDICATOR = "status_indicator"
CONF_WIFI_TIMEOUT = "wifi_timeout" CONF_WIFI_TIMEOUT = "wifi_timeout"
improv_ns = cg.esphome_ns.namespace("improv") improv_ns = cg.esphome_ns.namespace("improv")
Error = improv_ns.enum("Error") Error = improv_ns.enum("Error")
State = improv_ns.enum("State") State = improv_ns.enum("State")
@@ -43,55 +44,63 @@ ESP32ImprovStoppedTrigger = esp32_improv_ns.class_(
) )
CONFIG_SCHEMA = cv.Schema( CONFIG_SCHEMA = (
{ cv.Schema(
cv.GenerateID(): cv.declare_id(ESP32ImprovComponent), {
cv.Required(CONF_AUTHORIZER): cv.Any( cv.GenerateID(): cv.declare_id(ESP32ImprovComponent),
cv.none, cv.use_id(binary_sensor.BinarySensor) cv.Required(CONF_AUTHORIZER): cv.Any(
), cv.none, cv.use_id(binary_sensor.BinarySensor)
cv.Optional(CONF_STATUS_INDICATOR): cv.use_id(output.BinaryOutput), ),
cv.Optional( cv.Optional(CONF_STATUS_INDICATOR): cv.use_id(output.BinaryOutput),
CONF_IDENTIFY_DURATION, default="10s" cv.Optional(
): cv.positive_time_period_milliseconds, CONF_IDENTIFY_DURATION, default="10s"
cv.Optional( ): cv.positive_time_period_milliseconds,
CONF_AUTHORIZED_DURATION, default="1min" cv.Optional(
): cv.positive_time_period_milliseconds, CONF_AUTHORIZED_DURATION, default="1min"
cv.Optional( ): cv.positive_time_period_milliseconds,
CONF_WIFI_TIMEOUT, default="1min" cv.Optional(
): cv.positive_time_period_milliseconds, CONF_WIFI_TIMEOUT, default="1min"
cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( ): cv.positive_time_period_milliseconds,
{ cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation(
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( {
ESP32ImprovProvisionedTrigger cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
), ESP32ImprovProvisionedTrigger
} ),
), }
cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( ),
{ cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation(
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( {
ESP32ImprovProvisioningTrigger cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
), ESP32ImprovProvisioningTrigger
} ),
), }
cv.Optional(CONF_ON_START): automation.validate_automation( ),
{ cv.Optional(CONF_ON_START): automation.validate_automation(
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ESP32ImprovStartTrigger), {
} cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
), ESP32ImprovStartTrigger
cv.Optional(CONF_ON_STATE): automation.validate_automation( ),
{ }
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ESP32ImprovStateTrigger), ),
} cv.Optional(CONF_ON_STATE): automation.validate_automation(
), {
cv.Optional(CONF_ON_STOP): automation.validate_automation( cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
{ ESP32ImprovStateTrigger
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( ),
ESP32ImprovStoppedTrigger }
), ),
} cv.Optional(CONF_ON_STOP): automation.validate_automation(
), {
} cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
).extend(cv.COMPONENT_SCHEMA) ESP32ImprovStoppedTrigger
),
}
),
}
)
.extend(improv_base.IMPROV_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config): async def to_code(config):
@@ -102,7 +111,8 @@ async def to_code(config):
await cg.register_component(var, config) await cg.register_component(var, config)
cg.add_define("USE_IMPROV") cg.add_define("USE_IMPROV")
cg.add_library("improv/Improv", "1.2.4")
await improv_base.setup_improv_core(var, config, "esp32_improv")
cg.add(var.set_identify_duration(config[CONF_IDENTIFY_DURATION])) cg.add(var.set_identify_duration(config[CONF_IDENTIFY_DURATION]))
cg.add(var.set_authorized_duration(config[CONF_AUTHORIZED_DURATION])) cg.add(var.set_authorized_duration(config[CONF_AUTHORIZED_DURATION]))

View File

@@ -1,10 +1,10 @@
#include "esp32_improv_component.h" #include "esp32_improv_component.h"
#include "esphome/components/bytebuffer/bytebuffer.h"
#include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble/ble.h"
#include "esphome/components/esp32_ble_server/ble_2902.h" #include "esphome/components/esp32_ble_server/ble_2902.h"
#include "esphome/core/application.h" #include "esphome/core/application.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include "esphome/components/bytebuffer/bytebuffer.h"
#ifdef USE_ESP32 #ifdef USE_ESP32
@@ -384,17 +384,34 @@ void ESP32ImprovComponent::check_wifi_connection_() {
this->connecting_sta_ = {}; this->connecting_sta_ = {};
this->cancel_timeout("wifi-connect-timeout"); this->cancel_timeout("wifi-connect-timeout");
std::vector<std::string> urls = {ESPHOME_MY_LINK}; // Build URL list with minimal allocations
// Maximum 3 URLs: custom next_url + ESPHOME_MY_LINK + webserver URL
std::string url_strings[3];
size_t url_count = 0;
#ifdef USE_ESP32_IMPROV_NEXT_URL
// Add next_url if configured (should be first per Improv BLE spec)
std::string next_url = this->get_formatted_next_url_();
if (!next_url.empty()) {
url_strings[url_count++] = std::move(next_url);
}
#endif
// Add default URLs for backward compatibility
url_strings[url_count++] = ESPHOME_MY_LINK;
#ifdef USE_WEBSERVER #ifdef USE_WEBSERVER
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) { for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
if (ip.is_ip4()) { if (ip.is_ip4()) {
std::string webserver_url = "http://" + ip.str() + ":" + to_string(USE_WEBSERVER_PORT); char url_buffer[64];
urls.push_back(webserver_url); snprintf(url_buffer, sizeof(url_buffer), "http://%s:%d", ip.str().c_str(), USE_WEBSERVER_PORT);
url_strings[url_count++] = url_buffer;
break; break;
} }
} }
#endif #endif
std::vector<uint8_t> data = improv::build_rpc_response(improv::WIFI_SETTINGS, urls); // Pass to build_rpc_response using vector constructor from iterators to avoid extra copies
std::vector<uint8_t> data = improv::build_rpc_response(
improv::WIFI_SETTINGS, std::vector<std::string>(url_strings, url_strings + url_count));
this->send_response_(data); this->send_response_(data);
} else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) { } else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) {
ESP_LOGD(TAG, "WiFi provisioned externally"); ESP_LOGD(TAG, "WiFi provisioned externally");

View File

@@ -7,6 +7,7 @@
#include "esphome/components/esp32_ble_server/ble_characteristic.h" #include "esphome/components/esp32_ble_server/ble_characteristic.h"
#include "esphome/components/esp32_ble_server/ble_server.h" #include "esphome/components/esp32_ble_server/ble_server.h"
#include "esphome/components/improv_base/improv_base.h"
#include "esphome/components/wifi/wifi_component.h" #include "esphome/components/wifi/wifi_component.h"
#ifdef USE_ESP32_IMPROV_STATE_CALLBACK #ifdef USE_ESP32_IMPROV_STATE_CALLBACK
@@ -32,7 +33,7 @@ namespace esp32_improv {
using namespace esp32_ble_server; using namespace esp32_ble_server;
class ESP32ImprovComponent : public Component { class ESP32ImprovComponent : public Component, public improv_base::ImprovBase {
public: public:
ESP32ImprovComponent(); ESP32ImprovComponent();
void dump_config() override; void dump_config() override;

View File

@@ -190,7 +190,9 @@ async def to_code(config):
cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define("ESPHOME_VARIANT", "ESP8266")
cg.add_define(ThreadModel.SINGLE) cg.add_define(ThreadModel.SINGLE)
cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) cg.add_platformio_option(
"extra_scripts", ["pre:testing_mode.py", "post:post_build.py"]
)
conf = config[CONF_FRAMEWORK] conf = config[CONF_FRAMEWORK]
cg.add_platformio_option("framework", "arduino") cg.add_platformio_option("framework", "arduino")
@@ -230,6 +232,12 @@ async def to_code(config):
# For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;`
cg.add_build_flag("-DNEW_OOM_ABORT") cg.add_build_flag("-DNEW_OOM_ABORT")
# In testing mode, fake larger memory to allow linking grouped component tests
# Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing
# we pretend it has much larger memory to test that components compile together
if CORE.testing_mode:
cg.add_build_flag("-DESPHOME_TESTING_MODE")
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
@@ -265,3 +273,8 @@ def copy_files():
post_build_file, post_build_file,
CORE.relative_build_path("post_build.py"), CORE.relative_build_path("post_build.py"),
) )
testing_mode_file = dir / "testing_mode.py.script"
copy_file_if_changed(
testing_mode_file,
CORE.relative_build_path("testing_mode.py"),
)

View File

@@ -0,0 +1,166 @@
import os
import re
# pylint: disable=E0602
Import("env") # noqa
# Memory sizes for testing mode (allow larger builds for CI component grouping)
TESTING_IRAM_SIZE = "0x200000" # 2MB
TESTING_DRAM_SIZE = "0x200000" # 2MB
TESTING_FLASH_SIZE = "0x2000000" # 32MB
def patch_segment_size(content, segment_name, new_size, label):
"""Patch a memory segment's length in linker script.
Args:
content: Linker script content
segment_name: Name of the segment (e.g., 'iram1_0_seg')
new_size: New size as hex string (e.g., '0x200000')
label: Human-readable label for logging (e.g., 'IRAM')
Returns:
Tuple of (patched_content, was_patched)
"""
# Match: segment_name : org = 0x..., len = 0x...
pattern = rf"({segment_name}\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)0x[0-9a-fA-F]+"
new_content = re.sub(pattern, rf"\g<1>{new_size}", content)
return new_content, new_content != content
def apply_memory_patches(content):
"""Apply IRAM, DRAM, and Flash patches to linker script content.
Args:
content: Linker script content as string
Returns:
Patched content as string
"""
patches_applied = []
# Patch IRAM (for larger code in IRAM)
content, patched = patch_segment_size(content, "iram1_0_seg", TESTING_IRAM_SIZE, "IRAM")
if patched:
patches_applied.append("IRAM")
# Patch DRAM (for larger BSS/data sections)
content, patched = patch_segment_size(content, "dram0_0_seg", TESTING_DRAM_SIZE, "DRAM")
if patched:
patches_applied.append("DRAM")
# Patch Flash (for larger code sections)
content, patched = patch_segment_size(content, "irom0_0_seg", TESTING_FLASH_SIZE, "Flash")
if patched:
patches_applied.append("Flash")
if patches_applied:
iram_mb = int(TESTING_IRAM_SIZE, 16) // (1024 * 1024)
dram_mb = int(TESTING_DRAM_SIZE, 16) // (1024 * 1024)
flash_mb = int(TESTING_FLASH_SIZE, 16) // (1024 * 1024)
print(f" Patched memory segments: {', '.join(patches_applied)} (IRAM/DRAM: {iram_mb}MB, Flash: {flash_mb}MB)")
return content
def patch_linker_script_file(filepath, description):
"""Patch a linker script file in the build directory with enlarged memory segments.
This function modifies linker scripts in the build directory only (never SDK files).
It patches IRAM, DRAM, and Flash segments to allow larger builds in testing mode.
Args:
filepath: Path to the linker script file in the build directory
description: Human-readable description for logging
Returns:
True if the file was patched, False if already patched or not found
"""
if not os.path.exists(filepath):
print(f"ESPHome: {description} not found at {filepath}")
return False
print(f"ESPHome: Patching {description}...")
with open(filepath, "r") as f:
content = f.read()
patched_content = apply_memory_patches(content)
if patched_content != content:
with open(filepath, "w") as f:
f.write(patched_content)
print(f"ESPHome: Successfully patched {description}")
return True
else:
print(f"ESPHome: {description} already patched or no changes needed")
return False
def patch_local_linker_script(source, target, env):
"""Patch the local.eagle.app.v6.common.ld in build directory.
This patches the preprocessed linker script that PlatformIO creates in the build
directory, enlarging IRAM, DRAM, and Flash segments for testing mode.
Args:
source: SCons source nodes
target: SCons target nodes
env: SCons environment
"""
# Check if we're in testing mode
build_flags = env.get("BUILD_FLAGS", [])
testing_mode = any("-DESPHOME_TESTING_MODE" in flag for flag in build_flags)
if not testing_mode:
return
# Patch the local linker script if it exists
build_dir = env.subst("$BUILD_DIR")
ld_dir = os.path.join(build_dir, "ld")
if os.path.exists(ld_dir):
local_ld = os.path.join(ld_dir, "local.eagle.app.v6.common.ld")
if os.path.exists(local_ld):
patch_linker_script_file(local_ld, "local.eagle.app.v6.common.ld")
# Check if we're in testing mode
build_flags = env.get("BUILD_FLAGS", [])
testing_mode = any("-DESPHOME_TESTING_MODE" in flag for flag in build_flags)
if testing_mode:
# Create a custom linker script in the build directory with patched memory limits
# This allows larger IRAM/DRAM/Flash for CI component grouping tests
build_dir = env.subst("$BUILD_DIR")
ldscript = env.GetProjectOption("board_build.ldscript", "")
assert ldscript, "No linker script configured in board_build.ldscript"
framework_dir = env.PioPlatform().get_package_dir("framework-arduinoespressif8266")
assert framework_dir is not None, "Could not find framework-arduinoespressif8266 package"
# Read the original SDK linker script (read-only, SDK is never modified)
sdk_ld = os.path.join(framework_dir, "tools", "sdk", "ld", ldscript)
# Create a custom version in the build directory (isolated, temporary)
custom_ld = os.path.join(build_dir, f"testing_{ldscript}")
if os.path.exists(sdk_ld) and not os.path.exists(custom_ld):
# Read the SDK linker script
with open(sdk_ld, "r") as f:
content = f.read()
# Apply memory patches (IRAM: 2MB, DRAM: 2MB, Flash: 32MB)
patched_content = apply_memory_patches(content)
# Write the patched linker script to the build directory
with open(custom_ld, "w") as f:
f.write(patched_content)
print(f"ESPHome: Created custom linker script: {custom_ld}")
# Tell the linker to use our custom script from the build directory
assert os.path.exists(custom_ld), f"Custom linker script not found: {custom_ld}"
env.Replace(LDSCRIPT_PATH=custom_ld)
print(f"ESPHome: Using custom linker script with patched memory limits")
# Also patch local.eagle.app.v6.common.ld after PlatformIO creates it
env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", patch_local_linker_script)

View File

@@ -19,6 +19,7 @@ from esphome.const import (
from esphome.core import CORE, coroutine_with_priority from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority from esphome.coroutine import CoroPriority
import esphome.final_validate as fv import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -102,7 +103,16 @@ def ota_esphome_final_validate(config):
) )
CONFIG_SCHEMA = ( def _consume_ota_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for OTA component."""
from esphome.components import socket
# OTA needs 1 listening socket (client connections are temporary during updates)
socket.consume_sockets(1, "ota")(config)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema( cv.Schema(
{ {
cv.GenerateID(): cv.declare_id(ESPHomeOTAComponent), cv.GenerateID(): cv.declare_id(ESPHomeOTAComponent),
@@ -129,18 +139,20 @@ CONFIG_SCHEMA = (
} }
) )
.extend(BASE_OTA_SCHEMA) .extend(BASE_OTA_SCHEMA)
.extend(cv.COMPONENT_SCHEMA) .extend(cv.COMPONENT_SCHEMA),
_consume_ota_sockets,
) )
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
@coroutine_with_priority(CoroPriority.OTA_UPDATES) @coroutine_with_priority(CoroPriority.OTA_UPDATES)
async def to_code(config): async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_port(config[CONF_PORT])) cg.add(var.set_port(config[CONF_PORT]))
if CONF_PASSWORD in config: # Password could be set to an empty string and we can assume that means no password
if config.get(CONF_PASSWORD):
cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add(var.set_auth_password(config[CONF_PASSWORD]))
cg.add_define("USE_OTA_PASSWORD") cg.add_define("USE_OTA_PASSWORD")
# Only include hash algorithms when password is configured # Only include hash algorithms when password is configured

View File

@@ -14,13 +14,13 @@ template<typename... Ts> class SendAction : public Action<Ts...>, public Parente
TEMPLATABLE_VALUE(std::vector<uint8_t>, data); TEMPLATABLE_VALUE(std::vector<uint8_t>, data);
public: public:
void add_on_sent(const std::vector<Action<Ts...> *> &actions) { void add_on_sent(const std::initializer_list<Action<Ts...> *> &actions) {
this->sent_.add_actions(actions); this->sent_.add_actions(actions);
if (this->flags_.wait_for_sent) { if (this->flags_.wait_for_sent) {
this->sent_.add_action(new LambdaAction<Ts...>([this](Ts... x) { this->play_next_(x...); })); this->sent_.add_action(new LambdaAction<Ts...>([this](Ts... x) { this->play_next_(x...); }));
} }
} }
void add_on_error(const std::vector<Action<Ts...> *> &actions) { void add_on_error(const std::initializer_list<Action<Ts...> *> &actions) {
this->error_.add_actions(actions); this->error_.add_actions(actions);
if (this->flags_.wait_for_sent) { if (this->flags_.wait_for_sent) {
this->error_.add_action(new LambdaAction<Ts...>([this](Ts... x) { this->error_.add_action(new LambdaAction<Ts...>([this](Ts... x) {

View File

@@ -0,0 +1,39 @@
"""ESP-NOW transport platform for packet_transport component."""
import esphome.codegen as cg
from esphome.components.packet_transport import (
PacketTransport,
new_packet_transport,
transport_schema,
)
import esphome.config_validation as cv
from esphome.core import HexInt
from esphome.cpp_types import PollingComponent
from .. import ESPNowComponent, espnow_ns
CODEOWNERS = ["@EasilyBoredEngineer"]
DEPENDENCIES = ["espnow"]
ESPNowTransport = espnow_ns.class_("ESPNowTransport", PacketTransport, PollingComponent)
CONF_ESPNOW_ID = "espnow_id"
CONF_PEER_ADDRESS = "peer_address"
CONFIG_SCHEMA = transport_schema(ESPNowTransport).extend(
{
cv.GenerateID(CONF_ESPNOW_ID): cv.use_id(ESPNowComponent),
cv.Optional(CONF_PEER_ADDRESS, default="FF:FF:FF:FF:FF:FF"): cv.mac_address,
}
)
async def to_code(config):
"""Set up the ESP-NOW transport component."""
var, _ = await new_packet_transport(config)
await cg.register_parented(var, config[CONF_ESPNOW_ID])
# Set peer address - convert MAC to parts array like ESP-NOW does
mac = config[CONF_PEER_ADDRESS]
cg.add(var.set_peer_address([HexInt(x) for x in mac.parts]))

View File

@@ -0,0 +1,97 @@
#include "espnow_transport.h"
#ifdef USE_ESP32
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome {
namespace espnow {
static const char *const TAG = "espnow.transport";
bool ESPNowTransport::should_send() { return this->parent_ != nullptr && !this->parent_->is_failed(); }
void ESPNowTransport::setup() {
packet_transport::PacketTransport::setup();
if (this->parent_ == nullptr) {
ESP_LOGE(TAG, "ESPNow component not set");
this->mark_failed();
return;
}
ESP_LOGI(TAG, "Registering ESP-NOW handlers");
ESP_LOGI(TAG, "Peer address: %02X:%02X:%02X:%02X:%02X:%02X", this->peer_address_[0], this->peer_address_[1],
this->peer_address_[2], this->peer_address_[3], this->peer_address_[4], this->peer_address_[5]);
// Register received handler
this->parent_->register_received_handler(static_cast<ESPNowReceivedPacketHandler *>(this));
// Register broadcasted handler
this->parent_->register_broadcasted_handler(static_cast<ESPNowBroadcastedHandler *>(this));
}
void ESPNowTransport::update() {
packet_transport::PacketTransport::update();
this->updated_ = true;
}
void ESPNowTransport::send_packet(const std::vector<uint8_t> &buf) const {
if (this->parent_ == nullptr) {
ESP_LOGE(TAG, "ESPNow component not set");
return;
}
if (buf.empty()) {
ESP_LOGW(TAG, "Attempted to send empty packet");
return;
}
if (buf.size() > ESP_NOW_MAX_DATA_LEN) {
ESP_LOGE(TAG, "Packet too large: %zu bytes (max %d)", buf.size(), ESP_NOW_MAX_DATA_LEN);
return;
}
// Send to configured peer address
this->parent_->send(this->peer_address_.data(), buf.data(), buf.size(), [](esp_err_t err) {
if (err != ESP_OK) {
ESP_LOGW(TAG, "Send failed: %d", err);
}
});
}
bool ESPNowTransport::on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) {
ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0],
info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]);
if (data == nullptr || size == 0) {
ESP_LOGW(TAG, "Received empty or null packet");
return false;
}
this->packet_buffer_.resize(size);
memcpy(this->packet_buffer_.data(), data, size);
this->process_(this->packet_buffer_);
return false; // Allow other handlers to run
}
bool ESPNowTransport::on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) {
ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0],
info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]);
if (data == nullptr || size == 0) {
ESP_LOGW(TAG, "Received empty or null broadcast packet");
return false;
}
this->packet_buffer_.resize(size);
memcpy(this->packet_buffer_.data(), data, size);
this->process_(this->packet_buffer_);
return false; // Allow other handlers to run
}
} // namespace espnow
} // namespace esphome
#endif // USE_ESP32

View File

@@ -0,0 +1,44 @@
#pragma once
#include "../espnow_component.h"
#ifdef USE_ESP32
#include "esphome/core/component.h"
#include "esphome/components/packet_transport/packet_transport.h"
#include <vector>
namespace esphome {
namespace espnow {
class ESPNowTransport : public packet_transport::PacketTransport,
public Parented<ESPNowComponent>,
public ESPNowReceivedPacketHandler,
public ESPNowBroadcastedHandler {
public:
void setup() override;
void update() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void set_peer_address(peer_address_t address) {
memcpy(this->peer_address_.data(), address.data(), ESP_NOW_ETH_ALEN);
}
// ESPNow handler interface
bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override;
bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override;
protected:
void send_packet(const std::vector<uint8_t> &buf) const override;
size_t get_max_packet_size() override { return ESP_NOW_MAX_DATA_LEN; }
bool should_send() override;
peer_address_t peer_address_{{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}};
std::vector<uint8_t> packet_buffer_;
};
} // namespace espnow
} // namespace esphome
#endif // USE_ESP32

View File

@@ -32,6 +32,7 @@ from esphome.const import (
CONF_MISO_PIN, CONF_MISO_PIN,
CONF_MODE, CONF_MODE,
CONF_MOSI_PIN, CONF_MOSI_PIN,
CONF_NUMBER,
CONF_PAGE_ID, CONF_PAGE_ID,
CONF_PIN, CONF_PIN,
CONF_POLLING_INTERVAL, CONF_POLLING_INTERVAL,
@@ -52,12 +53,24 @@ from esphome.core import (
coroutine_with_priority, coroutine_with_priority,
) )
import esphome.final_validate as fv import esphome.final_validate as fv
from esphome.types import ConfigType
CONFLICTS_WITH = ["wifi"] CONFLICTS_WITH = ["wifi"]
DEPENDENCIES = ["esp32"] DEPENDENCIES = ["esp32"]
AUTO_LOAD = ["network"] AUTO_LOAD = ["network"]
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
# RMII pins that are hardcoded on ESP32 and cannot be changed
# These pins are used by the internal Ethernet MAC when using RMII PHYs
ESP32_RMII_FIXED_PINS = {
19: "EMAC_TXD0",
21: "EMAC_TX_EN",
22: "EMAC_TXD1",
25: "EMAC_RXD0",
26: "EMAC_RXD1",
27: "EMAC_RX_CRS_DV",
}
ethernet_ns = cg.esphome_ns.namespace("ethernet") ethernet_ns = cg.esphome_ns.namespace("ethernet")
PHYRegister = ethernet_ns.struct("PHYRegister") PHYRegister = ethernet_ns.struct("PHYRegister")
CONF_PHY_ADDR = "phy_addr" CONF_PHY_ADDR = "phy_addr"
@@ -383,3 +396,39 @@ async def to_code(config):
if CORE.using_arduino: if CORE.using_arduino:
cg.add_library("WiFi", None) cg.add_library("WiFi", None)
def _final_validate_rmii_pins(config: ConfigType) -> None:
"""Validate that RMII pins are not used by other components."""
# Only validate for RMII-based PHYs on ESP32/ESP32P4
if config[CONF_TYPE] in SPI_ETHERNET_TYPES or config[CONF_TYPE] == "OPENETH":
return # SPI and OPENETH don't use RMII
variant = get_esp32_variant()
if variant not in (VARIANT_ESP32, VARIANT_ESP32P4):
return # Only ESP32 classic and P4 have RMII
# Check all used pins against RMII reserved pins
for pin_list in pins.PIN_SCHEMA_REGISTRY.pins_used.values():
for pin_path, _, pin_config in pin_list:
pin_num = pin_config.get(CONF_NUMBER)
if pin_num not in ESP32_RMII_FIXED_PINS:
continue
# Found a conflict - show helpful error message
pin_function = ESP32_RMII_FIXED_PINS[pin_num]
component_path = ".".join(str(p) for p in pin_path)
raise cv.Invalid(
f"GPIO{pin_num} is reserved for Ethernet RMII ({pin_function}) and cannot be used. "
f"This pin is hardcoded by ESP-IDF and cannot be changed when using RMII Ethernet PHYs. "
f"Please choose a different GPIO pin for '{component_path}'.",
path=pin_path,
)
def _final_validate(config: ConfigType) -> ConfigType:
"""Final validation for Ethernet component."""
_final_validate_rmii_pins(config)
return config
FINAL_VALIDATE_SCHEMA = _final_validate

View File

@@ -8,12 +8,19 @@ namespace event {
static const char *const TAG = "event"; static const char *const TAG = "event";
void Event::trigger(const std::string &event_type) { void Event::trigger(const std::string &event_type) {
auto found = types_.find(event_type); // Linear search - faster than std::set for small datasets (1-5 items typical)
if (found == types_.end()) { const std::string *found = nullptr;
for (const auto &type : this->types_) {
if (type == event_type) {
found = &type;
break;
}
}
if (found == nullptr) {
ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", this->get_name().c_str(), event_type.c_str()); ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", this->get_name().c_str(), event_type.c_str());
return; return;
} }
last_event_type = &(*found); last_event_type = found;
ESP_LOGD(TAG, "'%s' Triggered event '%s'", this->get_name().c_str(), last_event_type->c_str()); ESP_LOGD(TAG, "'%s' Triggered event '%s'", this->get_name().c_str(), last_event_type->c_str());
this->event_callback_.call(event_type); this->event_callback_.call(event_type);
} }

View File

@@ -1,6 +1,5 @@
#pragma once #pragma once
#include <set>
#include <string> #include <string>
#include "esphome/core/component.h" #include "esphome/core/component.h"
@@ -26,13 +25,13 @@ class Event : public EntityBase, public EntityBase_DeviceClass {
const std::string *last_event_type; const std::string *last_event_type;
void trigger(const std::string &event_type); void trigger(const std::string &event_type);
void set_event_types(const std::set<std::string> &event_types) { this->types_ = event_types; } void set_event_types(const std::initializer_list<std::string> &event_types) { this->types_ = event_types; }
std::set<std::string> get_event_types() const { return this->types_; } const FixedVector<std::string> &get_event_types() const { return this->types_; }
void add_on_event_callback(std::function<void(const std::string &event_type)> &&callback); void add_on_event_callback(std::function<void(const std::string &event_type)> &&callback);
protected: protected:
CallbackManager<void(const std::string &event_type)> event_callback_; CallbackManager<void(const std::string &event_type)> event_callback_;
std::set<std::string> types_; FixedVector<std::string> types_;
}; };
} // namespace event } // namespace event

View File

@@ -38,7 +38,6 @@ IS_PLATFORM_COMPONENT = True
fan_ns = cg.esphome_ns.namespace("fan") fan_ns = cg.esphome_ns.namespace("fan")
Fan = fan_ns.class_("Fan", cg.EntityBase) Fan = fan_ns.class_("Fan", cg.EntityBase)
FanState = fan_ns.class_("Fan", Fan, cg.Component)
FanDirection = fan_ns.enum("FanDirection", is_class=True) FanDirection = fan_ns.enum("FanDirection", is_class=True)
FAN_DIRECTION_ENUM = { FAN_DIRECTION_ENUM = {

View File

@@ -1,8 +1,8 @@
#pragma once #pragma once
#include "esphome/core/component.h"
#include "esphome/core/automation.h" #include "esphome/core/automation.h"
#include "fan_state.h" #include "esphome/core/component.h"
#include "fan.h"
namespace esphome { namespace esphome {
namespace fan { namespace fan {

View File

@@ -51,7 +51,14 @@ void FanCall::validate_() {
if (!this->preset_mode_.empty()) { if (!this->preset_mode_.empty()) {
const auto &preset_modes = traits.supported_preset_modes(); const auto &preset_modes = traits.supported_preset_modes();
if (preset_modes.find(this->preset_mode_) == preset_modes.end()) { bool found = false;
for (const auto &mode : preset_modes) {
if (mode == this->preset_mode_) {
found = true;
break;
}
}
if (!found) {
ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), this->preset_mode_.c_str()); ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), this->preset_mode_.c_str());
this->preset_mode_.clear(); this->preset_mode_.clear();
} }
@@ -191,9 +198,14 @@ void Fan::save_state_() {
if (this->get_traits().supports_preset_modes() && !this->preset_mode.empty()) { if (this->get_traits().supports_preset_modes() && !this->preset_mode.empty()) {
const auto &preset_modes = this->get_traits().supported_preset_modes(); const auto &preset_modes = this->get_traits().supported_preset_modes();
// Store index of current preset mode // Store index of current preset mode
auto preset_iterator = preset_modes.find(this->preset_mode); size_t i = 0;
if (preset_iterator != preset_modes.end()) for (const auto &mode : preset_modes) {
state.preset_mode = std::distance(preset_modes.begin(), preset_iterator); if (mode == this->preset_mode) {
state.preset_mode = i;
break;
}
i++;
}
} }
this->rtc_.save(&state); this->rtc_.save(&state);

View File

@@ -1,16 +0,0 @@
#include "fan_state.h"
namespace esphome {
namespace fan {
static const char *const TAG = "fan";
void FanState::setup() {
auto restore = this->restore_state_();
if (restore)
restore->to_call(*this).perform();
}
float FanState::get_setup_priority() const { return setup_priority::DATA - 1.0f; }
} // namespace fan
} // namespace esphome

View File

@@ -1,34 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "fan.h"
namespace esphome {
namespace fan {
enum ESPDEPRECATED("LegacyFanDirection members are deprecated, use FanDirection instead.",
"2022.2") LegacyFanDirection {
FAN_DIRECTION_FORWARD = 0,
FAN_DIRECTION_REVERSE = 1
};
class ESPDEPRECATED("FanState is deprecated, use Fan instead.", "2022.2") FanState : public Fan, public Component {
public:
FanState() = default;
/// Get the traits of this fan.
FanTraits get_traits() override { return this->traits_; }
/// Set the traits of this fan (i.e. what features it supports).
void set_traits(const FanTraits &traits) { this->traits_ = traits; }
void setup() override;
float get_setup_priority() const override;
protected:
void control(const FanCall &call) override { this->publish_state(); }
FanTraits traits_{};
};
} // namespace fan
} // namespace esphome

View File

@@ -1,8 +1,7 @@
#include <set>
#include <utility>
#pragma once #pragma once
#include <vector>
namespace esphome { namespace esphome {
#ifdef USE_API #ifdef USE_API
@@ -36,9 +35,9 @@ class FanTraits {
/// Set whether this fan supports changing direction /// Set whether this fan supports changing direction
void set_direction(bool direction) { this->direction_ = direction; } void set_direction(bool direction) { this->direction_ = direction; }
/// Return the preset modes supported by the fan. /// Return the preset modes supported by the fan.
std::set<std::string> supported_preset_modes() const { return this->preset_modes_; } const std::vector<std::string> &supported_preset_modes() const { return this->preset_modes_; }
/// Set the preset modes supported by the fan. /// Set the preset modes supported by the fan.
void set_supported_preset_modes(const std::set<std::string> &preset_modes) { this->preset_modes_ = preset_modes; } void set_supported_preset_modes(const std::vector<std::string> &preset_modes) { this->preset_modes_ = preset_modes; }
/// Return if preset modes are supported /// Return if preset modes are supported
bool supports_preset_modes() const { return !this->preset_modes_.empty(); } bool supports_preset_modes() const { return !this->preset_modes_.empty(); }
@@ -46,17 +45,17 @@ class FanTraits {
#ifdef USE_API #ifdef USE_API
// The API connection is a friend class to access internal methods // The API connection is a friend class to access internal methods
friend class api::APIConnection; friend class api::APIConnection;
// This method returns a reference to the internal preset modes set. // This method returns a reference to the internal preset modes.
// It is used by the API to avoid copying data when encoding messages. // 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. // Warning: Do not use this method outside of the API connection code.
// It returns a reference to internal data that can be invalidated. // It returns a reference to internal data that can be invalidated.
const std::set<std::string> &supported_preset_modes_for_api_() const { return this->preset_modes_; } const std::vector<std::string> &supported_preset_modes_for_api_() const { return this->preset_modes_; }
#endif #endif
bool oscillation_{false}; bool oscillation_{false};
bool speed_{false}; bool speed_{false};
bool direction_{false}; bool direction_{false};
int speed_count_{}; int speed_count_{};
std::set<std::string> preset_modes_{}; std::vector<std::string> preset_modes_{};
}; };
} // namespace fan } // namespace fan

View File

@@ -67,7 +67,7 @@ void GPIOSwitch::write_state(bool state) {
this->pin_->digital_write(state); this->pin_->digital_write(state);
this->publish_state(state); this->publish_state(state);
} }
void GPIOSwitch::set_interlock(const std::vector<Switch *> &interlock) { this->interlock_ = interlock; } void GPIOSwitch::set_interlock(const std::initializer_list<Switch *> &interlock) { this->interlock_ = interlock; }
} // namespace gpio } // namespace gpio
} // namespace esphome } // namespace esphome

View File

@@ -2,10 +2,9 @@
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/components/switch/switch.h" #include "esphome/components/switch/switch.h"
#include <vector>
namespace esphome { namespace esphome {
namespace gpio { namespace gpio {
@@ -19,14 +18,14 @@ class GPIOSwitch : public switch_::Switch, public Component {
void setup() override; void setup() override;
void dump_config() override; void dump_config() override;
void set_interlock(const std::vector<Switch *> &interlock); void set_interlock(const std::initializer_list<Switch *> &interlock);
void set_interlock_wait_time(uint32_t interlock_wait_time) { interlock_wait_time_ = interlock_wait_time; } void set_interlock_wait_time(uint32_t interlock_wait_time) { interlock_wait_time_ = interlock_wait_time; }
protected: protected:
void write_state(bool state) override; void write_state(bool state) override;
GPIOPin *pin_; GPIOPin *pin_;
std::vector<Switch *> interlock_; FixedVector<Switch *> interlock_;
uint32_t interlock_wait_time_{0}; uint32_t interlock_wait_time_{0};
}; };

View File

@@ -65,7 +65,7 @@ HaierClimateBase::HaierClimateBase()
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}); {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH});
this->traits_.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, this->traits_.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH,
climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL}); climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL});
this->traits_.set_supports_current_temperature(true); this->traits_.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
} }
HaierClimateBase::~HaierClimateBase() {} HaierClimateBase::~HaierClimateBase() {}

View File

@@ -22,7 +22,7 @@ class HBridgeFan : public Component, public fan::Fan {
void set_pin_a(output::FloatOutput *pin_a) { pin_a_ = pin_a; } void set_pin_a(output::FloatOutput *pin_a) { pin_a_ = pin_a; }
void set_pin_b(output::FloatOutput *pin_b) { pin_b_ = pin_b; } void set_pin_b(output::FloatOutput *pin_b) { pin_b_ = pin_b; }
void set_enable_pin(output::FloatOutput *enable) { enable_ = enable; } void set_enable_pin(output::FloatOutput *enable) { enable_ = enable; }
void set_preset_modes(const std::set<std::string> &presets) { preset_modes_ = presets; } void set_preset_modes(const std::vector<std::string> &presets) { preset_modes_ = presets; }
void setup() override; void setup() override;
void dump_config() override; void dump_config() override;
@@ -38,7 +38,7 @@ class HBridgeFan : public Component, public fan::Fan {
int speed_count_{}; int speed_count_{};
DecayMode decay_mode_{DECAY_MODE_SLOW}; DecayMode decay_mode_{DECAY_MODE_SLOW};
fan::FanTraits traits_; fan::FanTraits traits_;
std::set<std::string> preset_modes_{}; std::vector<std::string> preset_modes_{};
void control(const fan::FanCall &call) override; void control(const fan::FanCall &call) override;
void write_state_(); void write_state_();

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