1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-04 17:11:51 +00:00

Compare commits

..

48 Commits

Author SHA1 Message Date
J. Nick Koston
eb48ff83a3 Merge remote-tracking branch 'upstream/sensor_init_cleanup' into fix_clang_tidy_split_comp_file_count_sensor_test 2025-10-20 16:08:50 -10:00
J. Nick Koston
c9e166905f [ci] Fix clang-tidy split decision to account for component dependencies 2025-10-20 16:07:58 -10:00
J. Nick Koston
b698b45809 [sensor,text_sensor,binary_sensor] Optimize filter parameters with std::initializer_list 2025-10-20 14:11:49 -10:00
J. Nick Koston
6a239f4d1c [ci] Prefer platform-specific tests for memory impact analysis (#11398) 2025-10-21 10:25:33 +13:00
J. Nick Koston
ffb0e854b6 [ci] Optimize clang-tidy for small PRs by avoiding unnecessary job spitting (#11402)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 10:24:46 +13:00
Jonathan Swoboda
6fbd0e3385 [esp32_hosted] Bump esp hosted (#11414) 2025-10-20 11:12:07 -10:00
dependabot[bot]
426511e78d Bump actions/download-artifact from 4.3.0 to 5.0.0 (#11419)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 11:11:15 -10:00
dependabot[bot]
97d91fee85 Bump pylint from 4.0.1 to 4.0.2 (#11418)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 11:10:33 -10:00
J. Nick Koston
0f4b54aa82 [esp32_improv, improv_base] Reduce flash usage by 352 bytes (#11406) 2025-10-20 11:07:39 -10:00
J. Nick Koston
1706a69fad [sensor] Optimize filter memory usage with ValueListFilter base class (#11407)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 09:38:49 +13:00
J. Nick Koston
e23d66a8cf [esp32] Automatic CONFIG_LWIP_MAX_SOCKETS configuration based on component needs (#11378) 2025-10-21 09:38:34 +13:00
J. Nick Koston
46101fd830 Add tests for FilterOutValueFilter and ThrottleWithPriorityFilter (#11408)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-20 09:25:03 -10:00
J. Nick Koston
e988905c2f [json] Add basic compile tests (#11409)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 07:31:59 +13:00
Patrick
abb57f08f5 [pipsolar] cleanup / refactoring (#10291)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2025-10-20 17:08:31 +00:00
EasilyBoredEngineer
ca2fe994a1 [espnow] Add transport platform for packet_transport (#11025)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-10-20 10:44:20 -04:00
Peter Zich
03def13917 [hdc1080] Make HDC1080_CMD_CONFIGURATION failure a warning (and log it) (#11355)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-10-20 09:13:13 -04:00
Keith Burzinski
63f100a8ca [bang_bang] Various clean-up (#11356) 2025-10-19 22:56:25 -10:00
Juan Antonio Aldea
ea4e5fd7bd [climate] Migrate components to the new API (#11369)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2025-10-19 22:20:39 -10:00
Enrico Galli
12e9c5e60e [epaper_spi] Fix busy pin logic (#11349)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-10-20 19:11:09 +13:00
Aman kumar
3d82c5baf7 [esp32_improv]: add next_url support for WiFi provisioning (#10757)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2025-10-20 19:10:38 +13:00
Keith Burzinski
6f5e36ffc3 [climate] First pass at some optimization (#11366)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-10-19 23:42:54 -05:00
Grant Le Roux
118b1d8593 MQTT Light - Min/Max Color Temperature (#11103)
Co-authored-by: Cram42 <5396871+cram42@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-10-20 17:05:05 +13:00
Jesse Hills
319ba4a504 [cover] Clean up deprecated functions from 2021.9 (#11391) 2025-10-20 04:03:09 +00:00
J. Nick Koston
ae8336c268 [esp32][ci] Fix IRAM overflow in grouped component tests for ESP32-IDF (#11386) 2025-10-20 03:58:03 +00:00
J. Nick Koston
1b38518c63 [tests] Fix flaky test_noise_corrupt_encrypted_frame integration test (#11405) 2025-10-20 03:45:44 +00:00
J. Nick Koston
c00977df54 [climate] Add basic compile tests for climate component (#11404) 2025-10-20 03:27:04 +00:00
J. Nick Koston
255b5a3abd [ci] Skip memory analysis when only Python/config files change in core (#11397) 2025-10-20 16:13:08 +13:00
Clyde Stubbs
dd732dd155 [mipi_rgb] Add Waveshare 5" 1024x600 (#11206) 2025-10-20 14:09:36 +11:00
Jesse Hills
22fec4329f [fan] Clean up deprecated code from 2022.2 (#11392) 2025-10-20 03:02:03 +00:00
Stefan Rado
8f1c4634ec [uponor_smatrix] Use combined 32 bit addresses instead of separate 16 bit system and device addresses (#11066)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-10-19 16:49:06 -10:00
tomaszduda23
c15f1a9be8 [nrf52] add missing defines for tests (#11384)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-10-19 16:11:44 -10:00
J. Nick Koston
11b53096a6 [ci] Fix fork PR workflow failing to find PRs from forks (#11396) 2025-10-19 15:58:05 -10:00
J. Nick Koston
6a18367949 [cli] Add analyze-memory command (#11395)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-20 14:26:37 +13:00
Javier Peletier
a59b1494d8 [substitutions] Recursive substitutions and better jinja error handling and debug help (#10806) 2025-10-20 14:17:16 +13:00
Jesse Hills
e6ce5c58d1 Merge branch 'release' into dev 2025-10-20 13:43:31 +13:00
Jesse Hills
ebc0f5f7c9 Merge pull request #11387 from esphome/bump-2025.10.2
2025.10.2
2025-10-20 13:42:48 +13:00
J. Nick Koston
87ca8784ef [openthread] Backport address resolution support to prevent OTA crash (#11312)
Co-authored-by: Daniel Stiner <danstiner@gmail.com>
2025-10-20 10:12:56 +13:00
Jesse Hills
a186c1062f Bump version to 2025.10.2 2025-10-20 10:06:43 +13:00
Jonathan Swoboda
ea38237f29 [esp32] Fix OTA rollback (#11300)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-10-20 10:06:43 +13:00
J. Nick Koston
6aff1394ad [core] Fix IndexError when OTA devices cannot be resolved (#11311) 2025-10-20 10:06:43 +13:00
Spectre5
0e34d1b64d Change all temperature offsets to temperature_delta (#11347) 2025-10-20 10:06:43 +13:00
tomaszduda23
1483cee0fb [dashboard] fix migration to Path (#11342)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-10-20 10:06:43 +13:00
J. Nick Koston
8c1bd2fd85 [dashboard] Fix binary download with packages using secrets after Path migration (#11313) 2025-10-20 10:06:43 +13:00
Daniel Stiner
ea609dc0f6 [const] Add CONF_OPENTHREAD (#11318) 2025-10-20 10:06:42 +13:00
Jonathan Swoboda
913095f6be [esp32] Reduce tx power on Arduino (#11304) 2025-10-20 10:06:42 +13:00
Jonathan Swoboda
bb24ad4a30 [htu21d] Revert register address change (#11291) 2025-10-20 10:06:42 +13:00
Jonathan Swoboda
0d612fecfc [core] Add ESP32 ROM functions to reserved ids (#11293) 2025-10-20 10:06:42 +13:00
J. Nick Koston
9c235b4140 [datetime] Fix DateTimeStateTrigger compilation when time component is not used (#11287) 2025-10-20 10:06:42 +13:00
96 changed files with 3496 additions and 2425 deletions

View File

@@ -28,20 +28,23 @@ jobs:
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 "/repos/${{ github.repository }}/commits/$head_sha/pulls" \
--jq '.[0] | {number: .number, base_ref: .base.ref}')
if [ -z "$pr_data" ] || [ "$pr_data" == "null" ]; then
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
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 "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
@@ -87,9 +90,9 @@ jobs:
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
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> $GITHUB_OUTPUT
echo "found=false" >> "$GITHUB_OUTPUT"
echo "Memory analysis artifacts not found, skipping comment"
fi

View File

@@ -170,11 +170,13 @@ jobs:
outputs:
integration-tests: ${{ steps.determine.outputs.integration-tests }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
python-linters: ${{ steps.determine.outputs.python-linters }}
changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
component-test-count: ${{ steps.determine.outputs.component-test-count }}
changed-cpp-file-count: ${{ steps.determine.outputs.changed-cpp-file-count }}
memory_impact: ${{ steps.determine.outputs.memory-impact }}
steps:
- name: Check out code from GitHub
@@ -200,11 +202,13 @@ jobs:
# Extract individual fields
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT
echo "changed-cpp-file-count=$(echo "$output" | jq -r '.changed_cpp_file_count')" >> $GITHUB_OUTPUT
echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT
integration-tests:
@@ -243,7 +247,7 @@ jobs:
. venv/bin/activate
pytest -vv --no-cov --tb=native -n auto tests/integration/
clang-tidy:
clang-tidy-single:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
needs:
@@ -261,22 +265,6 @@ jobs:
name: Run script/clang-tidy for ESP8266
options: --environment esp8266-arduino-tidy --grep USE_ESP8266
pio_cache_key: tidyesp8266
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 1/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 1
pio_cache_key: tidyesp32
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 2/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 2
pio_cache_key: tidyesp32
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 3/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 3
pio_cache_key: tidyesp32
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 4/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 4
pio_cache_key: tidyesp32
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF
options: --environment esp32-idf-tidy --grep USE_ESP_IDF
@@ -357,6 +345,166 @@ jobs:
# yamllint disable-line rule:line-length
if: always()
clang-tidy-nosplit:
name: Run script/clang-tidy for ESP32 Arduino
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.clang-tidy-mode == 'nosplit'
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
echo "::add-matcher::.github/workflows/matchers/clang-tidy.json"
- name: Check if full clang-tidy scan needed
id: check_full_scan
run: |
. venv/bin/activate
if python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT
fi
- name: Run clang-tidy
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (hash changed)"
script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy
else
echo "Running clang-tidy on changed files only"
script/clang-tidy --all-headers --fix --changed --environment esp32-arduino-tidy
fi
env:
# Also cache libdeps, store them in a ~/.platformio subfolder
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
clang-tidy-split:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.clang-tidy-mode == 'split'
env:
GH_TOKEN: ${{ github.token }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 1/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 1
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 2/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 2
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 3/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 3
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino 4/4
options: --environment esp32-arduino-tidy --split-num 4 --split-at 4
steps:
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
echo "::add-matcher::.github/workflows/matchers/clang-tidy.json"
- name: Check if full clang-tidy scan needed
id: check_full_scan
run: |
. venv/bin/activate
if python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT
fi
- name: Run clang-tidy
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (hash changed)"
script/clang-tidy --all-headers --fix ${{ matrix.options }}
else
echo "Running clang-tidy on changed files only"
script/clang-tidy --all-headers --fix --changed ${{ matrix.options }}
fi
env:
# Also cache libdeps, store them in a ~/.platformio subfolder
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
test-build-components-splitter:
name: Split components for intelligent grouping (40 weighted per batch)
runs-on: ubuntu-24.04
@@ -432,21 +580,6 @@ jobs:
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-test-${{ 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-test-${{ hashFiles('platformio.ini') }}
- name: Validate and compile components with intelligent grouping
run: |
. venv/bin/activate
@@ -779,13 +912,13 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Download target analysis JSON
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: memory-analysis-pr
path: ./memory-analysis
@@ -812,7 +945,9 @@ jobs:
- pylint
- pytest
- integration-tests
- clang-tidy
- clang-tidy-single
- clang-tidy-nosplit
- clang-tidy-split
- determine-jobs
- test-build-components-splitter
- test-build-components-split

View File

@@ -161,6 +161,7 @@ esphome/components/esp32_rmt_led_strip/* @jesserockz
esphome/components/esp8266/* @esphome/core
esphome/components/esp_ldo/* @clydebarrow
esphome/components/espnow/* @jesserockz
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
esphome/components/ethernet_info/* @gtjadsonsantos
esphome/components/event/* @nohat
esphome/components/exposure_notifications/* @OttoWinter

View File

@@ -62,6 +62,40 @@ from esphome.util import (
_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):
device: list[str] | None
@@ -892,6 +926,54 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
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:
new_name = args.name
for c in new_name:
@@ -1007,6 +1089,7 @@ POST_CONFIG_ACTIONS = {
"idedata": command_idedata,
"rename": command_rename,
"discover": command_discover,
"analyze-memory": command_analyze_memory,
}
SIMPLE_CONFIG_ACTIONS = [
@@ -1292,6 +1375,14 @@ def parse_args(argv):
)
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
# esphome <config> <command>.
#

View File

@@ -28,7 +28,7 @@ class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode
void dump_config() override;
climate::ClimateTraits traits() override {
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_visual_min_temperature(25.0);
traits.set_visual_max_temperature(100.0);

View File

@@ -155,6 +155,17 @@ def _validate_api_config(config: ConfigType) -> ConfigType:
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(
cv.Schema(
{
@@ -222,6 +233,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_validate_api_config,
_consume_api_sockets,
)

View File

@@ -6,6 +6,9 @@ namespace bang_bang {
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() {
this->sensor_->add_on_state_callback([this](float state) {
this->current_temperature = state;
@@ -31,53 +34,63 @@ void BangBangClimate::setup() {
restore->to_call(this).perform();
} else {
// 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;
} else if (supports_cool_) {
} else if (this->supports_cool_) {
this->mode = climate::CLIMATE_MODE_COOL;
} else if (supports_heat_) {
} else if (this->supports_heat_) {
this->mode = climate::CLIMATE_MODE_HEAT;
}
this->change_away_(false);
}
}
void BangBangClimate::control(const climate::ClimateCall &call) {
if (call.get_mode().has_value())
if (call.get_mode().has_value()) {
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();
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();
if (call.get_preset().has_value())
}
if (call.get_preset().has_value()) {
this->change_away_(*call.get_preset() == climate::CLIMATE_PRESET_AWAY);
}
this->compute_state_();
this->publish_state();
}
climate::ClimateTraits BangBangClimate::traits() {
auto traits = climate::ClimateTraits();
traits.set_supports_current_temperature(true);
if (this->humidity_sensor_ != nullptr)
traits.set_supports_current_humidity(true);
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE |
climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION);
if (this->humidity_sensor_ != nullptr) {
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY);
}
traits.set_supported_modes({
climate::CLIMATE_MODE_OFF,
});
if (supports_cool_)
if (this->supports_cool_) {
traits.add_supported_mode(climate::CLIMATE_MODE_COOL);
if (supports_heat_)
}
if (this->supports_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.set_supports_two_point_target_temperature(true);
if (supports_away_) {
}
if (this->supports_away_) {
traits.set_supported_presets({
climate::CLIMATE_PRESET_HOME,
climate::CLIMATE_PRESET_AWAY,
});
}
traits.set_supports_action(true);
return traits;
}
void BangBangClimate::compute_state_() {
if (this->mode == climate::CLIMATE_MODE_OFF) {
this->switch_to_action_(climate::CLIMATE_ACTION_OFF);
@@ -122,6 +135,7 @@ void BangBangClimate::compute_state_() {
this->switch_to_action_(target_action);
}
void BangBangClimate::switch_to_action_(climate::ClimateAction action) {
if (action == this->action) {
// already in target mode
@@ -166,6 +180,7 @@ void BangBangClimate::switch_to_action_(climate::ClimateAction action) {
this->prev_trigger_ = trig;
this->publish_state();
}
void BangBangClimate::change_away_(bool away) {
if (!away) {
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;
}
void BangBangClimate::set_normal_config(const BangBangClimateTargetTempConfig &normal_config) {
this->normal_config_ = normal_config;
}
void BangBangClimate::set_away_config(const BangBangClimateTargetTempConfig &away_config) {
this->supports_away_ = true;
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_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; }
Trigger<> *BangBangClimate::get_idle_trigger() const { return this->idle_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_; }
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::dump_config() {
LOG_CLIMATE("", "Bang Bang Climate", this);
ESP_LOGCONFIG(TAG,

View File

@@ -25,14 +25,15 @@ class BangBangClimate : public climate::Climate, public Component {
void set_sensor(sensor::Sensor *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);
Trigger<> *get_heat_trigger() const;
void set_supports_heat(bool supports_heat);
void set_normal_config(const BangBangClimateTargetTempConfig &normal_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:
/// Override control to change settings of the climate device.
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.
*/
Trigger<> *idle_trigger_;
Trigger<> *idle_trigger_{nullptr};
/** The trigger to call when the controller should switch to cooling mode.
*/
Trigger<> *cool_trigger_;
/** 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};
Trigger<> *cool_trigger_{nullptr};
/** 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
@@ -73,15 +68,23 @@ class BangBangClimate : public climate::Climate, public Component {
* (blinds open) is possible.
*/
Trigger<> *heat_trigger_{nullptr};
bool supports_heat_{false};
/** A reference to the trigger that was previously active.
*
* This is so that the previous trigger can be stopped before enabling a new one.
*/
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};
BangBangClimateTargetTempConfig normal_config_{};
BangBangClimateTargetTempConfig away_config_{};
};

View File

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

View File

@@ -51,7 +51,7 @@ void BinarySensor::add_filter(Filter *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) {
this->add_filter(filter);
}

View File

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

View File

@@ -6,6 +6,42 @@ namespace 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() {
this->parent_->control_callback_.call(*this);
ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str());
@@ -50,47 +86,46 @@ void ClimateCall::perform() {
}
this->parent_->control(*this);
}
void ClimateCall::validate_() {
auto traits = this->parent_->get_traits();
if (this->mode_.has_value()) {
auto mode = *this->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();
}
}
if (this->custom_fan_mode_.has_value()) {
auto custom_fan_mode = *this->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();
}
} else if (this->fan_mode_.has_value()) {
auto fan_mode = *this->fan_mode_;
if (!traits.supports_fan_mode(fan_mode)) {
ESP_LOGW(TAG, " Fan Mode %s is not supported by this device!",
LOG_STR_ARG(climate_fan_mode_to_string(fan_mode)));
ESP_LOGW(TAG, " Fan Mode %s not supported", LOG_STR_ARG(climate_fan_mode_to_string(fan_mode)));
this->fan_mode_.reset();
}
}
if (this->custom_preset_.has_value()) {
auto custom_preset = *this->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();
}
} else if (this->preset_.has_value()) {
auto preset = *this->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();
}
}
if (this->swing_mode_.has_value()) {
auto swing_mode = *this->swing_mode_;
if (!traits.supports_swing_mode(swing_mode)) {
ESP_LOGW(TAG, " Swing Mode %s is not supported by this device!",
LOG_STR_ARG(climate_swing_mode_to_string(swing_mode)));
ESP_LOGW(TAG, " Swing Mode %s not supported", LOG_STR_ARG(climate_swing_mode_to_string(swing_mode)));
this->swing_mode_.reset();
}
}
@@ -99,159 +134,127 @@ void ClimateCall::validate_() {
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 "
"with two-point target temperature!");
"with two-point target temperature");
this->target_temperature_.reset();
} 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();
}
}
if (this->target_temperature_low_.has_value() || this->target_temperature_high_.has_value()) {
if (!traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
ESP_LOGW(TAG, " Cannot set low/high target temperature for this device!");
ESP_LOGW(TAG, " Cannot set low/high target temperature");
this->target_temperature_low_.reset();
this->target_temperature_high_.reset();
}
}
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();
}
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();
}
if (this->target_temperature_low_.has_value() && this->target_temperature_high_.has_value()) {
float low = *this->target_temperature_low_;
float high = *this->target_temperature_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_high_.reset();
}
}
}
ClimateCall &ClimateCall::set_mode(ClimateMode mode) {
this->mode_ = mode;
return *this;
}
ClimateCall &ClimateCall::set_mode(const std::string &mode) {
if (str_equals_case_insensitive(mode, "OFF")) {
this->set_mode(CLIMATE_MODE_OFF);
} else if (str_equals_case_insensitive(mode, "AUTO")) {
this->set_mode(CLIMATE_MODE_AUTO);
} 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());
for (const auto &mode_entry : CLIMATE_MODES_BY_STR) {
if (str_equals_case_insensitive(mode, mode_entry.str)) {
this->set_mode(static_cast<ClimateMode>(mode_entry.value));
return *this;
}
}
ESP_LOGW(TAG, "'%s' - Unrecognized mode %s", this->parent_->get_name().c_str(), mode.c_str());
return *this;
}
ClimateCall &ClimateCall::set_fan_mode(ClimateFanMode fan_mode) {
this->fan_mode_ = fan_mode;
this->custom_fan_mode_.reset();
return *this;
}
ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) {
if (str_equals_case_insensitive(fan_mode, "ON")) {
this->set_fan_mode(CLIMATE_FAN_ON);
} else if (str_equals_case_insensitive(fan_mode, "OFF")) {
this->set_fan_mode(CLIMATE_FAN_OFF);
} 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());
for (const auto &mode_entry : CLIMATE_FAN_MODES_BY_STR) {
if (str_equals_case_insensitive(fan_mode, mode_entry.str)) {
this->set_fan_mode(static_cast<ClimateFanMode>(mode_entry.value));
return *this;
}
}
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;
}
ClimateCall &ClimateCall::set_fan_mode(optional<std::string> fan_mode) {
if (fan_mode.has_value()) {
this->set_fan_mode(fan_mode.value());
}
return *this;
}
ClimateCall &ClimateCall::set_preset(ClimatePreset preset) {
this->preset_ = preset;
this->custom_preset_.reset();
return *this;
}
ClimateCall &ClimateCall::set_preset(const std::string &preset) {
if (str_equals_case_insensitive(preset, "ECO")) {
this->set_preset(CLIMATE_PRESET_ECO);
} else if (str_equals_case_insensitive(preset, "AWAY")) {
this->set_preset(CLIMATE_PRESET_AWAY);
} 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());
for (const auto &preset_entry : CLIMATE_PRESETS_BY_STR) {
if (str_equals_case_insensitive(preset, preset_entry.str)) {
this->set_preset(static_cast<ClimatePreset>(preset_entry.value));
return *this;
}
}
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;
}
ClimateCall &ClimateCall::set_preset(optional<std::string> preset) {
if (preset.has_value()) {
this->set_preset(preset.value());
}
return *this;
}
ClimateCall &ClimateCall::set_swing_mode(ClimateSwingMode swing_mode) {
this->swing_mode_ = swing_mode;
return *this;
}
ClimateCall &ClimateCall::set_swing_mode(const std::string &swing_mode) {
if (str_equals_case_insensitive(swing_mode, "OFF")) {
this->set_swing_mode(CLIMATE_SWING_OFF);
} else if (str_equals_case_insensitive(swing_mode, "BOTH")) {
this->set_swing_mode(CLIMATE_SWING_BOTH);
} 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());
for (const auto &mode_entry : CLIMATE_SWING_MODES_BY_STR) {
if (str_equals_case_insensitive(swing_mode, mode_entry.str)) {
this->set_swing_mode(static_cast<ClimateSwingMode>(mode_entry.value));
return *this;
}
}
ESP_LOGW(TAG, "'%s' - Unrecognized swing mode %s", this->parent_->get_name().c_str(), swing_mode.c_str());
return *this;
}
@@ -259,59 +262,71 @@ ClimateCall &ClimateCall::set_target_temperature(float target_temperature) {
this->target_temperature_ = target_temperature;
return *this;
}
ClimateCall &ClimateCall::set_target_temperature_low(float target_temperature_low) {
this->target_temperature_low_ = target_temperature_low;
return *this;
}
ClimateCall &ClimateCall::set_target_temperature_high(float target_temperature_high) {
this->target_temperature_high_ = target_temperature_high;
return *this;
}
ClimateCall &ClimateCall::set_target_humidity(float target_humidity) {
this->target_humidity_ = target_humidity;
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_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_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<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<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) {
this->target_temperature_high_ = target_temperature_high;
return *this;
}
ClimateCall &ClimateCall::set_target_temperature_low(optional<float> target_temperature_low) {
this->target_temperature_low_ = target_temperature_low;
return *this;
}
ClimateCall &ClimateCall::set_target_temperature(optional<float> target_temperature) {
this->target_temperature_ = target_temperature;
return *this;
}
ClimateCall &ClimateCall::set_target_humidity(optional<float> target_humidity) {
this->target_humidity_ = target_humidity;
return *this;
}
ClimateCall &ClimateCall::set_mode(optional<ClimateMode> mode) {
this->mode_ = mode;
return *this;
}
ClimateCall &ClimateCall::set_fan_mode(optional<ClimateFanMode> fan_mode) {
this->fan_mode_ = fan_mode;
this->custom_fan_mode_.reset();
return *this;
}
ClimateCall &ClimateCall::set_preset(optional<ClimatePreset> preset) {
this->preset_ = preset;
this->custom_preset_.reset();
return *this;
}
ClimateCall &ClimateCall::set_swing_mode(optional<ClimateSwingMode> swing_mode) {
this->swing_mode_ = swing_mode;
return *this;
@@ -336,6 +351,7 @@ optional<ClimateDeviceRestoreState> Climate::restore_state_() {
return {};
return recovered;
}
void Climate::save_state_() {
#if (defined(USE_ESP_IDF) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \
!defined(CLANG_TIDY)
@@ -398,6 +414,7 @@ void Climate::save_state_() {
this->rtc_.save(&state);
}
void Climate::publish_state() {
ESP_LOGD(TAG, "'%s' - Sending state:", this->name_.c_str());
auto traits = this->get_traits();
@@ -469,16 +486,20 @@ ClimateTraits Climate::get_traits() {
void Climate::set_visual_min_temperature_override(float 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) {
this->visual_max_temperature_override_ = visual_max_temperature_override;
}
void Climate::set_visual_temperature_step_override(float target, float current) {
this->visual_target_temperature_step_override_ = target;
this->visual_current_temperature_step_override_ = current;
}
void Climate::set_visual_min_humidity_override(float 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) {
this->visual_max_humidity_override_ = visual_max_humidity_override;
}
@@ -510,6 +531,7 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) {
}
return call;
}
void ClimateDeviceRestoreState::apply(Climate *climate) {
auto traits = climate->get_traits();
climate->mode = this->mode;
@@ -579,68 +601,68 @@ void Climate::dump_traits_(const char *tag) {
auto traits = this->get_traits();
ESP_LOGCONFIG(tag, "ClimateTraits:");
ESP_LOGCONFIG(tag,
" [x] Visual settings:\n"
" - Min temperature: %.1f\n"
" - Max temperature: %.1f\n"
" - Temperature step:\n"
" Target: %.1f",
" Visual settings:\n"
" - Min temperature: %.1f\n"
" - Max temperature: %.1f\n"
" - Temperature step:\n"
" Target: %.1f",
traits.get_visual_min_temperature(), traits.get_visual_max_temperature(),
traits.get_visual_target_temperature_step());
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.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY |
climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) {
ESP_LOGCONFIG(tag,
" - Min humidity: %.0f\n"
" - Max humidity: %.0f",
" - Min humidity: %.0f\n"
" - Max humidity: %.0f",
traits.get_visual_min_humidity(), traits.get_visual_max_humidity());
}
if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
ESP_LOGCONFIG(tag, " [x] Supports two-point target temperature");
ESP_LOGCONFIG(tag, " Supports two-point target 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.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
ESP_LOGCONFIG(tag, " [x] Supports target humidity");
ESP_LOGCONFIG(tag, " Supports target 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.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
ESP_LOGCONFIG(tag, " [x] Supports action");
ESP_LOGCONFIG(tag, " Supports action");
}
if (!traits.get_supported_modes().empty()) {
ESP_LOGCONFIG(tag, " [x] Supported modes:");
ESP_LOGCONFIG(tag, " 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()) {
ESP_LOGCONFIG(tag, " [x] Supported fan modes:");
ESP_LOGCONFIG(tag, " 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()) {
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())
ESP_LOGCONFIG(tag, " - %s", s.c_str());
ESP_LOGCONFIG(tag, " - %s", s.c_str());
}
if (!traits.get_supported_presets().empty()) {
ESP_LOGCONFIG(tag, " [x] Supported presets:");
ESP_LOGCONFIG(tag, " 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()) {
ESP_LOGCONFIG(tag, " [x] Supported custom presets:");
ESP_LOGCONFIG(tag, " 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()) {
ESP_LOGCONFIG(tag, " [x] Supported swing modes:");
ESP_LOGCONFIG(tag, " 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

@@ -93,30 +93,31 @@ class ClimateCall {
void perform();
const optional<ClimateMode> &get_mode() const;
const optional<float> &get_target_temperature() const;
const optional<float> &get_target_temperature_low() const;
const optional<float> &get_target_temperature_high() const;
const optional<float> &get_target_humidity() const;
const optional<ClimateMode> &get_mode() const;
const optional<ClimateFanMode> &get_fan_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<std::string> &get_custom_fan_mode() const;
const optional<std::string> &get_custom_preset() const;
protected:
void validate_();
Climate *const parent_;
optional<ClimateMode> mode_;
optional<float> target_temperature_;
optional<float> target_temperature_low_;
optional<float> target_temperature_high_;
optional<float> target_humidity_;
optional<ClimateMode> mode_;
optional<ClimateFanMode> fan_mode_;
optional<ClimateSwingMode> swing_mode_;
optional<std::string> custom_fan_mode_;
optional<ClimatePreset> preset_;
optional<std::string> custom_fan_mode_;
optional<std::string> custom_preset_;
};
@@ -169,47 +170,6 @@ class Climate : public EntityBase {
public:
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
* (using publish_state), this callback will be called.
*
@@ -251,6 +211,47 @@ class Climate : public EntityBase {
void set_visual_min_humidity_override(float visual_min_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:
friend ClimateCall;

View File

@@ -8,7 +8,10 @@ static const char *const TAG = "climate_ir";
climate::ClimateTraits ClimateIR::traits() {
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});
if (this->supports_cool_)
traits.add_supported_mode(climate::CLIMATE_MODE_COOL);
@@ -19,7 +22,6 @@ climate::ClimateTraits ClimateIR::traits() {
if (this->supports_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_max_temperature(this->maximum_temperature_);
traits.set_visual_temperature_step(this->temperature_step_);

View File

@@ -1,6 +1,6 @@
#include "cover.h"
#include "esphome/core/log.h"
#include <strings.h>
#include "esphome/core/log.h"
namespace esphome {
namespace cover {
@@ -144,21 +144,7 @@ CoverCall &CoverCall::set_stop(bool stop) {
bool CoverCall::get_stop() const { return this->stop_; }
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::publish_state(bool save) {
this->position = clamp(this->position, 0.0f, 1.0f);

View File

@@ -4,6 +4,7 @@
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "cover_traits.h"
namespace esphome {
@@ -125,25 +126,6 @@ class Cover : public EntityBase, public EntityBase_DeviceClass {
/// Construct a new cover call used to control the cover.
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);

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import contextlib
from dataclasses import dataclass
import itertools
import logging
@@ -102,6 +103,10 @@ COMPILER_OPTIMIZATIONS = {
"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 = [
VARIANT_ESP32,
VARIANT_ESP32C3,
@@ -746,6 +751,72 @@ CONFIG_SCHEMA = cv.All(
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):
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
@@ -779,6 +850,16 @@ async def to_code(config):
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:
cg.add_platformio_option("framework", "espidf")
cg.add_build_flag("-DUSE_ESP_IDF")
@@ -805,6 +886,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True)
add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True)
cg.add_build_flag("-Wno-nonnull-compare")
@@ -855,6 +937,9 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False):
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
_configure_lwip_max_sockets(conf)
if advanced.get(CONF_EXECUTE_FROM_PSRAM, False):
add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True)
add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True)

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

@@ -1,6 +1,7 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_MODE, CONF_PORT
from esphome.types import ConfigType
CODEOWNERS = ["@ayufan"]
AUTO_LOAD = ["camera"]
@@ -13,13 +14,27 @@ Mode = esp32_camera_web_server_ns.enum("Mode")
MODES = {"STREAM": Mode.STREAM, "SNAPSHOT": Mode.SNAPSHOT}
CONFIG_SCHEMA = 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)
def _consume_camera_web_server_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for camera web server."""
from esphome.components import socket
# Each camera web server instance needs 1 listening socket + 2 client connections
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):

View File

@@ -95,7 +95,7 @@ async def to_code(config):
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/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:
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")

View File

@@ -1,11 +1,11 @@
from esphome import automation
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
import esphome.config_validation as cv
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"]
DEPENDENCIES = ["wifi", "esp32"]
@@ -20,6 +20,7 @@ CONF_ON_STOP = "on_stop"
CONF_STATUS_INDICATOR = "status_indicator"
CONF_WIFI_TIMEOUT = "wifi_timeout"
improv_ns = cg.esphome_ns.namespace("improv")
Error = improv_ns.enum("Error")
State = improv_ns.enum("State")
@@ -43,55 +44,63 @@ ESP32ImprovStoppedTrigger = esp32_improv_ns.class_(
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(ESP32ImprovComponent),
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(
CONF_IDENTIFY_DURATION, default="10s"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_AUTHORIZED_DURATION, default="1min"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_WIFI_TIMEOUT, default="1min"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
ESP32ImprovProvisionedTrigger
),
}
),
cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
ESP32ImprovProvisioningTrigger
),
}
),
cv.Optional(CONF_ON_START): automation.validate_automation(
{
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_STOP): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
ESP32ImprovStoppedTrigger
),
}
),
}
).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ESP32ImprovComponent),
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(
CONF_IDENTIFY_DURATION, default="10s"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_AUTHORIZED_DURATION, default="1min"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_WIFI_TIMEOUT, default="1min"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
ESP32ImprovProvisionedTrigger
),
}
),
cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
ESP32ImprovProvisioningTrigger
),
}
),
cv.Optional(CONF_ON_START): automation.validate_automation(
{
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_STOP): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
ESP32ImprovStoppedTrigger
),
}
),
}
)
.extend(improv_base.IMPROV_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
@@ -102,7 +111,8 @@ async def to_code(config):
await cg.register_component(var, config)
cg.add_define("USE_IMPROV")
cg.add_library("improv/Improv", "1.2.4")
await improv_base.setup_improv_core(var, config)
cg.add(var.set_identify_duration(config[CONF_IDENTIFY_DURATION]))
cg.add(var.set_authorized_duration(config[CONF_AUTHORIZED_DURATION]))

View File

@@ -1,10 +1,10 @@
#include "esp32_improv_component.h"
#include "esphome/components/bytebuffer/bytebuffer.h"
#include "esphome/components/esp32_ble/ble.h"
#include "esphome/components/esp32_ble_server/ble_2902.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include "esphome/components/bytebuffer/bytebuffer.h"
#ifdef USE_ESP32
@@ -384,17 +384,32 @@ void ESP32ImprovComponent::check_wifi_connection_() {
this->connecting_sta_ = {};
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;
// 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);
}
// Add default URLs for backward compatibility
url_strings[url_count++] = ESPHOME_MY_LINK;
#ifdef USE_WEBSERVER
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
if (ip.is_ip4()) {
std::string webserver_url = "http://" + ip.str() + ":" + to_string(USE_WEBSERVER_PORT);
urls.push_back(webserver_url);
char url_buffer[64];
snprintf(url_buffer, sizeof(url_buffer), "http://%s:%d", ip.str().c_str(), USE_WEBSERVER_PORT);
url_strings[url_count++] = url_buffer;
break;
}
}
#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);
} else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) {
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_server.h"
#include "esphome/components/improv_base/improv_base.h"
#include "esphome/components/wifi/wifi_component.h"
#ifdef USE_ESP32_IMPROV_STATE_CALLBACK
@@ -32,7 +33,7 @@ namespace esp32_improv {
using namespace esp32_ble_server;
class ESP32ImprovComponent : public Component {
class ESP32ImprovComponent : public Component, public improv_base::ImprovBase {
public:
ESP32ImprovComponent();
void dump_config() override;

View File

@@ -103,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.GenerateID(): cv.declare_id(ESPHomeOTAComponent),
@@ -130,7 +139,8 @@ CONFIG_SCHEMA = (
}
)
.extend(BASE_OTA_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
_consume_ota_sockets,
)
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate

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

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

View File

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

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

@@ -65,7 +65,7 @@ HaierClimateBase::HaierClimateBase()
{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,
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() {}

View File

@@ -16,7 +16,8 @@ void HDC1080Component::setup() {
// if configuration fails - there is a problem
if (this->write_register(HDC1080_CMD_CONFIGURATION, config, 2) != i2c::ERROR_OK) {
this->mark_failed();
ESP_LOGW(TAG, "Failed to configure HDC1080");
this->status_set_warning();
return;
}
}

View File

@@ -6,31 +6,42 @@
namespace esphome {
namespace improv_base {
static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}";
static constexpr size_t DEVICE_NAME_PLACEHOLDER_LEN = sizeof(DEVICE_NAME_PLACEHOLDER) - 1;
static constexpr const char IP_ADDRESS_PLACEHOLDER[] = "{{ip_address}}";
static constexpr size_t IP_ADDRESS_PLACEHOLDER_LEN = sizeof(IP_ADDRESS_PLACEHOLDER) - 1;
static void replace_all_in_place(std::string &str, const char *placeholder, size_t placeholder_len,
const std::string &replacement) {
size_t pos = 0;
const size_t replacement_len = replacement.length();
while ((pos = str.find(placeholder, pos)) != std::string::npos) {
str.replace(pos, placeholder_len, replacement);
pos += replacement_len;
}
}
std::string ImprovBase::get_formatted_next_url_() {
if (this->next_url_.empty()) {
return "";
}
std::string copy = this->next_url_;
// Device name
std::size_t pos = this->next_url_.find("{{device_name}}");
if (pos != std::string::npos) {
const std::string &device_name = App.get_name();
copy.replace(pos, 15, device_name);
}
// Ip address
pos = this->next_url_.find("{{ip_address}}");
if (pos != std::string::npos) {
for (auto &ip : network::get_ip_addresses()) {
if (ip.is_ip4()) {
std::string ipa = ip.str();
copy.replace(pos, 14, ipa);
break;
}
std::string formatted_url = this->next_url_;
// Replace all occurrences of {{device_name}}
replace_all_in_place(formatted_url, DEVICE_NAME_PLACEHOLDER, DEVICE_NAME_PLACEHOLDER_LEN, App.get_name());
// Replace all occurrences of {{ip_address}}
for (auto &ip : network::get_ip_addresses()) {
if (ip.is_ip4()) {
replace_all_in_place(formatted_url, IP_ADDRESS_PLACEHOLDER, IP_ADDRESS_PLACEHOLDER_LEN, ip.str());
break;
}
}
return copy;
// Note: {{esphome_version}} is replaced at code generation time in Python
return formatted_url;
}
} // namespace improv_base

View File

@@ -13,6 +13,7 @@ from esphome.const import (
)
from esphome.core import CORE, Lambda, coroutine_with_priority
from esphome.coroutine import CoroPriority
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["network"]
@@ -46,6 +47,19 @@ SERVICE_SCHEMA = cv.Schema(
}
)
def _consume_mdns_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for mDNS component."""
if config.get(CONF_DISABLED):
return config
from esphome.components import socket
# mDNS needs 2 sockets (IPv4 + IPv6 multicast)
socket.consume_sockets(2, "mdns")(config)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -55,6 +69,7 @@ CONFIG_SCHEMA = cv.All(
}
),
_remove_id_if_disabled,
_consume_mdns_sockets,
)

View File

@@ -77,7 +77,7 @@ void AirConditioner::control(const ClimateCall &call) {
ClimateTraits AirConditioner::traits() {
auto traits = ClimateTraits();
traits.set_supports_current_temperature(true);
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
traits.set_visual_min_temperature(17);
traits.set_visual_max_temperature(30);
traits.set_visual_temperature_step(0.5);

View File

@@ -30,6 +30,19 @@ wave_4_3 = DriverChip(
"blue": [14, 38, 18, 17, 10],
},
)
wave_4_3.extend(
"WAVESHARE-5-1024X600",
width=1024,
height=600,
hsync_back_porch=145,
hsync_front_porch=170,
hsync_pulse_width=30,
vsync_back_porch=23,
vsync_front_porch=12,
vsync_pulse_width=2,
)
wave_4_3.extend(
"ESP32-S3-TOUCH-LCD-7-800X480",
enable_pin=[{"ch422g": None, "number": 2}, {"ch422g": None, "number": 6}],

View File

@@ -52,8 +52,9 @@ const uint8_t MITSUBISHI_BYTE16 = 0x00;
climate::ClimateTraits MitsubishiClimate::traits() {
auto traits = climate::ClimateTraits();
traits.set_supports_current_temperature(this->sensor_ != nullptr);
traits.set_supports_action(false);
if (this->sensor_ != nullptr) {
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
}
traits.set_visual_min_temperature(MITSUBISHI_TEMP_MIN);
traits.set_visual_max_temperature(MITSUBISHI_TEMP_MAX);
traits.set_visual_temperature_step(1.0f);

View File

@@ -58,6 +58,7 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
DEPENDENCIES = ["network"]
@@ -210,6 +211,15 @@ def validate_fingerprint(value):
return value
def _consume_mqtt_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for MQTT component."""
from esphome.components import socket
# MQTT needs 1 socket for the broker connection
socket.consume_sockets(1, "mqtt")(config)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -306,6 +316,7 @@ CONFIG_SCHEMA = cv.All(
),
validate_config,
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]),
_consume_mqtt_sockets,
)

View File

@@ -5,7 +5,7 @@
#ifdef USE_MQTT
#ifdef USE_FAN
#include "esphome/components/fan/fan_state.h"
#include "esphome/components/fan/fan.h"
#include "mqtt_component.h"
namespace esphome {

View File

@@ -69,6 +69,12 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery
if (traits.supports_color_capability(ColorCapability::BRIGHTNESS))
root["brightness"] = true;
if (traits.supports_color_mode(ColorMode::COLOR_TEMPERATURE) ||
traits.supports_color_mode(ColorMode::COLD_WARM_WHITE)) {
root[MQTT_MIN_MIREDS] = traits.get_min_mireds();
root[MQTT_MAX_MIREDS] = traits.get_max_mireds();
}
if (this->state_->supports_effects()) {
root["effect"] = true;
JsonArray effect_list = root[MQTT_EFFECT_LIST].to<JsonArray>();

View File

@@ -54,11 +54,10 @@ void PIDClimate::control(const climate::ClimateCall &call) {
}
climate::ClimateTraits PIDClimate::traits() {
auto traits = climate::ClimateTraits();
traits.set_supports_current_temperature(true);
traits.set_supports_two_point_target_temperature(false);
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION);
if (this->humidity_sensor_ != nullptr)
traits.set_supports_current_humidity(true);
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY);
traits.set_supported_modes({climate::CLIMATE_MODE_OFF});
if (supports_cool_())
@@ -68,7 +67,6 @@ climate::ClimateTraits PIDClimate::traits() {
if (supports_heat_() && supports_cool_())
traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL);
traits.set_supports_action(true);
return traits;
}
void PIDClimate::dump_config() {

View File

@@ -62,7 +62,7 @@ CONF_WARNING_MPPT_OVERLOAD = "warning_mppt_overload"
CONF_WARNING_BATTERY_TOO_LOW_TO_CHARGE = "warning_battery_too_low_to_charge"
CONF_FAULT_DC_DC_OVER_CURRENT = "fault_dc_dc_over_current"
CONF_FAULT_CODE = "fault_code"
CONF_WARNUNG_LOW_PV_ENERGY = "warnung_low_pv_energy"
CONF_WARNING_LOW_PV_ENERGY = "warning_low_pv_energy"
CONF_WARNING_HIGH_AC_INPUT_DURING_BUS_SOFT_START = (
"warning_high_ac_input_during_bus_soft_start"
)
@@ -122,7 +122,7 @@ TYPES = [
CONF_WARNING_BATTERY_TOO_LOW_TO_CHARGE,
CONF_FAULT_DC_DC_OVER_CURRENT,
CONF_FAULT_CODE,
CONF_WARNUNG_LOW_PV_ENERGY,
CONF_WARNING_LOW_PV_ENERGY,
CONF_WARNING_HIGH_AC_INPUT_DURING_BUS_SOFT_START,
CONF_WARNING_BATTERY_EQUALIZATION,
]

View File

@@ -13,7 +13,7 @@ void PipsolarOutput::write_state(float state) {
if (std::find(this->possible_values_.begin(), this->possible_values_.end(), state) != this->possible_values_.end()) {
ESP_LOGD(TAG, "Will write: %s out of value %f / %02.0f", tmp, state, state);
this->parent_->switch_command(std::string(tmp));
this->parent_->queue_command(std::string(tmp));
} else {
ESP_LOGD(TAG, "Will not write: %s as it is not in list of allowed values", tmp);
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@
#include "esphome/components/uart/uart.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome {
namespace pipsolar {
@@ -28,10 +29,17 @@ struct PollingCommand {
bool needs_update;
};
#define PIPSOLAR_VALUED_ENTITY_(type, name, polling_command, value_type) \
protected: \
value_type value_##name##_; \
PIPSOLAR_ENTITY_(type, name, polling_command)
struct QFLAGValues {
esphome::optional<bool> silence_buzzer_open_buzzer;
esphome::optional<bool> overload_bypass_function;
esphome::optional<bool> lcd_escape_to_default;
esphome::optional<bool> overload_restart_function;
esphome::optional<bool> over_temperature_restart_function;
esphome::optional<bool> backlight_on;
esphome::optional<bool> alarm_on_when_primary_source_interrupt;
esphome::optional<bool> fault_code_record;
esphome::optional<bool> power_saving;
};
#define PIPSOLAR_ENTITY_(type, name, polling_command) \
protected: \
@@ -43,126 +51,123 @@ struct PollingCommand {
this->add_polling_command_(#polling_command, POLLING_##polling_command); \
}
#define PIPSOLAR_SENSOR(name, polling_command, value_type) \
PIPSOLAR_VALUED_ENTITY_(sensor::Sensor, name, polling_command, value_type)
#define PIPSOLAR_SENSOR(name, polling_command) PIPSOLAR_ENTITY_(sensor::Sensor, name, polling_command)
#define PIPSOLAR_SWITCH(name, polling_command) PIPSOLAR_ENTITY_(switch_::Switch, name, polling_command)
#define PIPSOLAR_BINARY_SENSOR(name, polling_command, value_type) \
PIPSOLAR_VALUED_ENTITY_(binary_sensor::BinarySensor, name, polling_command, value_type)
#define PIPSOLAR_VALUED_TEXT_SENSOR(name, polling_command, value_type) \
PIPSOLAR_VALUED_ENTITY_(text_sensor::TextSensor, name, polling_command, value_type)
#define PIPSOLAR_BINARY_SENSOR(name, polling_command) \
PIPSOLAR_ENTITY_(binary_sensor::BinarySensor, name, polling_command)
#define PIPSOLAR_TEXT_SENSOR(name, polling_command) PIPSOLAR_ENTITY_(text_sensor::TextSensor, name, polling_command)
class Pipsolar : public uart::UARTDevice, public PollingComponent {
// QPIGS values
PIPSOLAR_SENSOR(grid_voltage, QPIGS, float)
PIPSOLAR_SENSOR(grid_frequency, QPIGS, float)
PIPSOLAR_SENSOR(ac_output_voltage, QPIGS, float)
PIPSOLAR_SENSOR(ac_output_frequency, QPIGS, float)
PIPSOLAR_SENSOR(ac_output_apparent_power, QPIGS, int)
PIPSOLAR_SENSOR(ac_output_active_power, QPIGS, int)
PIPSOLAR_SENSOR(output_load_percent, QPIGS, int)
PIPSOLAR_SENSOR(bus_voltage, QPIGS, int)
PIPSOLAR_SENSOR(battery_voltage, QPIGS, float)
PIPSOLAR_SENSOR(battery_charging_current, QPIGS, int)
PIPSOLAR_SENSOR(battery_capacity_percent, QPIGS, int)
PIPSOLAR_SENSOR(inverter_heat_sink_temperature, QPIGS, int)
PIPSOLAR_SENSOR(pv_input_current_for_battery, QPIGS, float)
PIPSOLAR_SENSOR(pv_input_voltage, QPIGS, float)
PIPSOLAR_SENSOR(battery_voltage_scc, QPIGS, float)
PIPSOLAR_SENSOR(battery_discharge_current, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(add_sbu_priority_version, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(configuration_status, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(scc_firmware_version, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(load_status, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(battery_voltage_to_steady_while_charging, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(charging_status, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(scc_charging_status, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(ac_charging_status, QPIGS, int)
PIPSOLAR_SENSOR(battery_voltage_offset_for_fans_on, QPIGS, int) //.1 scale
PIPSOLAR_SENSOR(eeprom_version, QPIGS, int)
PIPSOLAR_SENSOR(pv_charging_power, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(charging_to_floating_mode, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(switch_on, QPIGS, int)
PIPSOLAR_BINARY_SENSOR(dustproof_installed, QPIGS, int)
PIPSOLAR_SENSOR(grid_voltage, QPIGS)
PIPSOLAR_SENSOR(grid_frequency, QPIGS)
PIPSOLAR_SENSOR(ac_output_voltage, QPIGS)
PIPSOLAR_SENSOR(ac_output_frequency, QPIGS)
PIPSOLAR_SENSOR(ac_output_apparent_power, QPIGS)
PIPSOLAR_SENSOR(ac_output_active_power, QPIGS)
PIPSOLAR_SENSOR(output_load_percent, QPIGS)
PIPSOLAR_SENSOR(bus_voltage, QPIGS)
PIPSOLAR_SENSOR(battery_voltage, QPIGS)
PIPSOLAR_SENSOR(battery_charging_current, QPIGS)
PIPSOLAR_SENSOR(battery_capacity_percent, QPIGS)
PIPSOLAR_SENSOR(inverter_heat_sink_temperature, QPIGS)
PIPSOLAR_SENSOR(pv_input_current_for_battery, QPIGS)
PIPSOLAR_SENSOR(pv_input_voltage, QPIGS)
PIPSOLAR_SENSOR(battery_voltage_scc, QPIGS)
PIPSOLAR_SENSOR(battery_discharge_current, QPIGS)
PIPSOLAR_BINARY_SENSOR(add_sbu_priority_version, QPIGS)
PIPSOLAR_BINARY_SENSOR(configuration_status, QPIGS)
PIPSOLAR_BINARY_SENSOR(scc_firmware_version, QPIGS)
PIPSOLAR_BINARY_SENSOR(load_status, QPIGS)
PIPSOLAR_BINARY_SENSOR(battery_voltage_to_steady_while_charging, QPIGS)
PIPSOLAR_BINARY_SENSOR(charging_status, QPIGS)
PIPSOLAR_BINARY_SENSOR(scc_charging_status, QPIGS)
PIPSOLAR_BINARY_SENSOR(ac_charging_status, QPIGS)
PIPSOLAR_SENSOR(battery_voltage_offset_for_fans_on, QPIGS) //.1 scale
PIPSOLAR_SENSOR(eeprom_version, QPIGS)
PIPSOLAR_SENSOR(pv_charging_power, QPIGS)
PIPSOLAR_BINARY_SENSOR(charging_to_floating_mode, QPIGS)
PIPSOLAR_BINARY_SENSOR(switch_on, QPIGS)
PIPSOLAR_BINARY_SENSOR(dustproof_installed, QPIGS)
// QPIRI values
PIPSOLAR_SENSOR(grid_rating_voltage, QPIRI, float)
PIPSOLAR_SENSOR(grid_rating_current, QPIRI, float)
PIPSOLAR_SENSOR(ac_output_rating_voltage, QPIRI, float)
PIPSOLAR_SENSOR(ac_output_rating_frequency, QPIRI, float)
PIPSOLAR_SENSOR(ac_output_rating_current, QPIRI, float)
PIPSOLAR_SENSOR(ac_output_rating_apparent_power, QPIRI, int)
PIPSOLAR_SENSOR(ac_output_rating_active_power, QPIRI, int)
PIPSOLAR_SENSOR(battery_rating_voltage, QPIRI, float)
PIPSOLAR_SENSOR(battery_recharge_voltage, QPIRI, float)
PIPSOLAR_SENSOR(battery_under_voltage, QPIRI, float)
PIPSOLAR_SENSOR(battery_bulk_voltage, QPIRI, float)
PIPSOLAR_SENSOR(battery_float_voltage, QPIRI, float)
PIPSOLAR_SENSOR(battery_type, QPIRI, int)
PIPSOLAR_SENSOR(current_max_ac_charging_current, QPIRI, int)
PIPSOLAR_SENSOR(current_max_charging_current, QPIRI, int)
PIPSOLAR_SENSOR(input_voltage_range, QPIRI, int)
PIPSOLAR_SENSOR(output_source_priority, QPIRI, int)
PIPSOLAR_SENSOR(charger_source_priority, QPIRI, int)
PIPSOLAR_SENSOR(parallel_max_num, QPIRI, int)
PIPSOLAR_SENSOR(machine_type, QPIRI, int)
PIPSOLAR_SENSOR(topology, QPIRI, int)
PIPSOLAR_SENSOR(output_mode, QPIRI, int)
PIPSOLAR_SENSOR(battery_redischarge_voltage, QPIRI, float)
PIPSOLAR_SENSOR(pv_ok_condition_for_parallel, QPIRI, int)
PIPSOLAR_SENSOR(pv_power_balance, QPIRI, int)
PIPSOLAR_SENSOR(grid_rating_voltage, QPIRI)
PIPSOLAR_SENSOR(grid_rating_current, QPIRI)
PIPSOLAR_SENSOR(ac_output_rating_voltage, QPIRI)
PIPSOLAR_SENSOR(ac_output_rating_frequency, QPIRI)
PIPSOLAR_SENSOR(ac_output_rating_current, QPIRI)
PIPSOLAR_SENSOR(ac_output_rating_apparent_power, QPIRI)
PIPSOLAR_SENSOR(ac_output_rating_active_power, QPIRI)
PIPSOLAR_SENSOR(battery_rating_voltage, QPIRI)
PIPSOLAR_SENSOR(battery_recharge_voltage, QPIRI)
PIPSOLAR_SENSOR(battery_under_voltage, QPIRI)
PIPSOLAR_SENSOR(battery_bulk_voltage, QPIRI)
PIPSOLAR_SENSOR(battery_float_voltage, QPIRI)
PIPSOLAR_SENSOR(battery_type, QPIRI)
PIPSOLAR_SENSOR(current_max_ac_charging_current, QPIRI)
PIPSOLAR_SENSOR(current_max_charging_current, QPIRI)
PIPSOLAR_SENSOR(input_voltage_range, QPIRI)
PIPSOLAR_SENSOR(output_source_priority, QPIRI)
PIPSOLAR_SENSOR(charger_source_priority, QPIRI)
PIPSOLAR_SENSOR(parallel_max_num, QPIRI)
PIPSOLAR_SENSOR(machine_type, QPIRI)
PIPSOLAR_SENSOR(topology, QPIRI)
PIPSOLAR_SENSOR(output_mode, QPIRI)
PIPSOLAR_SENSOR(battery_redischarge_voltage, QPIRI)
PIPSOLAR_SENSOR(pv_ok_condition_for_parallel, QPIRI)
PIPSOLAR_SENSOR(pv_power_balance, QPIRI)
// QMOD values
PIPSOLAR_VALUED_TEXT_SENSOR(device_mode, QMOD, char)
PIPSOLAR_TEXT_SENSOR(device_mode, QMOD)
// QFLAG values
PIPSOLAR_BINARY_SENSOR(silence_buzzer_open_buzzer, QFLAG, int)
PIPSOLAR_BINARY_SENSOR(overload_bypass_function, QFLAG, int)
PIPSOLAR_BINARY_SENSOR(lcd_escape_to_default, QFLAG, int)
PIPSOLAR_BINARY_SENSOR(overload_restart_function, QFLAG, int)
PIPSOLAR_BINARY_SENSOR(over_temperature_restart_function, QFLAG, int)
PIPSOLAR_BINARY_SENSOR(backlight_on, QFLAG, int)
PIPSOLAR_BINARY_SENSOR(alarm_on_when_primary_source_interrupt, QFLAG, int)
PIPSOLAR_BINARY_SENSOR(fault_code_record, QFLAG, int)
PIPSOLAR_BINARY_SENSOR(power_saving, QFLAG, int)
PIPSOLAR_BINARY_SENSOR(silence_buzzer_open_buzzer, QFLAG)
PIPSOLAR_BINARY_SENSOR(overload_bypass_function, QFLAG)
PIPSOLAR_BINARY_SENSOR(lcd_escape_to_default, QFLAG)
PIPSOLAR_BINARY_SENSOR(overload_restart_function, QFLAG)
PIPSOLAR_BINARY_SENSOR(over_temperature_restart_function, QFLAG)
PIPSOLAR_BINARY_SENSOR(backlight_on, QFLAG)
PIPSOLAR_BINARY_SENSOR(alarm_on_when_primary_source_interrupt, QFLAG)
PIPSOLAR_BINARY_SENSOR(fault_code_record, QFLAG)
PIPSOLAR_BINARY_SENSOR(power_saving, QFLAG)
// QPIWS values
PIPSOLAR_BINARY_SENSOR(warnings_present, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(faults_present, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_power_loss, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_inverter_fault, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_bus_over, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_bus_under, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_bus_soft_fail, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_line_fail, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_opvshort, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_inverter_voltage_too_low, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_inverter_voltage_too_high, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_over_temperature, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_fan_lock, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_battery_voltage_high, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_battery_low_alarm, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_battery_under_shutdown, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_battery_derating, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_over_load, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_eeprom_failed, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_inverter_over_current, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_inverter_soft_failed, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_self_test_failed, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_op_dc_voltage_over, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_battery_open, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_current_sensor_failed, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_battery_short, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_power_limit, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_pv_voltage_high, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_mppt_overload, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_mppt_overload, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_battery_too_low_to_charge, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_dc_dc_over_current, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(fault_code, QPIWS, int)
PIPSOLAR_BINARY_SENSOR(warnung_low_pv_energy, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_high_ac_input_during_bus_soft_start, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warning_battery_equalization, QPIWS, bool)
PIPSOLAR_BINARY_SENSOR(warnings_present, QPIWS)
PIPSOLAR_BINARY_SENSOR(faults_present, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_power_loss, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_inverter_fault, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_bus_over, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_bus_under, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_bus_soft_fail, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_line_fail, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_opvshort, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_inverter_voltage_too_low, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_inverter_voltage_too_high, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_over_temperature, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_fan_lock, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_battery_voltage_high, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_battery_low_alarm, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_battery_under_shutdown, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_battery_derating, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_over_load, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_eeprom_failed, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_inverter_over_current, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_inverter_soft_failed, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_self_test_failed, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_op_dc_voltage_over, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_battery_open, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_current_sensor_failed, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_battery_short, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_power_limit, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_pv_voltage_high, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_mppt_overload, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_mppt_overload, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_battery_too_low_to_charge, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_dc_dc_over_current, QPIWS)
PIPSOLAR_BINARY_SENSOR(fault_code, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_low_pv_energy, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_high_ac_input_during_bus_soft_start, QPIWS)
PIPSOLAR_BINARY_SENSOR(warning_battery_equalization, QPIWS)
PIPSOLAR_TEXT_SENSOR(last_qpigs, QPIGS)
PIPSOLAR_TEXT_SENSOR(last_qpiri, QPIRI)
@@ -180,14 +185,14 @@ class Pipsolar : public uart::UARTDevice, public PollingComponent {
PIPSOLAR_SWITCH(pv_ok_condition_for_parallel_switch, QPIRI)
PIPSOLAR_SWITCH(pv_power_balance_switch, QPIRI)
void switch_command(const std::string &command);
void queue_command(const std::string &command);
void setup() override;
void loop() override;
void dump_config() override;
void update() override;
protected:
static const size_t PIPSOLAR_READ_BUFFER_LENGTH = 110; // maximum supported answer length
static const size_t PIPSOLAR_READ_BUFFER_LENGTH = 128; // maximum supported answer length
static const size_t COMMAND_QUEUE_LENGTH = 10;
static const size_t COMMAND_TIMEOUT = 5000;
static const size_t POLLING_COMMANDS_MAX = 15;
@@ -198,7 +203,26 @@ class Pipsolar : public uart::UARTDevice, public PollingComponent {
uint16_t pipsolar_crc_(uint8_t *msg, uint8_t len);
bool send_next_command_();
bool send_next_poll_();
void queue_command_(const char *command, uint8_t length);
void handle_qpiri_(const char *message);
void handle_qpigs_(const char *message);
void handle_qmod_(const char *message);
void handle_qflag_(const char *message);
void handle_qpiws_(const char *message);
void handle_qt_(const char *message);
void handle_qmn_(const char *message);
void skip_start_(const char *message, size_t *pos);
void skip_field_(const char *message, size_t *pos);
std::string read_field_(const char *message, size_t *pos);
void read_float_sensor_(const char *message, size_t *pos, sensor::Sensor *sensor);
void read_int_sensor_(const char *message, size_t *pos, sensor::Sensor *sensor);
void publish_binary_sensor_(esphome::optional<bool> b, binary_sensor::BinarySensor *sensor);
esphome::optional<bool> get_bit_(std::string bits, uint8_t bit_pos);
std::string command_queue_[COMMAND_QUEUE_LENGTH];
uint8_t command_queue_position_ = 0;
uint8_t read_buffer_[PIPSOLAR_READ_BUFFER_LENGTH];
@@ -213,11 +237,10 @@ class Pipsolar : public uart::UARTDevice, public PollingComponent {
STATE_POLL_COMPLETE = 3,
STATE_COMMAND_COMPLETE = 4,
STATE_POLL_CHECKED = 5,
STATE_POLL_DECODED = 6,
};
uint8_t last_polling_command_ = 0;
PollingCommand used_polling_commands_[POLLING_COMMANDS_MAX];
PollingCommand enabled_polling_commands_[POLLING_COMMANDS_MAX];
};
} // namespace pipsolar

View File

@@ -11,11 +11,11 @@ void PipsolarSwitch::dump_config() { LOG_SWITCH("", "Pipsolar Switch", this); }
void PipsolarSwitch::write_state(bool state) {
if (state) {
if (!this->on_command_.empty()) {
this->parent_->switch_command(this->on_command_);
this->parent_->queue_command(this->on_command_);
}
} else {
if (!this->off_command_.empty()) {
this->parent_->switch_command(this->off_command_);
this->parent_->queue_command(this->off_command_);
}
}
}

View File

@@ -261,9 +261,12 @@ ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter, cg.Com
LambdaFilter = sensor_ns.class_("LambdaFilter", Filter)
OffsetFilter = sensor_ns.class_("OffsetFilter", Filter)
MultiplyFilter = sensor_ns.class_("MultiplyFilter", Filter)
FilterOutValueFilter = sensor_ns.class_("FilterOutValueFilter", Filter)
ValueListFilter = sensor_ns.class_("ValueListFilter", Filter)
FilterOutValueFilter = sensor_ns.class_("FilterOutValueFilter", ValueListFilter)
ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter)
ThrottleWithPriorityFilter = sensor_ns.class_("ThrottleWithPriorityFilter", Filter)
ThrottleWithPriorityFilter = sensor_ns.class_(
"ThrottleWithPriorityFilter", ValueListFilter
)
TimeoutFilter = sensor_ns.class_("TimeoutFilter", Filter, cg.Component)
DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component)
HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component)

View File

@@ -228,27 +228,40 @@ MultiplyFilter::MultiplyFilter(TemplatableValue<float> multiplier) : multiplier_
optional<float> MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); }
// FilterOutValueFilter
FilterOutValueFilter::FilterOutValueFilter(std::vector<TemplatableValue<float>> values_to_filter_out)
: values_to_filter_out_(std::move(values_to_filter_out)) {}
// ValueListFilter (base class)
ValueListFilter::ValueListFilter(std::initializer_list<TemplatableValue<float>> values) : values_(values) {}
optional<float> FilterOutValueFilter::new_value(float value) {
bool ValueListFilter::value_matches_any_(float sensor_value) {
int8_t accuracy = this->parent_->get_accuracy_decimals();
float accuracy_mult = powf(10.0f, accuracy);
for (auto filter_value : this->values_to_filter_out_) {
if (std::isnan(filter_value.value())) {
if (std::isnan(value)) {
return {};
}
float rounded_sensor = roundf(accuracy_mult * sensor_value);
for (auto &filter_value : this->values_) {
float fv = filter_value.value();
// Handle NaN comparison
if (std::isnan(fv)) {
if (std::isnan(sensor_value))
return true;
continue;
}
float rounded_filter_out = roundf(accuracy_mult * filter_value.value());
float rounded_value = roundf(accuracy_mult * value);
if (rounded_filter_out == rounded_value) {
return {};
}
// Compare rounded values
if (roundf(accuracy_mult * fv) == rounded_sensor)
return true;
}
return value;
return false;
}
// FilterOutValueFilter
FilterOutValueFilter::FilterOutValueFilter(std::initializer_list<TemplatableValue<float>> values_to_filter_out)
: ValueListFilter(values_to_filter_out) {}
optional<float> FilterOutValueFilter::new_value(float value) {
if (this->value_matches_any_(value))
return {}; // Filter out
return value; // Pass through
}
// ThrottleFilter
@@ -263,33 +276,15 @@ optional<float> ThrottleFilter::new_value(float value) {
}
// ThrottleWithPriorityFilter
ThrottleWithPriorityFilter::ThrottleWithPriorityFilter(uint32_t min_time_between_inputs,
std::vector<TemplatableValue<float>> prioritized_values)
: min_time_between_inputs_(min_time_between_inputs), prioritized_values_(std::move(prioritized_values)) {}
ThrottleWithPriorityFilter::ThrottleWithPriorityFilter(
uint32_t min_time_between_inputs, std::initializer_list<TemplatableValue<float>> prioritized_values)
: ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {}
optional<float> ThrottleWithPriorityFilter::new_value(float value) {
bool is_prioritized_value = false;
int8_t accuracy = this->parent_->get_accuracy_decimals();
float accuracy_mult = powf(10.0f, accuracy);
const uint32_t now = App.get_loop_component_start_time();
// First, determine if the new value is one of the prioritized values
for (auto prioritized_value : this->prioritized_values_) {
if (std::isnan(prioritized_value.value())) {
if (std::isnan(value)) {
is_prioritized_value = true;
break;
}
continue;
}
float rounded_prioritized_value = roundf(accuracy_mult * prioritized_value.value());
float rounded_value = roundf(accuracy_mult * value);
if (rounded_prioritized_value == rounded_value) {
is_prioritized_value = true;
break;
}
}
// Finally, determine if the new value should be throttled and pass it through if not
if (this->last_input_ == 0 || now - this->last_input_ >= min_time_between_inputs_ || is_prioritized_value) {
// Allow value through if: no previous input, time expired, or is prioritized
if (this->last_input_ == 0 || now - this->last_input_ >= min_time_between_inputs_ ||
this->value_matches_any_(value)) {
this->last_input_ = now;
return value;
}

View File

@@ -317,15 +317,28 @@ class MultiplyFilter : public Filter {
TemplatableValue<float> multiplier_;
};
/** Base class for filters that compare sensor values against a list of configured values.
*
* This base class provides common functionality for filters that need to check if a sensor
* value matches any value in a configured list, with proper handling of NaN values and
* accuracy-based rounding for comparisons.
*/
class ValueListFilter : public Filter {
protected:
explicit ValueListFilter(std::initializer_list<TemplatableValue<float>> values);
/// Check if sensor value matches any configured value (with accuracy rounding)
bool value_matches_any_(float sensor_value);
FixedVector<TemplatableValue<float>> values_;
};
/// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`.
class FilterOutValueFilter : public Filter {
class FilterOutValueFilter : public ValueListFilter {
public:
explicit FilterOutValueFilter(std::vector<TemplatableValue<float>> values_to_filter_out);
explicit FilterOutValueFilter(std::initializer_list<TemplatableValue<float>> values_to_filter_out);
optional<float> new_value(float value) override;
protected:
std::vector<TemplatableValue<float>> values_to_filter_out_;
};
class ThrottleFilter : public Filter {
@@ -340,17 +353,16 @@ class ThrottleFilter : public Filter {
};
/// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`.
class ThrottleWithPriorityFilter : public Filter {
class ThrottleWithPriorityFilter : public ValueListFilter {
public:
explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs,
std::vector<TemplatableValue<float>> prioritized_values);
std::initializer_list<TemplatableValue<float>> prioritized_values);
optional<float> new_value(float value) override;
protected:
uint32_t last_input_{0};
uint32_t min_time_between_inputs_;
std::vector<TemplatableValue<float>> prioritized_values_;
};
class TimeoutFilter : public Filter, public Component {

View File

@@ -107,12 +107,12 @@ void Sensor::add_filter(Filter *filter) {
}
filter->initialize(this, nullptr);
}
void Sensor::add_filters(const std::vector<Filter *> &filters) {
void Sensor::add_filters(std::initializer_list<Filter *> filters) {
for (Filter *filter : filters) {
this->add_filter(filter);
}
}
void Sensor::set_filters(const std::vector<Filter *> &filters) {
void Sensor::set_filters(std::initializer_list<Filter *> filters) {
this->clear_filters();
this->add_filters(filters);
}

View File

@@ -6,7 +6,7 @@
#include "esphome/core/log.h"
#include "esphome/components/sensor/filter.h"
#include <vector>
#include <initializer_list>
#include <memory>
namespace esphome {
@@ -77,10 +77,10 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa
* SlidingWindowMovingAverageFilter(15, 15), // average over last 15 values
* });
*/
void add_filters(const std::vector<Filter *> &filters);
void add_filters(std::initializer_list<Filter *> filters);
/// Clear the filters and replace them by filters.
void set_filters(const std::vector<Filter *> &filters);
void set_filters(std::initializer_list<Filter *> filters);
/// Clear the entire filter chain.
void clear_filters();

View File

@@ -1,3 +1,5 @@
from collections.abc import Callable, MutableMapping
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.core import CORE
@@ -9,6 +11,32 @@ IMPLEMENTATION_LWIP_TCP = "lwip_tcp"
IMPLEMENTATION_LWIP_SOCKETS = "lwip_sockets"
IMPLEMENTATION_BSD_SOCKETS = "bsd_sockets"
# Socket tracking infrastructure
# Components register their socket needs and platforms read this to configure appropriately
KEY_SOCKET_CONSUMERS = "socket_consumers"
def consume_sockets(
value: int, consumer: str
) -> Callable[[MutableMapping], MutableMapping]:
"""Register socket usage for a component.
Args:
value: Number of sockets needed by the component
consumer: Name of the component consuming the sockets
Returns:
A validator function that records the socket usage
"""
def _consume_sockets(config: MutableMapping) -> MutableMapping:
consumers: dict[str, int] = CORE.data.setdefault(KEY_SOCKET_CONSUMERS, {})
consumers[consumer] = consumers.get(consumer, 0) + value
return config
return _consume_sockets
CONFIG_SCHEMA = cv.Schema(
{
cv.SplitDefault(

View File

@@ -6,7 +6,7 @@ import esphome.config_validation as cv
from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS
from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, make_data_base
from .jinja import Jinja, JinjaStr, TemplateError, TemplateRuntimeError, has_jinja
from .jinja import Jinja, JinjaError, JinjaStr, has_jinja
CODEOWNERS = ["@esphome/core"]
_LOGGER = logging.getLogger(__name__)
@@ -57,17 +57,12 @@ def _expand_jinja(value, orig_value, path, jinja, ignore_missing):
"->".join(str(x) for x in path),
err.message,
)
except (
TemplateError,
TemplateRuntimeError,
RuntimeError,
ArithmeticError,
AttributeError,
TypeError,
) as err:
except JinjaError as err:
raise cv.Invalid(
f"{type(err).__name__} Error evaluating jinja expression '{value}': {str(err)}."
f" See {'->'.join(str(x) for x in path)}",
f"{err.error_name()} Error evaluating jinja expression '{value}': {str(err.parent())}."
f"\nEvaluation stack: (most recent evaluation last)\n{err.stack_trace_str()}"
f"\nRelevant context:\n{err.context_trace_str()}"
f"\nSee {'->'.join(str(x) for x in path)}",
path,
)
return value

View File

@@ -6,6 +6,8 @@ import re
import jinja2 as jinja
from jinja2.sandbox import SandboxedEnvironment
from esphome.yaml_util import ESPLiteralValue
TemplateError = jinja.TemplateError
TemplateSyntaxError = jinja.TemplateSyntaxError
TemplateRuntimeError = jinja.TemplateRuntimeError
@@ -26,18 +28,20 @@ def has_jinja(st):
return detect_jinja_re.search(st) is not None
# SAFE_GLOBAL_FUNCTIONS defines a allowlist of built-in functions that are considered safe to expose
# SAFE_GLOBALS defines a allowlist of built-in functions or modules that are considered safe to expose
# in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow
# arbitrary code execution, file access, or other security risks are included.
#
# The following functions are considered safe:
# - math: The entire math module is injected, allowing access to mathematical functions like sin, cos, sqrt, etc.
# - ord: Converts a character to its Unicode code point integer.
# - chr: Converts an integer to its corresponding Unicode character.
# - len: Returns the length of a sequence or collection.
#
# These functions were chosen because they are pure, have no side effects, and do not provide access
# to the file system, environment, or other potentially sensitive resources.
SAFE_GLOBAL_FUNCTIONS = {
SAFE_GLOBALS = {
"math": math, # Inject entire math module
"ord": ord,
"chr": chr,
"len": len,
@@ -56,22 +60,62 @@ class JinjaStr(str):
later in the main substitutions pass.
"""
Undefined = object()
def __new__(cls, value: str, upvalues=None):
obj = super().__new__(cls, value)
obj.upvalues = upvalues or {}
if isinstance(value, JinjaStr):
base = str(value)
merged = {**value.upvalues, **(upvalues or {})}
else:
base = value
merged = dict(upvalues or {})
obj = super().__new__(cls, base)
obj.upvalues = merged
obj.result = JinjaStr.Undefined
return obj
def __init__(self, value: str, upvalues=None):
self.upvalues = upvalues or {}
class JinjaError(Exception):
def __init__(self, context_trace: dict, expr: str):
self.context_trace = context_trace
self.eval_stack = [expr]
def parent(self):
return self.__context__
def error_name(self):
return type(self.parent()).__name__
def context_trace_str(self):
return "\n".join(
f" {k} = {repr(v)} ({type(v).__name__})"
for k, v in self.context_trace.items()
)
def stack_trace_str(self):
return "\n".join(
f" {len(self.eval_stack) - i}: {expr}{i == 0 and ' <-- ' + self.error_name() or ''}"
for i, expr in enumerate(self.eval_stack)
)
class Jinja:
class TrackerContext(jinja.runtime.Context):
def resolve_or_missing(self, key):
val = super().resolve_or_missing(key)
if isinstance(val, JinjaStr):
self.environment.context_trace[key] = val
val, _ = self.environment.expand(val)
self.environment.context_trace[key] = val
return val
class Jinja(SandboxedEnvironment):
"""
Wraps a Jinja environment
"""
def __init__(self, context_vars):
self.env = SandboxedEnvironment(
super().__init__(
trim_blocks=True,
lstrip_blocks=True,
block_start_string="<%",
@@ -82,13 +126,20 @@ class Jinja:
variable_end_string="}",
undefined=jinja.StrictUndefined,
)
self.env.add_extension("jinja2.ext.do")
self.env.globals["math"] = math # Inject entire math module
self.context_class = TrackerContext
self.add_extension("jinja2.ext.do")
self.context_trace = {}
self.context_vars = {**context_vars}
self.env.globals = {
**self.env.globals,
for k, v in self.context_vars.items():
if isinstance(v, ESPLiteralValue):
continue
if isinstance(v, str) and not isinstance(v, JinjaStr) and has_jinja(v):
self.context_vars[k] = JinjaStr(v, self.context_vars)
self.globals = {
**self.globals,
**self.context_vars,
**SAFE_GLOBAL_FUNCTIONS,
**SAFE_GLOBALS,
}
def safe_eval(self, expr):
@@ -110,23 +161,43 @@ class Jinja:
result = None
override_vars = {}
if isinstance(content_str, JinjaStr):
if content_str.result is not JinjaStr.Undefined:
return content_str.result, None
# If `value` is already a JinjaStr, it means we are trying to evaluate it again
# in a parent pass.
# Hopefully, all required variables are visible now.
override_vars = content_str.upvalues
old_trace = self.context_trace
self.context_trace = {}
try:
template = self.env.from_string(content_str)
template = self.from_string(content_str)
result = self.safe_eval(template.render(override_vars))
if isinstance(result, Undefined):
# This happens when the expression is simply an undefined variable. Jinja does not
# raise an exception, instead we get "Undefined".
# Trigger an UndefinedError exception so we skip to below.
print("" + result)
print("" + result) # force a UndefinedError exception
except (TemplateSyntaxError, UndefinedError) as err:
# `content_str` contains a Jinja expression that refers to a variable that is undefined
# in this scope. Perhaps it refers to a root substitution that is not visible yet.
# Therefore, return the original `content_str` as a JinjaStr, which contains the variables
# Therefore, return `content_str` as a JinjaStr, which contains the variables
# that are actually visible to it at this point to postpone evaluation.
return JinjaStr(content_str, {**self.context_vars, **override_vars}), err
except JinjaError as err:
err.context_trace = {**self.context_trace, **err.context_trace}
err.eval_stack.append(content_str)
raise err
except (
TemplateError,
TemplateRuntimeError,
RuntimeError,
ArithmeticError,
AttributeError,
TypeError,
) as err:
raise JinjaError(self.context_trace, content_str) from err
finally:
self.context_trace = old_trace
if isinstance(content_str, JinjaStr):
content_str.result = result
return result, None

View File

@@ -51,12 +51,12 @@ void TextSensor::add_filter(Filter *filter) {
}
filter->initialize(this, nullptr);
}
void TextSensor::add_filters(const std::vector<Filter *> &filters) {
void TextSensor::add_filters(std::initializer_list<Filter *> filters) {
for (Filter *filter : filters) {
this->add_filter(filter);
}
}
void TextSensor::set_filters(const std::vector<Filter *> &filters) {
void TextSensor::set_filters(std::initializer_list<Filter *> filters) {
this->clear_filters();
this->add_filters(filters);
}

View File

@@ -5,7 +5,7 @@
#include "esphome/core/helpers.h"
#include "esphome/components/text_sensor/filter.h"
#include <vector>
#include <initializer_list>
#include <memory>
namespace esphome {
@@ -37,10 +37,10 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass {
void add_filter(Filter *filter);
/// Add a list of vectors to the back of the filter chain.
void add_filters(const std::vector<Filter *> &filters);
void add_filters(std::initializer_list<Filter *> filters);
/// Clear the filters and replace them by filters.
void set_filters(const std::vector<Filter *> &filters);
void set_filters(std::initializer_list<Filter *> filters);
/// Clear the entire filter chain.
void clear_filters();

View File

@@ -283,8 +283,11 @@ void TuyaClimate::control_fan_mode_(const climate::ClimateCall &call) {
climate::ClimateTraits TuyaClimate::traits() {
auto traits = climate::ClimateTraits();
traits.set_supports_action(true);
traits.set_supports_current_temperature(this->current_temperature_id_.has_value());
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
if (this->current_temperature_id_.has_value()) {
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
}
if (supports_heat_)
traits.add_supported_mode(climate::CLIMATE_MODE_HEAT);
if (supports_cool_)

View File

@@ -17,6 +17,12 @@ UponorSmatrixDevice = uponor_smatrix_ns.class_(
"UponorSmatrixDevice", cg.Parented.template(UponorSmatrixComponent)
)
device_address = cv.All(
cv.hex_int,
cv.Range(min=0x1000000, max=0xFFFFFFFF, msg="Expected a 32 bit device address"),
)
CONF_UPONOR_SMATRIX_ID = "uponor_smatrix_id"
CONF_TIME_DEVICE_ADDRESS = "time_device_address"
@@ -24,9 +30,12 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(UponorSmatrixComponent),
cv.Optional(CONF_ADDRESS): cv.hex_uint16_t,
cv.Optional(CONF_ADDRESS): cv.invalid(
f"The '{CONF_ADDRESS}' option has been removed. "
"Use full 32 bit addresses in the device definitions instead."
),
cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
cv.Optional(CONF_TIME_DEVICE_ADDRESS): cv.hex_uint16_t,
cv.Optional(CONF_TIME_DEVICE_ADDRESS): device_address,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -47,7 +56,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
UPONOR_SMATRIX_DEVICE_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_UPONOR_SMATRIX_ID): cv.use_id(UponorSmatrixComponent),
cv.Required(CONF_ADDRESS): cv.hex_uint16_t,
cv.Required(CONF_ADDRESS): device_address,
}
)
@@ -58,17 +67,15 @@ async def to_code(config):
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
if address := config.get(CONF_ADDRESS):
cg.add(var.set_system_address(address))
if time_id := config.get(CONF_TIME_ID):
time_ = await cg.get_variable(time_id)
cg.add(var.set_time_id(time_))
if time_device_address := config.get(CONF_TIME_DEVICE_ADDRESS):
cg.add(var.set_time_device_address(time_device_address))
if time_device_address := config.get(CONF_TIME_DEVICE_ADDRESS):
cg.add(var.set_time_device_address(time_device_address))
async def register_uponor_smatrix_device(var, config):
parent = await cg.get_variable(config[CONF_UPONOR_SMATRIX_ID])
cg.add(var.set_parent(parent))
cg.add(var.set_device_address(config[CONF_ADDRESS]))
cg.add(var.set_address(config[CONF_ADDRESS]))
cg.add(parent.register_device(var))

View File

@@ -10,7 +10,7 @@ static const char *const TAG = "uponor_smatrix.climate";
void UponorSmatrixClimate::dump_config() {
LOG_CLIMATE("", "Uponor Smatrix Climate", this);
ESP_LOGCONFIG(TAG, " Device address: 0x%04X", this->address_);
ESP_LOGCONFIG(TAG, " Device address: 0x%08X", this->address_);
}
void UponorSmatrixClimate::loop() {
@@ -30,10 +30,9 @@ void UponorSmatrixClimate::loop() {
climate::ClimateTraits UponorSmatrixClimate::traits() {
auto traits = climate::ClimateTraits();
traits.set_supports_current_temperature(true);
traits.set_supports_current_humidity(true);
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY |
climate::CLIMATE_SUPPORTS_ACTION);
traits.set_supported_modes({climate::CLIMATE_MODE_HEAT});
traits.set_supports_action(true);
traits.set_supported_presets({climate::CLIMATE_PRESET_ECO});
traits.set_visual_min_temperature(this->min_temperature_);
traits.set_visual_max_temperature(this->max_temperature_);

View File

@@ -9,7 +9,7 @@ static const char *const TAG = "uponor_smatrix.sensor";
void UponorSmatrixSensor::dump_config() {
ESP_LOGCONFIG(TAG,
"Uponor Smatrix Sensor\n"
" Device address: 0x%04X",
" Device address: 0x%08X",
this->address_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "External Temperature", this->external_temperature_sensor_);

View File

@@ -18,11 +18,10 @@ void UponorSmatrixComponent::setup() {
void UponorSmatrixComponent::dump_config() {
ESP_LOGCONFIG(TAG, "Uponor Smatrix");
ESP_LOGCONFIG(TAG, " System address: 0x%04X", this->address_);
#ifdef USE_TIME
if (this->time_id_ != nullptr) {
ESP_LOGCONFIG(TAG, " Time synchronization: YES");
ESP_LOGCONFIG(TAG, " Time master device address: 0x%04X", this->time_device_address_);
ESP_LOGCONFIG(TAG, " Time master device address: 0x%08X", this->time_device_address_);
}
#endif
@@ -31,7 +30,7 @@ void UponorSmatrixComponent::dump_config() {
if (!this->unknown_devices_.empty()) {
ESP_LOGCONFIG(TAG, " Detected unknown device addresses:");
for (auto device_address : this->unknown_devices_) {
ESP_LOGCONFIG(TAG, " 0x%04X", device_address);
ESP_LOGCONFIG(TAG, " 0x%08X", device_address);
}
}
}
@@ -89,8 +88,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
return false;
}
uint16_t system_address = encode_uint16(packet[0], packet[1]);
uint16_t device_address = encode_uint16(packet[2], packet[3]);
uint32_t device_address = encode_uint32(packet[0], packet[1], packet[2], packet[3]);
uint16_t crc = encode_uint16(packet[packet_len - 1], packet[packet_len - 2]);
uint16_t computed_crc = crc16(packet, packet_len - 2);
@@ -99,24 +97,14 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
return false;
}
ESP_LOGV(TAG, "Received packet: sys=%04X, dev=%04X, data=%s, crc=%04X", system_address, device_address,
ESP_LOGV(TAG, "Received packet: addr=%08X, data=%s, crc=%04X", device_address,
format_hex(&packet[4], packet_len - 6).c_str(), crc);
// Detect or check system address
if (this->address_ == 0) {
ESP_LOGI(TAG, "Using detected system address 0x%04X", system_address);
this->address_ = system_address;
} else if (this->address_ != system_address) {
// This should never happen except if the system address was set or detected incorrectly, so warn the user.
ESP_LOGW(TAG, "Received packet from unknown system address 0x%04X", system_address);
return true;
}
// Handle packet
size_t data_len = (packet_len - 6) / 3;
if (data_len == 0) {
if (packet[4] == UPONOR_ID_REQUEST)
ESP_LOGVV(TAG, "Ignoring request packet for device 0x%04X", device_address);
ESP_LOGVV(TAG, "Ignoring request packet for device 0x%08X", device_address);
return true;
}
@@ -141,7 +129,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
if (data[i].id == UPONOR_ID_DATETIME1)
found_time = true;
if (found_temperature && found_time) {
ESP_LOGI(TAG, "Using detected time device address 0x%04X", device_address);
ESP_LOGI(TAG, "Using detected time device address 0x%08X", device_address);
this->time_device_address_ = device_address;
break;
}
@@ -160,7 +148,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
// Log unknown device addresses
if (!found && !this->unknown_devices_.count(device_address)) {
ESP_LOGI(TAG, "Received packet for unknown device address 0x%04X ", device_address);
ESP_LOGI(TAG, "Received packet for unknown device address 0x%08X ", device_address);
this->unknown_devices_.insert(device_address);
}
@@ -168,16 +156,16 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
return true;
}
bool UponorSmatrixComponent::send(uint16_t device_address, const UponorSmatrixData *data, size_t data_len) {
if (this->address_ == 0 || device_address == 0 || data == nullptr || data_len == 0)
bool UponorSmatrixComponent::send(uint32_t device_address, const UponorSmatrixData *data, size_t data_len) {
if (device_address == 0 || data == nullptr || data_len == 0)
return false;
// Assemble packet for send queue. All fields are big-endian except for the little-endian checksum.
std::vector<uint8_t> packet;
packet.reserve(6 + 3 * data_len);
packet.push_back(this->address_ >> 8);
packet.push_back(this->address_ >> 0);
packet.push_back(device_address >> 24);
packet.push_back(device_address >> 16);
packet.push_back(device_address >> 8);
packet.push_back(device_address >> 0);

View File

@@ -71,23 +71,21 @@ class UponorSmatrixComponent : public uart::UARTDevice, public Component {
void dump_config() override;
void loop() override;
void set_system_address(uint16_t address) { this->address_ = address; }
void register_device(UponorSmatrixDevice *device) { this->devices_.push_back(device); }
bool send(uint16_t device_address, const UponorSmatrixData *data, size_t data_len);
bool send(uint32_t device_address, const UponorSmatrixData *data, size_t data_len);
#ifdef USE_TIME
void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; }
void set_time_device_address(uint16_t address) { this->time_device_address_ = address; }
void set_time_device_address(uint32_t address) { this->time_device_address_ = address; }
void send_time() { this->send_time_requested_ = true; }
#endif
protected:
bool parse_byte_(uint8_t byte);
uint16_t address_;
std::vector<UponorSmatrixDevice *> devices_;
std::set<uint16_t> unknown_devices_;
std::set<uint32_t> unknown_devices_;
std::vector<uint8_t> rx_buffer_;
std::queue<std::vector<uint8_t>> tx_queue_;
@@ -96,7 +94,7 @@ class UponorSmatrixComponent : public uart::UARTDevice, public Component {
#ifdef USE_TIME
time::RealTimeClock *time_id_{nullptr};
uint16_t time_device_address_;
uint32_t time_device_address_;
bool send_time_requested_;
bool do_send_time_();
#endif
@@ -104,7 +102,7 @@ class UponorSmatrixComponent : public uart::UARTDevice, public Component {
class UponorSmatrixDevice : public Parented<UponorSmatrixComponent> {
public:
void set_device_address(uint16_t address) { this->address_ = address; }
void set_address(uint32_t address) { this->address_ = address; }
virtual void on_device_data(const UponorSmatrixData *data, size_t data_len) = 0;
bool send(const UponorSmatrixData *data, size_t data_len) {
@@ -113,7 +111,7 @@ class UponorSmatrixDevice : public Parented<UponorSmatrixComponent> {
protected:
friend UponorSmatrixComponent;
uint16_t address_;
uint32_t address_;
};
inline float raw_to_celsius(uint16_t raw) {

View File

@@ -136,6 +136,18 @@ def _final_validate_sorting(config: ConfigType) -> ConfigType:
FINAL_VALIDATE_SCHEMA = _final_validate_sorting
def _consume_web_server_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for web_server component."""
from esphome.components import socket
# Web server needs 1 listening socket + typically 2 concurrent client connections
# (browser makes 2 connections for page + event stream)
sockets_needed = 3
socket.consume_sockets(sockets_needed, "web_server")(config)
return config
sorting_group = {
cv.Required(CONF_ID): cv.declare_id(cg.int_),
cv.Required(CONF_NAME): cv.string,
@@ -205,6 +217,7 @@ CONFIG_SCHEMA = cv.All(
validate_local,
validate_sorting_groups,
validate_ota,
_consume_web_server_sockets,
)

View File

@@ -81,7 +81,9 @@ const uint32_t YASHIMA_CARRIER_FREQUENCY = 38000;
climate::ClimateTraits YashimaClimate::traits() {
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});
if (supports_cool_)
@@ -89,7 +91,6 @@ climate::ClimateTraits YashimaClimate::traits() {
if (supports_heat_)
traits.add_supported_mode(climate::CLIMATE_MODE_HEAT);
traits.set_supports_two_point_target_temperature(false);
traits.set_visual_min_temperature(YASHIMA_TEMP_MIN);
traits.set_visual_max_temperature(YASHIMA_TEMP_MAX);
traits.set_visual_temperature_step(1);

View File

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

View File

@@ -39,7 +39,7 @@
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#ifdef USE_FAN
#include "esphome/components/fan/fan_state.h"
#include "esphome/components/fan/fan.h"
#endif
#ifdef USE_CLIMATE
#include "esphome/components/climate/climate.h"

View File

@@ -5,7 +5,7 @@
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_FAN
#include "esphome/components/fan/fan_state.h"
#include "esphome/components/fan/fan.h"
#endif
#ifdef USE_LIGHT
#include "esphome/components/light/light_state.h"

View File

@@ -273,6 +273,8 @@
#ifdef USE_NRF52
#define USE_NRF52_DFU
#define USE_SOFTDEVICE_ID 7
#define USE_SOFTDEVICE_VERSION 1
#endif
// Disabled feature flags

View File

@@ -1,362 +0,0 @@
"""GitHub download cache for ESPHome.
This module provides caching functionality for GitHub release downloads
to avoid redundant network I/O when switching between platforms.
"""
from __future__ import annotations
import hashlib
import json
import logging
from pathlib import Path
import shutil
import time
import urllib.error
import urllib.request
_LOGGER = logging.getLogger(__name__)
class GitHubCache:
"""Manages caching of GitHub release downloads."""
# Cache expiration time in seconds (30 days)
CACHE_EXPIRATION_SECONDS = 30 * 24 * 60 * 60
def __init__(self, cache_dir: Path | None = None):
"""Initialize the cache manager.
Args:
cache_dir: Directory to store cached files.
Defaults to ~/.esphome_cache/github
"""
if cache_dir is None:
cache_dir = Path.home() / ".esphome_cache" / "github"
self.cache_dir = cache_dir
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.metadata_file = self.cache_dir / "cache_metadata.json"
# Prune old files on initialization
try:
self._prune_old_files()
except Exception as e:
_LOGGER.debug("Failed to prune old cache files: %s", e)
def _load_metadata(self) -> dict:
"""Load cache metadata from disk."""
if self.metadata_file.exists():
try:
with open(self.metadata_file) as f:
return json.load(f)
except (OSError, ValueError, json.JSONDecodeError):
return {}
return {}
def _save_metadata(self, metadata: dict) -> None:
"""Save cache metadata to disk."""
try:
with open(self.metadata_file, "w") as f:
json.dump(metadata, f, indent=2)
except OSError as e:
_LOGGER.debug("Failed to save cache metadata: %s", e)
@staticmethod
def is_github_url(url: str) -> bool:
"""Check if URL is a GitHub release download."""
return "github.com" in url.lower() and url.endswith(".zip")
def _get_cache_key(self, url: str) -> str:
"""Get cache key (hash) for a URL."""
return hashlib.sha256(url.encode()).hexdigest()
def _get_cache_path(self, url: str) -> Path:
"""Get cache file path for a URL."""
cache_key = self._get_cache_key(url)
ext = Path(url.split("?")[0]).suffix
return self.cache_dir / f"{cache_key}{ext}"
def _check_if_modified(
self,
url: str,
last_modified: str | None = None,
etag: str | None = None,
) -> bool:
"""Check if a URL has been modified using HTTP 304.
Args:
url: URL to check
last_modified: Last-Modified header from previous response
etag: ETag header from previous response
Returns:
True if modified, False if not modified (or offline/unreachable)
"""
if not last_modified and not etag:
# No cache headers available, assume modified
return True
try:
request = urllib.request.Request(url)
request.get_method = lambda: "HEAD"
if last_modified:
request.add_header("If-Modified-Since", last_modified)
if etag:
request.add_header("If-None-Match", etag)
try:
urllib.request.urlopen(request, timeout=10)
# 200 OK = file was modified
return True
except urllib.error.HTTPError as e:
if e.code == 304:
# Not modified
_LOGGER.debug("File not modified (HTTP 304): %s", url)
return False
# Other errors, assume modified to be safe
return True
except (OSError, urllib.error.URLError):
# If check fails (offline/network error), assume not modified (use cache)
_LOGGER.info("Cannot reach server (offline?), using cached file: %s", url)
return False
def get_cached_path(self, url: str, check_updates: bool = True) -> Path | None:
"""Get path to cached file if available and valid.
Args:
url: URL to check
check_updates: Whether to check for updates using HTTP 304
Returns:
Path to cached file if valid, None if needs download
"""
if not self.is_github_url(url):
return None
cache_path = self._get_cache_path(url)
if not cache_path.exists():
return None
# Load metadata
metadata = self._load_metadata()
cache_key = self._get_cache_key(url)
# Check if file should be re-downloaded
should_redownload = False
if check_updates and cache_key in metadata:
last_modified = metadata[cache_key].get("last_modified")
etag = metadata[cache_key].get("etag")
if self._check_if_modified(url, last_modified, etag):
# File was modified, need to re-download
_LOGGER.debug("Cached file is outdated: %s", url)
should_redownload = True
if should_redownload:
return None
# File is valid, update cached_at timestamp to keep it fresh
if cache_key in metadata:
metadata[cache_key]["cached_at"] = time.time()
self._save_metadata(metadata)
# Log appropriate message
if not check_updates:
_LOGGER.debug("Using cached file (no update check): %s", url)
elif cache_key not in metadata:
_LOGGER.debug("Using cached file (no metadata): %s", url)
else:
_LOGGER.debug("Using cached file: %s", url)
return cache_path
def save_to_cache(self, url: str, source_path: Path) -> None:
"""Save a downloaded file to cache.
Args:
url: URL the file was downloaded from
source_path: Path to the downloaded file
"""
if not self.is_github_url(url):
return
try:
cache_path = self._get_cache_path(url)
# Only copy if source and destination are different
if source_path.resolve() != cache_path.resolve():
shutil.copy2(source_path, cache_path)
# Try to get HTTP headers for caching
last_modified = None
etag = None
try:
request = urllib.request.Request(url)
request.get_method = lambda: "HEAD"
response = urllib.request.urlopen(request, timeout=10)
last_modified = response.headers.get("Last-Modified")
etag = response.headers.get("ETag")
except (OSError, urllib.error.URLError):
pass
# Update metadata
metadata = self._load_metadata()
cache_key = self._get_cache_key(url)
metadata[cache_key] = {
"url": url,
"size": cache_path.stat().st_size,
"cached_at": time.time(),
"last_modified": last_modified,
"etag": etag,
}
self._save_metadata(metadata)
_LOGGER.debug("Saved to cache: %s", url)
except OSError as e:
_LOGGER.debug("Failed to save to cache: %s", e)
def copy_from_cache(self, url: str, destination: Path) -> bool:
"""Copy a cached file to destination.
Args:
url: URL of the cached file
destination: Where to copy the file
Returns:
True if successful, False otherwise
"""
cached_path = self.get_cached_path(url, check_updates=True)
if not cached_path:
return False
try:
shutil.copy2(cached_path, destination)
_LOGGER.info("Using cached download for %s", url)
return True
except OSError as e:
_LOGGER.warning("Failed to use cache: %s", e)
return False
def cache_size(self) -> int:
"""Get total size of cached files in bytes."""
total = 0
try:
for file_path in self.cache_dir.glob("*"):
if file_path.is_file() and file_path != self.metadata_file:
total += file_path.stat().st_size
except OSError:
pass
return total
def list_cached(self) -> list[dict]:
"""List all cached files with metadata."""
cached_files = []
metadata = self._load_metadata()
for cache_key, meta in metadata.items():
cache_path = (
self.cache_dir / f"{cache_key}{Path(meta['url'].split('?')[0]).suffix}"
)
if cache_path.exists():
cached_files.append(
{
"url": meta["url"],
"path": cache_path,
"size": meta["size"],
"cached_at": meta.get("cached_at"),
"last_modified": meta.get("last_modified"),
"etag": meta.get("etag"),
}
)
return cached_files
def clear_cache(self) -> None:
"""Clear all cached files."""
try:
for file_path in self.cache_dir.glob("*"):
if file_path.is_file():
file_path.unlink()
_LOGGER.info("Cache cleared: %s", self.cache_dir)
except OSError as e:
_LOGGER.warning("Failed to clear cache: %s", e)
def _prune_old_files(self) -> None:
"""Remove cache files older than CACHE_EXPIRATION_SECONDS."""
current_time = time.time()
metadata = self._load_metadata()
removed_count = 0
removed_size = 0
# Check each file in metadata
for cache_key, meta in list(metadata.items()):
cached_at = meta.get("cached_at", 0)
age_seconds = current_time - cached_at
if age_seconds > self.CACHE_EXPIRATION_SECONDS:
# File is too old, remove it
cache_path = (
self.cache_dir
/ f"{cache_key}{Path(meta['url'].split('?')[0]).suffix}"
)
if cache_path.exists():
file_size = cache_path.stat().st_size
cache_path.unlink()
removed_size += file_size
removed_count += 1
_LOGGER.debug(
"Pruned old cache file (age: %.1f days): %s",
age_seconds / (24 * 60 * 60),
meta["url"],
)
# Remove from metadata
del metadata[cache_key]
# Also check for orphaned files (files without metadata)
for file_path in self.cache_dir.glob("*.zip"):
if file_path == self.metadata_file:
continue
# Check if file is in metadata
found_in_metadata = False
for cache_key in metadata:
if file_path.name.startswith(cache_key):
found_in_metadata = True
break
if not found_in_metadata:
# Orphaned file - check age by modification time
file_age = current_time - file_path.stat().st_mtime
if file_age > self.CACHE_EXPIRATION_SECONDS:
file_size = file_path.stat().st_size
file_path.unlink()
removed_size += file_size
removed_count += 1
_LOGGER.debug(
"Pruned orphaned cache file (age: %.1f days): %s",
file_age / (24 * 60 * 60),
file_path.name,
)
# Save updated metadata if anything was removed
if removed_count > 0:
self._save_metadata(metadata)
removed_mb = removed_size / (1024 * 1024)
_LOGGER.info(
"Pruned %d old cache file(s), freed %.2f MB",
removed_count,
removed_mb,
)
# Global cache instance
_cache: GitHubCache | None = None
def get_cache() -> GitHubCache:
"""Get the global GitHub cache instance."""
global _cache # noqa: PLW0603
if _cache is None:
_cache = GitHubCache()
return _cache

View File

@@ -5,6 +5,7 @@ import os
from pathlib import Path
import re
import subprocess
from typing import Any
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
from esphome.core import CORE, EsphomeError
@@ -43,168 +44,32 @@ def patch_structhash():
def patch_file_downloader():
"""Patch PlatformIO's FileDownloader to add caching and retry on PackageException errors.
"""Patch PlatformIO's FileDownloader to retry on PackageException errors."""
from platformio.package.download import FileDownloader
from platformio.package.exception import PackageException
This function attempts to patch PlatformIO's internal download mechanism.
If patching fails (due to API changes), it gracefully falls back to no caching.
"""
try:
from platformio.package.download import FileDownloader
from platformio.package.exception import PackageException
except ImportError as e:
_LOGGER.debug("Could not import PlatformIO modules for patching: %s", e)
return
original_init = FileDownloader.__init__
# Import our cache module
from esphome.github_cache import GitHubCache
def patched_init(self, *args: Any, **kwargs: Any) -> None:
max_retries = 3
_LOGGER.debug("Applying GitHub download cache patch...")
# Verify the classes have the expected methods before patching
if not hasattr(FileDownloader, "__init__") or not hasattr(FileDownloader, "start"):
_LOGGER.warning(
"PlatformIO FileDownloader API has changed, skipping cache patch"
)
return
try:
original_init = FileDownloader.__init__
original_start = FileDownloader.start
# Initialize cache in .platformio directory so it benefits from GitHub Actions cache
platformio_dir = Path.home() / ".platformio"
cache_dir = platformio_dir / "esphome_download_cache"
cache_dir_existed = cache_dir.exists()
cache = GitHubCache(cache_dir=cache_dir)
if not cache_dir_existed:
_LOGGER.info("Created GitHub download cache at: %s", cache.cache_dir)
except Exception as e:
_LOGGER.warning("Failed to initialize GitHub download cache: %s", e)
return
def patched_init(self, *args, **kwargs):
"""Patched init that checks cache before making HTTP connection."""
try:
# Extract URL from args (first positional argument)
url = args[0] if args else kwargs.get("url")
dest_dir = args[1] if len(args) > 1 else kwargs.get("dest_dir")
# Debug: Log all downloads
_LOGGER.debug("[GitHub Cache] Download request for: %s", url)
# Store URL for later use (original FileDownloader doesn't store it)
self._esphome_cache_url = url if cache.is_github_url(url) else None
# Check cache for GitHub URLs BEFORE making HTTP request
if self._esphome_cache_url:
_LOGGER.debug("[GitHub Cache] This is a GitHub URL, checking cache...")
self._esphome_use_cache = cache.get_cached_path(url, check_updates=True)
if self._esphome_use_cache:
_LOGGER.info(
"Found %s in cache, will restore instead of downloading",
Path(url.split("?")[0]).name,
)
_LOGGER.debug(
"[GitHub Cache] Found in cache: %s", self._esphome_use_cache
for attempt in range(max_retries):
try:
return original_init(self, *args, **kwargs)
except PackageException as e:
if attempt < max_retries - 1:
_LOGGER.warning(
"Package download failed: %s. Retrying... (attempt %d/%d)",
str(e),
attempt + 1,
max_retries,
)
else:
_LOGGER.debug(
"[GitHub Cache] Not in cache, will download and cache"
)
else:
self._esphome_use_cache = None
if url and str(url).startswith("http"):
_LOGGER.debug("[GitHub Cache] Not a GitHub URL, skipping cache")
# Final attempt - re-raise
raise
return None
# Only make HTTP connection if we don't have cached file
if self._esphome_use_cache:
# Skip HTTP connection, we'll handle this in start()
# Set minimal attributes to satisfy FileDownloader
# Create a mock session that can be safely closed in __del__
class MockSession:
def close(self):
pass
self._http_session = MockSession()
self._http_response = None
self._fname = Path(url.split("?")[0]).name
self._destination = self._fname
if dest_dir:
from os.path import join
self._destination = join(dest_dir, self._fname)
# Note: Actual restoration logged in patched_start
return None # Don't call original_init
# Normal initialization with retry logic
max_retries = 3
for attempt in range(max_retries):
try:
return original_init(self, *args, **kwargs)
except PackageException as e:
if attempt < max_retries - 1:
_LOGGER.warning(
"Package download failed: %s. Retrying... (attempt %d/%d)",
str(e),
attempt + 1,
max_retries,
)
else:
# Final attempt - re-raise
raise
return None
except Exception as e:
# If anything goes wrong in our cache logic, fall back to normal download
_LOGGER.debug("Cache check failed, falling back to normal download: %s", e)
self._esphome_cache_url = None
self._esphome_use_cache = None
return original_init(self, *args, **kwargs)
def patched_start(self, *args, **kwargs):
"""Patched start that uses cache when available."""
try:
import shutil
# Get the cache URL and path that were set in __init__
cache_url = getattr(self, "_esphome_cache_url", None)
cached_file = getattr(self, "_esphome_use_cache", None)
# If we're using cache, copy file instead of downloading
if cached_file:
try:
shutil.copy2(cached_file, self._destination)
_LOGGER.info(
"Restored %s from cache (avoided download)",
Path(cached_file).name,
)
return True
except OSError as e:
_LOGGER.warning("Failed to copy from cache: %s", e)
# Fall through to re-download
# Perform normal download
result = original_start(self, *args, **kwargs)
# Save to cache if it was a GitHub URL
if cache_url:
try:
cache.save_to_cache(cache_url, Path(self._destination))
except OSError as e:
_LOGGER.debug("Failed to save to cache: %s", e)
return result
except Exception as e:
# If anything goes wrong, fall back to normal download
_LOGGER.debug("Cache restoration failed, using normal download: %s", e)
return original_start(self, *args, **kwargs)
# Apply the patches
try:
FileDownloader.__init__ = patched_init
FileDownloader.start = patched_start
_LOGGER.debug("GitHub download cache patch applied successfully")
except Exception as e:
_LOGGER.warning("Failed to apply GitHub download cache patch: %s", e)
FileDownloader.__init__ = patched_init
IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})"
@@ -222,8 +87,6 @@ FILTER_PLATFORMIO_LINES = [
r"Memory Usage -> https://bit.ly/pio-memory-usage",
r"Found: https://platformio.org/lib/show/.*",
r"Using cache: .*",
# Don't filter our cache messages - let users see when cache is being used
# r"Using cached download for .*",
r"Installing dependencies",
r"Library Manager: Already installed, built-in library",
r"Building in .* mode",

View File

@@ -1,4 +1,4 @@
pylint==4.0.1
pylint==4.0.2
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.14.1 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.0 # also change in .pre-commit-config.yaml when updating

View File

@@ -1,164 +0,0 @@
#!/usr/bin/env python3
"""
Pre-cache PlatformIO GitHub Downloads
This script extracts GitHub URLs from platformio.ini and pre-caches them
to avoid redundant downloads when switching between ESP8266 and ESP32 builds.
Usage:
python3 script/cache_platformio_downloads.py [platformio.ini]
"""
import argparse
import configparser
from pathlib import Path
import re
import sys
# Import the cache manager
sys.path.insert(0, str(Path(__file__).parent.parent))
from esphome.github_cache import GitHubCache
def extract_github_urls(platformio_ini: Path) -> list[str]:
"""Extract all GitHub URLs from platformio.ini.
Args:
platformio_ini: Path to platformio.ini file
Returns:
List of GitHub URLs found
"""
config = configparser.ConfigParser(inline_comment_prefixes=(";",))
config.read(platformio_ini)
urls = []
github_pattern = re.compile(r"https://github\.com/[^\s;]+\.zip")
for section in config.sections():
conf = config[section]
# Check platform
if "platform" in conf:
platform_value = conf["platform"]
matches = github_pattern.findall(platform_value)
urls.extend(matches)
# Check platform_packages
if "platform_packages" in conf:
for line in conf["platform_packages"].splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
matches = github_pattern.findall(line)
urls.extend(matches)
# Remove duplicates while preserving order using dict
return list(dict.fromkeys(urls))
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Pre-cache PlatformIO GitHub downloads",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This script scans platformio.ini for GitHub URLs and pre-caches them.
This avoids redundant downloads when switching between platforms (e.g., ESP8266 and ESP32).
Examples:
# Cache downloads from default platformio.ini
%(prog)s
# Cache downloads from specific file
%(prog)s custom_platformio.ini
# Show what would be cached without downloading
%(prog)s --dry-run
""",
)
parser.add_argument(
"platformio_ini",
nargs="?",
default="platformio.ini",
help="Path to platformio.ini (default: platformio.ini)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be cached without downloading",
)
parser.add_argument(
"--cache-dir",
type=Path,
help="Cache directory (default: ~/.platformio/esphome_download_cache)",
)
parser.add_argument(
"--force",
action="store_true",
help="Force re-download even if cached",
)
args = parser.parse_args()
platformio_ini = Path(args.platformio_ini)
if not platformio_ini.exists():
print(f"Error: {platformio_ini} not found", file=sys.stderr)
return 1
# Extract URLs
print(f"Scanning {platformio_ini} for GitHub URLs...")
urls = extract_github_urls(platformio_ini)
if not urls:
print("No GitHub URLs found in platformio.ini")
return 0
print(f"Found {len(urls)} unique GitHub URL(s):")
for url in urls:
print(f" - {url}")
print()
if args.dry_run:
print("Dry run - not downloading")
return 0
# Initialize cache (use PlatformIO directory by default)
cache_dir = args.cache_dir
if cache_dir is None:
cache_dir = Path.home() / ".platformio" / "esphome_download_cache"
cache = GitHubCache(cache_dir)
# Cache each URL
success_count = 0
for i, url in enumerate(urls, 1):
print(f"[{i}/{len(urls)}] Checking {url}")
try:
# Use the download_with_progress from github_download_cache CLI
from script.github_download_cache import download_with_progress
download_with_progress(cache, url, force=args.force, check_updates=True)
success_count += 1
print()
except Exception as e:
print(f"Error caching {url}: {e}", file=sys.stderr)
print()
# Show cache stats
total_size = cache.cache_size()
size_mb = total_size / (1024 * 1024)
print("\nCache summary:")
print(f" Successfully cached: {success_count}/{len(urls)}")
print(f" Total cache size: {size_mb:.2f} MB")
print(f" Cache location: {cache.cache_dir}")
return 0 if success_count == len(urls) else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -57,10 +57,16 @@ from helpers import (
get_component_from_path,
get_component_test_files,
get_components_from_integration_fixtures,
git_ls_files,
parse_test_filename,
root_path,
)
# Threshold for splitting clang-tidy jobs
# For small PRs (< 65 files), use nosplit for faster CI
# For large PRs (>= 65 files), use split for better parallelization
CLANG_TIDY_SPLIT_THRESHOLD = 65
class Platform(StrEnum):
"""Platform identifiers for memory impact analysis."""
@@ -78,11 +84,16 @@ MEMORY_IMPACT_FALLBACK_COMPONENT = "api" # Representative component for core ch
MEMORY_IMPACT_FALLBACK_PLATFORM = Platform.ESP32_IDF # Most representative platform
# Platform preference order for memory impact analysis
# Prefer newer platforms first as they represent the future of ESPHome
# ESP8266 is most constrained but many new features don't support it
# This order is used when no platform-specific hints are detected from filenames
# Priority rationale:
# 1. ESP32-C6 IDF - Newest platform, supports Thread/Zigbee
# 2. ESP8266 Arduino - Most memory constrained (best for detecting memory impact),
# fastest build times, most sensitive to code size changes
# 3. ESP32 IDF - Primary ESP32 platform, most representative of modern ESPHome
# 4-6. Other ESP32 variants - Less commonly used but still supported
MEMORY_IMPACT_PLATFORM_PREFERENCE = [
Platform.ESP32_C6_IDF, # ESP32-C6 IDF (newest, supports Thread/Zigbee)
Platform.ESP8266_ARD, # ESP8266 Arduino (most memory constrained - best for impact analysis)
Platform.ESP8266_ARD, # ESP8266 Arduino (most memory constrained, fastest builds)
Platform.ESP32_IDF, # ESP32 IDF platform (primary ESP32 platform, most representative)
Platform.ESP32_C3_IDF, # ESP32-C3 IDF
Platform.ESP32_S2_IDF, # ESP32-S2 IDF
@@ -152,6 +163,26 @@ def should_run_integration_tests(branch: str | None = None) -> bool:
return False
@cache
def _is_clang_tidy_full_scan() -> bool:
"""Check if clang-tidy configuration changed (requires full scan).
Returns:
True if full scan is needed (hash changed), False otherwise.
"""
try:
result = subprocess.run(
[os.path.join(root_path, "script", "clang_tidy_hash.py"), "--check"],
capture_output=True,
check=False,
)
# Exit 0 means hash changed (full scan needed)
return result.returncode == 0
except Exception:
# If hash check fails, run full scan to be safe
return True
def should_run_clang_tidy(branch: str | None = None) -> bool:
"""Determine if clang-tidy should run based on changed files.
@@ -188,17 +219,7 @@ def should_run_clang_tidy(branch: str | None = None) -> bool:
True if clang-tidy should run, False otherwise.
"""
# First check if clang-tidy configuration changed (full scan needed)
try:
result = subprocess.run(
[os.path.join(root_path, "script", "clang_tidy_hash.py"), "--check"],
capture_output=True,
check=False,
)
# Exit 0 means hash changed (full scan needed)
if result.returncode == 0:
return True
except Exception:
# If hash check fails, run clang-tidy to be safe
if _is_clang_tidy_full_scan():
return True
# Check if .clang-tidy.hash file itself was changed
@@ -210,6 +231,22 @@ def should_run_clang_tidy(branch: str | None = None) -> bool:
return _any_changed_file_endswith(branch, CPP_FILE_EXTENSIONS)
def count_changed_cpp_files(branch: str | None = None) -> int:
"""Count the number of changed C++ files.
This is used to determine whether to split clang-tidy jobs or run them as a single job.
For PRs with < 65 changed C++ files, running a single job is faster than splitting.
Args:
branch: Branch to compare against. If None, uses default.
Returns:
Number of changed C++ files.
"""
files = changed_files(branch)
return sum(1 for file in files if file.endswith(CPP_FILE_EXTENSIONS))
def should_run_clang_format(branch: str | None = None) -> bool:
"""Determine if clang-format should run based on changed files.
@@ -264,6 +301,91 @@ def _component_has_tests(component: str) -> bool:
return bool(get_component_test_files(component))
def _select_platform_by_preference(
platforms: list[Platform] | set[Platform],
) -> Platform:
"""Select the most preferred platform from a list/set based on MEMORY_IMPACT_PLATFORM_PREFERENCE.
Args:
platforms: List or set of platforms to choose from
Returns:
The most preferred platform (earliest in MEMORY_IMPACT_PLATFORM_PREFERENCE)
"""
return min(platforms, key=MEMORY_IMPACT_PLATFORM_PREFERENCE.index)
def _select_platform_by_count(
platform_counts: Counter[Platform],
) -> Platform:
"""Select platform by count, using MEMORY_IMPACT_PLATFORM_PREFERENCE as tiebreaker.
Args:
platform_counts: Counter mapping platforms to their counts
Returns:
Platform with highest count, breaking ties by preference order
"""
return min(
platform_counts.keys(),
key=lambda p: (
-platform_counts[p], # Negative to prefer higher counts
MEMORY_IMPACT_PLATFORM_PREFERENCE.index(p),
),
)
def _detect_platform_hint_from_filename(filename: str) -> Platform | None:
"""Detect platform hint from filename patterns.
Detects platform-specific files using patterns like:
- wifi_component_esp_idf.cpp, *_idf.h -> ESP32 IDF variants
- wifi_component_esp8266.cpp, *_esp8266.h -> ESP8266_ARD
- *_esp32*.cpp -> ESP32 IDF (generic)
- *_libretiny.cpp, *_retiny.* -> LibreTiny (not in preference list)
- *_pico.cpp, *_rp2040.* -> RP2040 (not in preference list)
Args:
filename: File path to check
Returns:
Platform enum if a specific platform is detected, None otherwise
"""
filename_lower = filename.lower()
# ESP-IDF platforms (check specific variants first)
if "esp_idf" in filename_lower or "_idf" in filename_lower:
# Check for specific ESP32 variants
if "c6" in filename_lower or "esp32c6" in filename_lower:
return Platform.ESP32_C6_IDF
if "c3" in filename_lower or "esp32c3" in filename_lower:
return Platform.ESP32_C3_IDF
if "s2" in filename_lower or "esp32s2" in filename_lower:
return Platform.ESP32_S2_IDF
if "s3" in filename_lower or "esp32s3" in filename_lower:
return Platform.ESP32_S3_IDF
# Default to ESP32 IDF for generic esp_idf files
return Platform.ESP32_IDF
# ESP8266 Arduino
if "esp8266" in filename_lower:
return Platform.ESP8266_ARD
# Generic ESP32 (without _idf suffix, could be Arduino or shared code)
# Prefer IDF as it's the modern platform
if "esp32" in filename_lower:
return Platform.ESP32_IDF
# LibreTiny and RP2040 are not in MEMORY_IMPACT_PLATFORM_PREFERENCE
# so we don't return them as hints
# if "retiny" in filename_lower or "libretiny" in filename_lower:
# return None # No specific LibreTiny platform preference
# if "pico" in filename_lower or "rp2040" in filename_lower:
# return None # No RP2040 platform preference
return None
def detect_memory_impact_config(
branch: str | None = None,
) -> dict[str, Any]:
@@ -273,6 +395,12 @@ def detect_memory_impact_config(
building a merged configuration with all changed components (like
test_build_components.py does) to get comprehensive memory analysis.
When platform-specific files are detected (e.g., wifi_component_esp_idf.cpp),
prefers that platform for testing to ensure the most relevant memory analysis.
For core C++ file changes without component changes, runs a fallback
analysis using a representative component to measure the impact.
Args:
branch: Branch to compare against
@@ -288,8 +416,10 @@ def detect_memory_impact_config(
files = changed_files(branch)
# Find all changed components (excluding core and base bus components)
# Also collect platform hints from platform-specific filenames
changed_component_set: set[str] = set()
has_core_changes = False
has_core_cpp_changes = False
platform_hints: list[Platform] = []
for file in files:
component = get_component_from_path(file)
@@ -297,22 +427,27 @@ def detect_memory_impact_config(
# Skip base bus components as they're used across many builds
if component not in BASE_BUS_COMPONENTS:
changed_component_set.add(component)
elif file.startswith("esphome/"):
# Core ESPHome files changed (not component-specific)
has_core_changes = True
# Check if this is a platform-specific file
platform_hint = _detect_platform_hint_from_filename(file)
if platform_hint:
platform_hints.append(platform_hint)
elif file.startswith("esphome/") and file.endswith(CPP_FILE_EXTENSIONS):
# Core ESPHome C++ files changed (not component-specific)
# Only C++ files affect memory usage
has_core_cpp_changes = True
# If no components changed but core changed, test representative component
# If no components changed but core C++ changed, test representative component
force_fallback_platform = False
if not changed_component_set and has_core_changes:
if not changed_component_set and has_core_cpp_changes:
print(
f"Memory impact: No components changed, but core files changed. "
f"Memory impact: No components changed, but core C++ files changed. "
f"Testing {MEMORY_IMPACT_FALLBACK_COMPONENT} component on {MEMORY_IMPACT_FALLBACK_PLATFORM}.",
file=sys.stderr,
)
changed_component_set.add(MEMORY_IMPACT_FALLBACK_COMPONENT)
force_fallback_platform = True # Use fallback platform (most representative)
elif not changed_component_set:
# No components and no core changes
# No components and no core C++ changes
return {"should_run": "false"}
# Find components that have tests and collect their supported platforms
@@ -352,27 +487,42 @@ def detect_memory_impact_config(
common_platforms &= platforms
# Select the most preferred platform from the common set
# Exception: for core changes, use fallback platform (most representative of codebase)
if force_fallback_platform:
# Priority order:
# 1. Platform hints from filenames (e.g., wifi_component_esp_idf.cpp suggests ESP32_IDF)
# 2. Core changes use fallback platform (most representative of codebase)
# 3. Common platforms supported by all components
# 4. Most commonly supported platform
if platform_hints:
# Use most common platform hint that's also supported by all components
hint_counts = Counter(platform_hints)
# Filter to only hints that are in common_platforms (if any common platforms exist)
valid_hints = (
[h for h in hint_counts if h in common_platforms]
if common_platforms
else list(hint_counts.keys())
)
if valid_hints:
platform = _select_platform_by_count(
Counter({p: hint_counts[p] for p in valid_hints})
)
elif common_platforms:
# Hints exist but none match common platforms, use common platform logic
platform = _select_platform_by_preference(common_platforms)
else:
# Use the most common hint even if it's not in common platforms
platform = _select_platform_by_count(hint_counts)
elif force_fallback_platform:
platform = MEMORY_IMPACT_FALLBACK_PLATFORM
elif common_platforms:
# Pick the most preferred platform that all components support
platform = min(common_platforms, key=MEMORY_IMPACT_PLATFORM_PREFERENCE.index)
platform = _select_platform_by_preference(common_platforms)
else:
# No common platform - pick the most commonly supported platform
# This allows testing components individually even if they can't be merged
# Count how many components support each platform
platform_counts = Counter(
p for platforms in component_platforms_map.values() for p in platforms
)
# Pick the platform supported by most components, preferring earlier in MEMORY_IMPACT_PLATFORM_PREFERENCE
platform = max(
platform_counts.keys(),
key=lambda p: (
platform_counts[p],
-MEMORY_IMPACT_PLATFORM_PREFERENCE.index(p),
),
)
platform = _select_platform_by_count(platform_counts)
# Debug output
print("Memory impact analysis:", file=sys.stderr)
@@ -382,6 +532,7 @@ def detect_memory_impact_config(
f" Component platforms: {dict(sorted(component_platforms_map.items()))}",
file=sys.stderr,
)
print(f" Platform hints from filenames: {platform_hints}", file=sys.stderr)
print(f" Common platforms: {sorted(common_platforms)}", file=sys.stderr)
print(f" Selected platform: {platform}", file=sys.stderr)
@@ -408,6 +559,7 @@ def main() -> None:
run_clang_tidy = should_run_clang_tidy(args.branch)
run_clang_format = should_run_clang_format(args.branch)
run_python_linters = should_run_python_linters(args.branch)
changed_cpp_file_count = count_changed_cpp_files(args.branch)
# Get both directly changed and all changed components (with dependencies) in one call
script_path = Path(__file__).parent / "list-components.py"
@@ -445,10 +597,43 @@ def main() -> None:
# Detect components for memory impact analysis (merged config)
memory_impact = detect_memory_impact_config(args.branch)
# Determine clang-tidy mode based on actual files that will be checked
if run_clang_tidy:
is_full_scan = _is_clang_tidy_full_scan()
if is_full_scan:
# Full scan checks all files - always use split mode for efficiency
clang_tidy_mode = "split"
files_to_check_count = -1 # Sentinel value for "all files"
else:
# Targeted scan - calculate actual files that will be checked
# This accounts for component dependencies, not just directly changed files
if changed_components:
# Count C++ files in all changed components (including dependencies)
all_cpp_files = list(git_ls_files(["*.cpp"]).keys())
component_set = set(changed_components)
files_to_check_count = sum(
1
for f in all_cpp_files
if get_component_from_path(f) in component_set
)
else:
# If no components changed, use the simple count of changed C++ files
files_to_check_count = changed_cpp_file_count
if files_to_check_count < CLANG_TIDY_SPLIT_THRESHOLD:
clang_tidy_mode = "nosplit"
else:
clang_tidy_mode = "split"
else:
clang_tidy_mode = "disabled"
files_to_check_count = 0
# Build output
output: dict[str, Any] = {
"integration_tests": run_integration,
"clang_tidy": run_clang_tidy,
"clang_tidy_mode": clang_tidy_mode,
"clang_format": run_clang_format,
"python_linters": run_python_linters,
"changed_components": changed_components,
@@ -458,6 +643,7 @@ def main() -> None:
"component_test_count": len(changed_components_with_tests),
"directly_changed_count": len(directly_changed_with_tests),
"dependency_only_count": len(dependency_only_components),
"changed_cpp_file_count": changed_cpp_file_count,
"memory_impact": memory_impact,
}

View File

@@ -1,195 +0,0 @@
#!/usr/bin/env python3
"""
GitHub Download Cache CLI
This script provides a command-line interface to the GitHub download cache.
The actual caching logic is in esphome/github_cache.py.
Usage:
python3 script/github_download_cache.py download URL
python3 script/github_download_cache.py list
python3 script/github_download_cache.py stats
python3 script/github_download_cache.py clear
"""
import argparse
from pathlib import Path
import sys
import urllib.request
# Add parent directory to path to import esphome modules
sys.path.insert(0, str(Path(__file__).parent.parent))
from esphome.github_cache import GitHubCache
def download_with_progress(
cache: GitHubCache, url: str, force: bool = False, check_updates: bool = True
) -> Path:
"""Download a URL with progress indicator and caching.
Args:
cache: GitHubCache instance
url: URL to download
force: Force re-download even if cached
check_updates: Check for updates using HTTP 304
Returns:
Path to cached file
"""
# If force, skip cache check
if not force:
cached_path = cache.get_cached_path(url, check_updates=check_updates)
if cached_path:
print(f"Using cached file for {url}")
print(f" Cache: {cached_path}")
return cached_path
# Need to download
print(f"Downloading {url}")
cache_path = cache._get_cache_path(url)
print(f" Cache: {cache_path}")
# Download with progress
temp_path = cache_path.with_suffix(cache_path.suffix + ".tmp")
try:
with urllib.request.urlopen(url) as response:
total_size = int(response.headers.get("Content-Length", 0))
downloaded = 0
with open(temp_path, "wb") as f:
while True:
chunk = response.read(8192)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
percent = (downloaded / total_size) * 100
print(f"\r Progress: {percent:.1f}%", end="", flush=True)
print() # New line after progress
# Move to final location
temp_path.replace(cache_path)
# Let cache handle metadata
cache.save_to_cache(url, cache_path)
return cache_path
except (OSError, urllib.error.URLError) as e:
if temp_path.exists():
temp_path.unlink()
raise RuntimeError(f"Failed to download {url}: {e}") from e
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(
description="GitHub Download Cache Manager",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Download and cache a URL
%(prog)s download https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.1.0.zip
# List cached files
%(prog)s list
# Show cache statistics
%(prog)s stats
# Clear cache
%(prog)s clear
""",
)
parser.add_argument(
"--cache-dir",
type=Path,
help="Cache directory (default: ~/.platformio/esphome_download_cache)",
)
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
# Download command
download_parser = subparsers.add_parser("download", help="Download and cache a URL")
download_parser.add_argument("url", help="URL to download")
download_parser.add_argument(
"--force", action="store_true", help="Force re-download even if cached"
)
download_parser.add_argument(
"--no-check-updates",
action="store_true",
help="Skip checking for updates (don't use HTTP 304)",
)
# List command
subparsers.add_parser("list", help="List cached files")
# Stats command
subparsers.add_parser("stats", help="Show cache statistics")
# Clear command
subparsers.add_parser("clear", help="Clear all cached files")
args = parser.parse_args()
if not args.command:
parser.print_help()
return 1
# Use PlatformIO cache directory by default
if args.cache_dir is None:
args.cache_dir = Path.home() / ".platformio" / "esphome_download_cache"
cache = GitHubCache(args.cache_dir)
if args.command == "download":
try:
check_updates = not args.no_check_updates
cache_path = download_with_progress(
cache, args.url, force=args.force, check_updates=check_updates
)
print(f"\nCached at: {cache_path}")
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
elif args.command == "list":
cached = cache.list_cached()
if not cached:
print("No cached files")
return 0
print(f"Cached files ({len(cached)}):")
for item in cached:
size_mb = item["size"] / (1024 * 1024)
print(f" {item['url']}")
print(f" Size: {size_mb:.2f} MB")
print(f" Path: {item['path']}")
return 0
elif args.command == "stats":
total_size = cache.cache_size()
cached_count = len(cache.list_cached())
size_mb = total_size / (1024 * 1024)
print(f"Cache directory: {cache.cache_dir}")
print(f"Cached files: {cached_count}")
print(f"Total size: {size_mb:.2f} MB")
return 0
elif args.command == "clear":
cache.clear_cache()
return 0
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,138 +0,0 @@
#!/usr/bin/env python3
"""
PlatformIO Download Wrapper with Caching
This script can be used as a wrapper around PlatformIO downloads to add caching.
It intercepts download operations and uses the GitHub download cache.
This is designed to be called from PlatformIO's extra_scripts if needed.
"""
from pathlib import Path
import sys
# Import the cache manager
sys.path.insert(0, str(Path(__file__).parent))
from github_download_cache import GitHubDownloadCache
def is_github_url(url: str) -> bool:
"""Check if a URL is a GitHub URL."""
return "github.com" in url.lower()
def cached_download_handler(source, target, env):
"""PlatformIO download handler that uses caching for GitHub URLs.
This function can be registered as a custom download handler in PlatformIO.
Args:
source: Source URL
target: Target file path
env: SCons environment
"""
import shutil
import urllib.request
url = str(source[0])
target_path = Path(str(target[0]))
# Only cache GitHub URLs
if not is_github_url(url):
# Fall back to default download
print(f"Downloading (no cache): {url}")
with (
urllib.request.urlopen(url) as response,
open(target_path, "wb") as out_file,
):
shutil.copyfileobj(response, out_file)
return
# Use cache for GitHub URLs
cache = GitHubDownloadCache()
print(f"Downloading with cache: {url}")
try:
cached_path = cache.download_with_cache(url, check_updates=True)
# Copy from cache to target
shutil.copy2(cached_path, target_path)
print(f" Copied to: {target_path}")
except Exception as e:
print(f"Cache download failed, using direct download: {e}")
# Fall back to direct download
with (
urllib.request.urlopen(url) as response,
open(target_path, "wb") as out_file,
):
shutil.copyfileobj(response, out_file)
def setup_platformio_caching():
"""Setup PlatformIO to use cached downloads.
This should be called from an extra_scripts file in platformio.ini.
Example extra_scripts file (e.g., platformio_hooks.py):
Import("env")
from script.platformio_download_wrapper import setup_platformio_caching
setup_platformio_caching()
"""
try:
from SCons.Script import DefaultEnvironment
DefaultEnvironment()
# Register custom download handler
# Note: This may not work with all PlatformIO versions
# as the download mechanism is internal
print("Note: Direct download interception is not fully supported.")
print("Please use the cache_platformio_downloads.py script instead.")
except ImportError:
print("Warning: SCons not available, cannot setup download caching")
if __name__ == "__main__":
# CLI mode - can be used to manually download a URL with caching
import argparse
parser = argparse.ArgumentParser(description="Download a URL with caching")
parser.add_argument("url", help="URL to download")
parser.add_argument("target", help="Target file path")
parser.add_argument("--cache-dir", type=Path, help="Cache directory")
args = parser.parse_args()
cache = GitHubDownloadCache(args.cache_dir)
target_path = Path(args.target)
try:
if is_github_url(args.url):
print(f"Downloading with cache: {args.url}")
cached_path = cache.download_with_cache(args.url)
# Copy to target
import shutil
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(cached_path, target_path)
print(f"Copied to: {target_path}")
else:
print(f"Downloading directly (not a GitHub URL): {args.url}")
import shutil
import urllib.request
target_path.parent.mkdir(parents=True, exist_ok=True)
with (
urllib.request.urlopen(args.url) as response,
open(target_path, "wb") as out_file,
):
shutil.copyfileobj(response, out_file)
sys.exit(0)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

View File

@@ -0,0 +1,31 @@
switch:
- platform: template
id: climate_heater_switch
optimistic: true
- platform: template
id: climate_cooler_switch
optimistic: true
sensor:
- platform: template
id: climate_temperature_sensor
lambda: |-
return 21.5;
update_interval: 60s
climate:
- platform: bang_bang
id: climate_test_climate
name: Test Climate
sensor: climate_temperature_sensor
default_target_temperature_low: 18°C
default_target_temperature_high: 24°C
idle_action:
- switch.turn_off: climate_heater_switch
- switch.turn_off: climate_cooler_switch
cool_action:
- switch.turn_on: climate_cooler_switch
- switch.turn_off: climate_heater_switch
heat_action:
- switch.turn_on: climate_heater_switch
- switch.turn_off: climate_cooler_switch

View File

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

View File

@@ -16,3 +16,4 @@ esp32_improv:
authorizer: io0_button
authorized_duration: 1min
status_indicator: built_in_led
next_url: "https://example.com/setup?device={{device_name}}&ip={{ip_address}}&version={{esphome_version}}"

View File

@@ -1,4 +1,5 @@
espnow:
id: espnow_component
auto_add_peer: false
channel: 1
peers:
@@ -50,3 +51,26 @@ espnow:
- format_mac_address_pretty(info.src_addr).c_str()
- format_hex_pretty(data, size).c_str()
- info.rx_ctrl->rssi
packet_transport:
- platform: espnow
id: transport1
espnow_id: espnow_component
peer_address: "FF:FF:FF:FF:FF:FF"
encryption:
key: "0123456789abcdef0123456789abcdef"
sensors:
- temp_sensor
providers:
- name: test_provider
encryption:
key: "0123456789abcdef0123456789abcdef"
sensor:
- platform: internal_temperature
id: temp_sensor
- platform: packet_transport
provider: test_provider
remote_id: temp_sensor
id: remote_temp

View File

@@ -0,0 +1,33 @@
json:
interval:
- interval: 60s
then:
- lambda: |-
// Test build_json
std::string json_str = esphome::json::build_json([](JsonObject root) {
root["sensor"] = "temperature";
root["value"] = 23.5;
root["unit"] = "°C";
});
ESP_LOGD("test", "Built JSON: %s", json_str.c_str());
// Test parse_json
bool parse_ok = esphome::json::parse_json(json_str, [](JsonObject root) {
if (root.containsKey("sensor") && root.containsKey("value")) {
const char* sensor = root["sensor"];
float value = root["value"];
ESP_LOGD("test", "Parsed: sensor=%s, value=%.1f", sensor, value);
} else {
ESP_LOGD("test", "Parsed JSON missing required keys");
}
});
ESP_LOGD("test", "Parse result (JSON syntax only): %s", parse_ok ? "success" : "failed");
// Test JsonBuilder class
esphome::json::JsonBuilder builder;
JsonObject obj = builder.root();
obj["test"] = "direct_builder";
obj["count"] = 42;
std::string result = builder.serialize();
ESP_LOGD("test", "JsonBuilder result: %s", result.c_str());

View File

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

View File

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

View File

@@ -99,3 +99,77 @@ sensor:
window_size: 10
send_every: 10
send_first_at: 1 # Send after first value
# ValueListFilter-based filters tests
# FilterOutValueFilter - single value
- platform: copy
source_id: source_sensor
name: "Filter Out Single Value"
filters:
- filter_out: 42.0 # Should filter out exactly 42.0
# FilterOutValueFilter - multiple values
- platform: copy
source_id: source_sensor
name: "Filter Out Multiple Values"
filters:
- filter_out: [0.0, 42.0, 100.0] # List of values to filter
# FilterOutValueFilter - with NaN
- platform: copy
source_id: source_sensor
name: "Filter Out NaN"
filters:
- filter_out: nan # Filter out NaN values
# FilterOutValueFilter - mixed values with NaN
- platform: copy
source_id: source_sensor
name: "Filter Out Mixed with NaN"
filters:
- filter_out: [nan, 0.0, 42.0]
# ThrottleWithPriorityFilter - single priority value
- platform: copy
source_id: source_sensor
name: "Throttle with Single Priority"
filters:
- throttle_with_priority:
timeout: 1000ms
value: 42.0 # Priority value bypasses throttle
# ThrottleWithPriorityFilter - multiple priority values
- platform: copy
source_id: source_sensor
name: "Throttle with Multiple Priorities"
filters:
- throttle_with_priority:
timeout: 500ms
value: [0.0, 42.0, 100.0] # Multiple priority values
# ThrottleWithPriorityFilter - with NaN priority
- platform: copy
source_id: source_sensor
name: "Throttle with NaN Priority"
filters:
- throttle_with_priority:
timeout: 1000ms
value: nan # NaN as priority value
# Combined filters - FilterOutValueFilter + other filters
- platform: copy
source_id: source_sensor
name: "Filter Out Then Throttle"
filters:
- filter_out: [0.0, 100.0]
- throttle: 500ms
# Combined filters - ThrottleWithPriorityFilter + other filters
- platform: copy
source_id: source_sensor
name: "Throttle Priority Then Scale"
filters:
- throttle_with_priority:
timeout: 1000ms
value: [42.0]
- multiply: 2.0

View File

@@ -11,18 +11,17 @@ time:
- 192.168.178.1
uponor_smatrix:
address: 0x110B
time_id: sntp_time
time_device_address: 0xDE13
time_device_address: 0x110BDE13
climate:
- platform: uponor_smatrix
address: 0xDE13
address: 0x110BDE13
name: Thermostat Living Room
sensor:
- platform: uponor_smatrix
address: 0xDE13
address: 0x110BDE13
humidity:
name: Thermostat Humidity Living Room
temperature:

View File

@@ -0,0 +1,332 @@
esphome:
name: test-value-list-filters
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Template sensors - one for each test to avoid cross-test interference
sensor:
- platform: template
name: "Source Sensor 1"
id: source_sensor_1
accuracy_decimals: 1
- platform: template
name: "Source Sensor 2"
id: source_sensor_2
accuracy_decimals: 1
- platform: template
name: "Source Sensor 3"
id: source_sensor_3
accuracy_decimals: 1
- platform: template
name: "Source Sensor 4"
id: source_sensor_4
accuracy_decimals: 1
- platform: template
name: "Source Sensor 5"
id: source_sensor_5
accuracy_decimals: 1
- platform: template
name: "Source Sensor 6"
id: source_sensor_6
accuracy_decimals: 2
- platform: template
name: "Source Sensor 7"
id: source_sensor_7
accuracy_decimals: 1
# FilterOutValueFilter - single value
- platform: copy
source_id: source_sensor_1
name: "Filter Out Single"
id: filter_out_single
filters:
- filter_out: 42.0
# FilterOutValueFilter - multiple values
- platform: copy
source_id: source_sensor_2
name: "Filter Out Multiple"
id: filter_out_multiple
filters:
- filter_out: [0.0, 42.0, 100.0]
# FilterOutValueFilter - with NaN
- platform: copy
source_id: source_sensor_1
name: "Filter Out NaN"
id: filter_out_nan
filters:
- filter_out: nan
# ThrottleWithPriorityFilter - single priority value
- platform: copy
source_id: source_sensor_3
name: "Throttle Priority Single"
id: throttle_priority_single
filters:
- throttle_with_priority:
timeout: 200ms
value: 42.0
# ThrottleWithPriorityFilter - multiple priority values
- platform: copy
source_id: source_sensor_4
name: "Throttle Priority Multiple"
id: throttle_priority_multiple
filters:
- throttle_with_priority:
timeout: 200ms
value: [0.0, 42.0, 100.0]
# Edge case: Filter Out NaN explicitly
- platform: copy
source_id: source_sensor_5
name: "Filter Out NaN Test"
id: filter_out_nan_test
filters:
- filter_out: nan
# Edge case: Accuracy decimals - 2 decimals
- platform: copy
source_id: source_sensor_6
name: "Filter Out Accuracy 2"
id: filter_out_accuracy_2
filters:
- filter_out: 42.0
# Edge case: Throttle with NaN priority
- platform: copy
source_id: source_sensor_7
name: "Throttle Priority NaN"
id: throttle_priority_nan
filters:
- throttle_with_priority:
timeout: 200ms
value: nan
# Script to test FilterOutValueFilter
script:
- id: test_filter_out_single
then:
# Should pass through: 1.0, 2.0, 3.0
# Should filter out: 42.0
- sensor.template.publish:
id: source_sensor_1
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 42.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 2.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 42.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 3.0
- id: test_filter_out_multiple
then:
# Should filter out: 0.0, 42.0, 100.0
# Should pass through: 1.0, 2.0, 50.0
- sensor.template.publish:
id: source_sensor_2
state: 0.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 42.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 2.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 100.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 50.0
- id: test_throttle_priority_single
then:
# 42.0 bypasses throttle, other values are throttled
- sensor.template.publish:
id: source_sensor_3
state: 1.0 # First value - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_3
state: 2.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_3
state: 42.0 # Priority - passes immediately
- delay: 50ms
- sensor.template.publish:
id: source_sensor_3
state: 3.0 # Throttled
- delay: 250ms # Wait for throttle to expire
- sensor.template.publish:
id: source_sensor_3
state: 4.0 # Passes after timeout
- id: test_throttle_priority_multiple
then:
# 0.0, 42.0, 100.0 bypass throttle
- sensor.template.publish:
id: source_sensor_4
state: 1.0 # First value - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 2.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 0.0 # Priority - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 3.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 42.0 # Priority - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 4.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 100.0 # Priority - passes
- id: test_filter_out_nan
then:
# NaN should be filtered out, regular values pass
- sensor.template.publish:
id: source_sensor_5
state: 1.0 # Pass
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: !lambda 'return NAN;' # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 2.0 # Pass
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: !lambda 'return NAN;' # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 3.0 # Pass
- id: test_filter_out_accuracy_2
then:
# With 2 decimal places, 42.00 filtered, 42.01 and 42.15 pass
- sensor.template.publish:
id: source_sensor_6
state: 42.0 # Filtered (rounds to 42.00)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: 42.01 # Pass (rounds to 42.01)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: 42.15 # Pass (rounds to 42.15)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: 42.0 # Filtered (rounds to 42.00)
- id: test_throttle_priority_nan
then:
# NaN bypasses throttle, regular values throttled
- sensor.template.publish:
id: source_sensor_7
state: 1.0 # First value - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_7
state: 2.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_7
state: !lambda 'return NAN;' # Priority NaN - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_7
state: 3.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_7
state: !lambda 'return NAN;' # Priority NaN - passes
# Buttons to trigger each test
button:
- platform: template
name: "Test Filter Out Single"
id: btn_filter_out_single
on_press:
- script.execute: test_filter_out_single
- platform: template
name: "Test Filter Out Multiple"
id: btn_filter_out_multiple
on_press:
- script.execute: test_filter_out_multiple
- platform: template
name: "Test Throttle Priority Single"
id: btn_throttle_priority_single
on_press:
- script.execute: test_throttle_priority_single
- platform: template
name: "Test Throttle Priority Multiple"
id: btn_throttle_priority_multiple
on_press:
- script.execute: test_throttle_priority_multiple
- platform: template
name: "Test Filter Out NaN"
id: btn_filter_out_nan
on_press:
- script.execute: test_filter_out_nan
- platform: template
name: "Test Filter Out Accuracy 2"
id: btn_filter_out_accuracy_2
on_press:
- script.execute: test_filter_out_accuracy_2
- platform: template
name: "Test Throttle Priority NaN"
id: btn_throttle_priority_nan
on_press:
- script.execute: test_throttle_priority_nan

View File

@@ -281,8 +281,12 @@ async def test_noise_corrupt_encrypted_frame(
# Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line:
process_exited = True
# Check for the expected warning about decryption failure
# Check for the expected log about decryption failure
# This can appear as either a VV-level log from noise or a W-level log from connection
if (
"[VV][api.noise" in line
and "noise_cipherstate_decrypt failed: MAC_FAILURE" in line
) or (
"[W][api.connection" in line
and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line
):
@@ -322,9 +326,9 @@ async def test_noise_corrupt_encrypted_frame(
assert not process_exited, (
"ESPHome process should not crash on corrupt encrypted frames"
)
# Verify we saw the expected warning message
# Verify we saw the expected log message about decryption failure
assert cipherstate_failed, (
"Expected to see warning about CIPHERSTATE_DECRYPT_FAILED"
"Expected to see log about noise_cipherstate_decrypt failure or CIPHERSTATE_DECRYPT_FAILED"
)
# Verify we can still reconnect after handling the corrupt frame

View File

@@ -0,0 +1,263 @@
"""Test sensor ValueListFilter functionality (FilterOutValueFilter and ThrottleWithPriorityFilter)."""
from __future__ import annotations
import asyncio
import math
from aioesphomeapi import ButtonInfo, EntityState, SensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_sensor_filters_value_list(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that ValueListFilter-based filters work correctly."""
loop = asyncio.get_running_loop()
# Track state changes for all sensors
sensor_values: dict[str, list[float]] = {
"filter_out_single": [],
"filter_out_multiple": [],
"throttle_priority_single": [],
"throttle_priority_multiple": [],
"filter_out_nan_test": [],
"filter_out_accuracy_2": [],
"throttle_priority_nan": [],
}
# Futures for each test
filter_out_single_done = loop.create_future()
filter_out_multiple_done = loop.create_future()
throttle_single_done = loop.create_future()
throttle_multiple_done = loop.create_future()
filter_out_nan_done = loop.create_future()
filter_out_accuracy_2_done = loop.create_future()
throttle_nan_done = loop.create_future()
def on_state(state: EntityState) -> None:
"""Track sensor state updates."""
if not isinstance(state, SensorState) or state.missing_state:
return
sensor_name = key_to_sensor.get(state.key)
if sensor_name not in sensor_values:
return
sensor_values[sensor_name].append(state.state)
# Check completion conditions
if (
sensor_name == "filter_out_single"
and len(sensor_values[sensor_name]) == 3
and not filter_out_single_done.done()
):
filter_out_single_done.set_result(True)
elif (
sensor_name == "filter_out_multiple"
and len(sensor_values[sensor_name]) == 3
and not filter_out_multiple_done.done()
):
filter_out_multiple_done.set_result(True)
elif (
sensor_name == "throttle_priority_single"
and len(sensor_values[sensor_name]) == 3
and not throttle_single_done.done()
):
throttle_single_done.set_result(True)
elif (
sensor_name == "throttle_priority_multiple"
and len(sensor_values[sensor_name]) == 4
and not throttle_multiple_done.done()
):
throttle_multiple_done.set_result(True)
elif (
sensor_name == "filter_out_nan_test"
and len(sensor_values[sensor_name]) == 3
and not filter_out_nan_done.done()
):
filter_out_nan_done.set_result(True)
elif (
sensor_name == "filter_out_accuracy_2"
and len(sensor_values[sensor_name]) == 2
and not filter_out_accuracy_2_done.done()
):
filter_out_accuracy_2_done.set_result(True)
elif (
sensor_name == "throttle_priority_nan"
and len(sensor_values[sensor_name]) == 3
and not throttle_nan_done.done()
):
throttle_nan_done.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# Get entities and build key mapping
entities, _ = await client.list_entities_services()
key_to_sensor = build_key_to_entity_mapping(
entities,
{
"filter_out_single": "Filter Out Single",
"filter_out_multiple": "Filter Out Multiple",
"throttle_priority_single": "Throttle Priority Single",
"throttle_priority_multiple": "Throttle Priority Multiple",
"filter_out_nan_test": "Filter Out NaN Test",
"filter_out_accuracy_2": "Filter Out Accuracy 2",
"throttle_priority_nan": "Throttle Priority NaN",
},
)
# Set up initial state helper with all entities
initial_state_helper = InitialStateHelper(entities)
# Subscribe to state changes with wrapper
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for initial states
await initial_state_helper.wait_for_initial_states()
# Find all buttons
button_name_map = {
"Test Filter Out Single": "filter_out_single",
"Test Filter Out Multiple": "filter_out_multiple",
"Test Throttle Priority Single": "throttle_priority_single",
"Test Throttle Priority Multiple": "throttle_priority_multiple",
"Test Filter Out NaN": "filter_out_nan",
"Test Filter Out Accuracy 2": "filter_out_accuracy_2",
"Test Throttle Priority NaN": "throttle_priority_nan",
}
buttons = {}
for entity in entities:
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
buttons[button_name_map[entity.name]] = entity.key
assert len(buttons) == 7, f"Expected 7 buttons, found {len(buttons)}"
# Test 1: FilterOutValueFilter - single value
sensor_values["filter_out_single"].clear()
client.button_command(buttons["filter_out_single"])
try:
await asyncio.wait_for(filter_out_single_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 1 timed out. Values: {sensor_values['filter_out_single']}"
)
expected = [1.0, 2.0, 3.0]
assert sensor_values["filter_out_single"] == pytest.approx(expected), (
f"Test 1 failed: expected {expected}, got {sensor_values['filter_out_single']}"
)
# Test 2: FilterOutValueFilter - multiple values
sensor_values["filter_out_multiple"].clear()
filter_out_multiple_done = loop.create_future()
client.button_command(buttons["filter_out_multiple"])
try:
await asyncio.wait_for(filter_out_multiple_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 2 timed out. Values: {sensor_values['filter_out_multiple']}"
)
expected = [1.0, 2.0, 50.0]
assert sensor_values["filter_out_multiple"] == pytest.approx(expected), (
f"Test 2 failed: expected {expected}, got {sensor_values['filter_out_multiple']}"
)
# Test 3: ThrottleWithPriorityFilter - single priority
sensor_values["throttle_priority_single"].clear()
throttle_single_done = loop.create_future()
client.button_command(buttons["throttle_priority_single"])
try:
await asyncio.wait_for(throttle_single_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 3 timed out. Values: {sensor_values['throttle_priority_single']}"
)
expected = [1.0, 42.0, 4.0]
assert sensor_values["throttle_priority_single"] == pytest.approx(expected), (
f"Test 3 failed: expected {expected}, got {sensor_values['throttle_priority_single']}"
)
# Test 4: ThrottleWithPriorityFilter - multiple priorities
sensor_values["throttle_priority_multiple"].clear()
throttle_multiple_done = loop.create_future()
client.button_command(buttons["throttle_priority_multiple"])
try:
await asyncio.wait_for(throttle_multiple_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 4 timed out. Values: {sensor_values['throttle_priority_multiple']}"
)
expected = [1.0, 0.0, 42.0, 100.0]
assert sensor_values["throttle_priority_multiple"] == pytest.approx(expected), (
f"Test 4 failed: expected {expected}, got {sensor_values['throttle_priority_multiple']}"
)
# Test 5: FilterOutValueFilter - NaN handling
sensor_values["filter_out_nan_test"].clear()
filter_out_nan_done = loop.create_future()
client.button_command(buttons["filter_out_nan"])
try:
await asyncio.wait_for(filter_out_nan_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 5 timed out. Values: {sensor_values['filter_out_nan_test']}"
)
expected = [1.0, 2.0, 3.0]
assert sensor_values["filter_out_nan_test"] == pytest.approx(expected), (
f"Test 5 failed: expected {expected}, got {sensor_values['filter_out_nan_test']}"
)
# Test 6: FilterOutValueFilter - Accuracy decimals (2)
sensor_values["filter_out_accuracy_2"].clear()
filter_out_accuracy_2_done = loop.create_future()
client.button_command(buttons["filter_out_accuracy_2"])
try:
await asyncio.wait_for(filter_out_accuracy_2_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 6 timed out. Values: {sensor_values['filter_out_accuracy_2']}"
)
expected = [42.01, 42.15]
assert sensor_values["filter_out_accuracy_2"] == pytest.approx(expected), (
f"Test 6 failed: expected {expected}, got {sensor_values['filter_out_accuracy_2']}"
)
# Test 7: ThrottleWithPriorityFilter - NaN priority
sensor_values["throttle_priority_nan"].clear()
throttle_nan_done = loop.create_future()
client.button_command(buttons["throttle_priority_nan"])
try:
await asyncio.wait_for(throttle_nan_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 7 timed out. Values: {sensor_values['throttle_priority_nan']}"
)
# First value (1.0) + two NaN priority values
# NaN values will be compared using math.isnan
assert len(sensor_values["throttle_priority_nan"]) == 3, (
f"Test 7 failed: expected 3 values, got {len(sensor_values['throttle_priority_nan'])}"
)
assert sensor_values["throttle_priority_nan"][0] == pytest.approx(1.0), (
f"Test 7 failed: first value should be 1.0, got {sensor_values['throttle_priority_nan'][0]}"
)
assert math.isnan(sensor_values["throttle_priority_nan"][1]), (
f"Test 7 failed: second value should be NaN, got {sensor_values['throttle_priority_nan'][1]}"
)
assert math.isnan(sensor_values["throttle_priority_nan"][2]), (
f"Test 7 failed: third value should be NaN, got {sensor_values['throttle_priority_nan'][2]}"
)

View File

@@ -71,6 +71,12 @@ def mock_changed_files() -> Generator[Mock, None, None]:
yield mock
@pytest.fixture(autouse=True)
def clear_clang_tidy_cache() -> None:
"""Clear the clang-tidy full scan cache before each test."""
determine_jobs._is_clang_tidy_full_scan.cache_clear()
def test_main_all_tests_should_run(
mock_should_run_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
@@ -98,7 +104,10 @@ def test_main_all_tests_should_run(
mock_subprocess_run.return_value = mock_result
# Run main function with mocked argv
with patch("sys.argv", ["determine-jobs.py"]):
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
):
determine_jobs.main()
# Check output
@@ -107,6 +116,7 @@ def test_main_all_tests_should_run(
assert output["integration_tests"] is True
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is True
assert output["python_linters"] is True
assert output["changed_components"] == ["wifi", "api", "sensor"]
@@ -117,6 +127,9 @@ def test_main_all_tests_should_run(
assert output["component_test_count"] == len(
output["changed_components_with_tests"]
)
# changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false" # No files changed
@@ -156,11 +169,14 @@ def test_main_no_tests_should_run(
assert output["integration_tests"] is False
assert output["clang_tidy"] is False
assert output["clang_tidy_mode"] == "disabled"
assert output["clang_format"] is False
assert output["python_linters"] is False
assert output["changed_components"] == []
assert output["changed_components_with_tests"] == []
assert output["component_test_count"] == 0
# changed_cpp_file_count should be 0
assert output["changed_cpp_file_count"] == 0
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
@@ -217,7 +233,10 @@ def test_main_with_branch_argument(
)
mock_subprocess_run.return_value = mock_result
with patch("sys.argv", ["script.py", "-b", "main"]):
with (
patch("sys.argv", ["script.py", "-b", "main"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
):
determine_jobs.main()
# Check that functions were called with branch
@@ -239,6 +258,7 @@ def test_main_with_branch_argument(
assert output["integration_tests"] is False
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is False
assert output["python_linters"] is True
assert output["changed_components"] == ["mqtt"]
@@ -249,6 +269,9 @@ def test_main_with_branch_argument(
assert output["component_test_count"] == len(
output["changed_components_with_tests"]
)
# changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
@@ -352,16 +375,6 @@ def test_should_run_clang_tidy_hash_check_exception() -> None:
result = determine_jobs.should_run_clang_tidy()
assert result is True # Fail safe - run clang-tidy
# Even with C++ files, exception should trigger clang-tidy
with (
patch.object(
determine_jobs, "changed_files", return_value=["esphome/core.cpp"]
),
patch("subprocess.run", side_effect=Exception("Hash check failed")),
):
result = determine_jobs.should_run_clang_tidy()
assert result is True
def test_should_run_clang_tidy_with_branch() -> None:
"""Test should_run_clang_tidy with branch argument."""
@@ -433,6 +446,40 @@ def test_should_run_clang_format_with_branch() -> None:
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize(
("changed_files", "expected_count"),
[
(["esphome/core.cpp"], 1),
(["esphome/core.h"], 1),
(["test.hpp"], 1),
(["test.cc"], 1),
(["test.cxx"], 1),
(["test.c"], 1),
(["test.tcc"], 1),
(["esphome/core.cpp", "esphome/core.h"], 2),
(["esphome/core.cpp", "esphome/core.h", "test.cc"], 3),
(["README.md"], 0),
(["esphome/config.py"], 0),
(["README.md", "esphome/config.py"], 0),
(["esphome/core.cpp", "README.md", "esphome/config.py"], 1),
([], 0),
],
)
def test_count_changed_cpp_files(changed_files: list[str], expected_count: int) -> None:
"""Test count_changed_cpp_files function."""
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
result = determine_jobs.count_changed_cpp_files()
assert result == expected_count
def test_count_changed_cpp_files_with_branch() -> None:
"""Test count_changed_cpp_files with branch argument."""
with patch.object(determine_jobs, "changed_files") as mock_changed:
mock_changed.return_value = []
determine_jobs.count_changed_cpp_files("release")
mock_changed.assert_called_once_with("release")
def test_main_filters_components_without_tests(
mock_should_run_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
@@ -501,6 +548,9 @@ def test_main_filters_components_without_tests(
assert set(output["changed_components_with_tests"]) == {"wifi", "sensor"}
# component_test_count should be based on components with tests
assert output["component_test_count"] == 2
# changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
@@ -545,7 +595,7 @@ def test_detect_memory_impact_config_with_common_platform(tmp_path: Path) -> Non
def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
"""Test memory impact detection with core-only changes (no component changes)."""
"""Test memory impact detection with core C++ changes (no component changes)."""
# Create test directory structure with fallback component
tests_dir = tmp_path / "tests" / "components"
@@ -554,7 +604,7 @@ def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
api_dir.mkdir(parents=True)
(api_dir / "test.esp32-idf.yaml").write_text("test: api")
# Mock changed_files to return only core files (no component files)
# Mock changed_files to return only core C++ files (no component files)
with (
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(helpers, "root_path", str(tmp_path)),
@@ -574,6 +624,35 @@ def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
assert result["use_merged_config"] == "true"
def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) -> None:
"""Test that Python-only core changes don't trigger memory impact analysis."""
# Create test directory structure with fallback component
tests_dir = tmp_path / "tests" / "components"
# api component (fallback component) with esp32-idf test
api_dir = tests_dir / "api"
api_dir.mkdir(parents=True)
(api_dir / "test.esp32-idf.yaml").write_text("test: api")
# Mock changed_files to return only core Python files (no C++ files)
with (
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(helpers, "root_path", str(tmp_path)),
patch.object(determine_jobs, "changed_files") as mock_changed_files,
):
mock_changed_files.return_value = [
"esphome/__main__.py",
"esphome/config.py",
"esphome/core/config.py",
]
determine_jobs._component_has_tests.cache_clear()
result = determine_jobs.detect_memory_impact_config()
# Python-only changes should NOT trigger memory impact analysis
assert result["should_run"] == "false"
def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
"""Test memory impact detection when components have no common platform."""
# Create test directory structure
@@ -686,3 +765,120 @@ def test_detect_memory_impact_config_skips_base_bus_components(tmp_path: Path) -
assert result["should_run"] == "true"
assert result["components"] == ["wifi"]
assert "i2c" not in result["components"]
# Tests for clang-tidy split mode logic
def test_clang_tidy_mode_full_scan(
mock_should_run_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_subprocess_run: Mock,
mock_changed_files: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test that full scan (hash changed) always uses split mode."""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_should_run_integration_tests.return_value = False
mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
# Mock list-components.py output
mock_result = Mock()
mock_result.stdout = json.dumps({"directly_changed": [], "all_changed": []})
mock_subprocess_run.return_value = mock_result
# Mock full scan (hash changed)
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=True),
):
determine_jobs.main()
captured = capsys.readouterr()
output = json.loads(captured.out)
# Full scan should always use split mode
assert output["clang_tidy_mode"] == "split"
@pytest.mark.parametrize(
("component_count", "files_per_component", "expected_mode"),
[
# Small PR: 5 files in 1 component -> nosplit
(1, 5, "nosplit"),
# Medium PR: 30 files in 2 components -> nosplit
(2, 15, "nosplit"),
# Medium PR: 64 files total -> nosplit (just under threshold)
(2, 32, "nosplit"),
# Large PR: 65 files total -> split (at threshold)
(2, 33, "split"), # 2 * 33 = 66 files
# Large PR: 100 files in 10 components -> split
(10, 10, "split"),
],
ids=[
"1_comp_5_files_nosplit",
"2_comp_30_files_nosplit",
"2_comp_64_files_nosplit_under_threshold",
"2_comp_66_files_split_at_threshold",
"10_comp_100_files_split",
],
)
def test_clang_tidy_mode_targeted_scan(
component_count: int,
files_per_component: int,
expected_mode: str,
mock_should_run_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_subprocess_run: Mock,
mock_changed_files: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test clang-tidy mode selection based on files_to_check count."""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_should_run_integration_tests.return_value = False
mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
# Create component names
components = [f"comp{i}" for i in range(component_count)]
# Mock list-components.py output
mock_result = Mock()
mock_result.stdout = json.dumps(
{"directly_changed": components, "all_changed": components}
)
mock_subprocess_run.return_value = mock_result
# Mock git_ls_files to return files for each component
cpp_files = {
f"esphome/components/{comp}/file{i}.cpp": 0
for comp in components
for i in range(files_per_component)
}
# Create a mock that returns the cpp_files dict for any call
def mock_git_ls_files(patterns=None):
return cpp_files
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
patch.object(determine_jobs, "git_ls_files", side_effect=mock_git_ls_files),
):
determine_jobs.main()
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["clang_tidy_mode"] == expected_mode

View File

@@ -8,6 +8,7 @@ substitutions:
area: 25
numberOne: 1
var1: 79
double_width: 14
test_list:
- The area is 56
- 56
@@ -25,3 +26,4 @@ test_list:
- ord("a") = 97
- chr(97) = a
- len([1,2,3]) = 3
- width = 7, double_width = 14

View File

@@ -8,6 +8,7 @@ substitutions:
area: 25
numberOne: 1
var1: 79
double_width: ${width * 2}
test_list:
- "The area is ${width * height}"
@@ -23,3 +24,4 @@ test_list:
- ord("a") = ${ ord("a") }
- chr(97) = ${ chr(97) }
- len([1,2,3]) = ${ len([1,2,3]) }
- width = ${width}, double_width = ${double_width}

View File

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

View File

@@ -17,10 +17,12 @@ from esphome import platformio_api
from esphome.__main__ import (
Purpose,
choose_upload_log_host,
command_analyze_memory,
command_clean_all,
command_rename,
command_update_all,
command_wizard,
detect_external_components,
get_port_type,
has_ip_address,
has_mqtt,
@@ -226,13 +228,47 @@ def mock_run_external_process() -> Generator[Mock]:
@pytest.fixture
def mock_run_external_command() -> Generator[Mock]:
"""Mock run_external_command for testing."""
def mock_run_external_command_main() -> Generator[Mock]:
"""Mock run_external_command in __main__ module (different from platformio_api)."""
with patch("esphome.__main__.run_external_command") as mock:
mock.return_value = 0 # Default to success
yield mock
@pytest.fixture
def mock_write_cpp() -> Generator[Mock]:
"""Mock write_cpp for testing."""
with patch("esphome.__main__.write_cpp") as mock:
mock.return_value = 0 # Default to success
yield mock
@pytest.fixture
def mock_compile_program() -> Generator[Mock]:
"""Mock compile_program for testing."""
with patch("esphome.__main__.compile_program") as mock:
mock.return_value = 0 # Default to success
yield mock
@pytest.fixture
def mock_get_esphome_components() -> Generator[Mock]:
"""Mock get_esphome_components for testing."""
with patch("esphome.analyze_memory.helpers.get_esphome_components") as mock:
mock.return_value = {"logger", "api", "ota"}
yield mock
@pytest.fixture
def mock_memory_analyzer_cli() -> Generator[Mock]:
"""Mock MemoryAnalyzerCLI for testing."""
with patch("esphome.analyze_memory.cli.MemoryAnalyzerCLI") as mock_class:
mock_analyzer = MagicMock()
mock_analyzer.generate_report.return_value = "Mock Memory Report"
mock_class.return_value = mock_analyzer
yield mock_class
def test_choose_upload_log_host_with_string_default() -> None:
"""Test with a single string default device."""
setup_core()
@@ -839,7 +875,7 @@ def test_upload_program_serial_esp8266_with_file(
def test_upload_using_esptool_path_conversion(
tmp_path: Path,
mock_run_external_command: Mock,
mock_run_external_command_main: Mock,
mock_get_idedata: Mock,
) -> None:
"""Test upload_using_esptool properly converts Path objects to strings for esptool.
@@ -875,10 +911,10 @@ def test_upload_using_esptool_path_conversion(
assert result == 0
# Verify that run_external_command was called
assert mock_run_external_command.call_count == 1
assert mock_run_external_command_main.call_count == 1
# Get the actual call arguments
call_args = mock_run_external_command.call_args[0]
call_args = mock_run_external_command_main.call_args[0]
# The first argument should be esptool.main function,
# followed by the command arguments
@@ -917,7 +953,7 @@ def test_upload_using_esptool_path_conversion(
def test_upload_using_esptool_with_file_path(
tmp_path: Path,
mock_run_external_command: Mock,
mock_run_external_command_main: Mock,
) -> None:
"""Test upload_using_esptool with a custom file that's a Path object."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
@@ -934,10 +970,10 @@ def test_upload_using_esptool_with_file_path(
assert result == 0
# Verify that run_external_command was called
mock_run_external_command.assert_called_once()
mock_run_external_command_main.assert_called_once()
# Get the actual call arguments
call_args = mock_run_external_command.call_args[0]
call_args = mock_run_external_command_main.call_args[0]
cmd_list = list(call_args[1:]) # Skip the esptool.main function
# Find the firmware path in the command
@@ -2273,3 +2309,226 @@ def test_show_logs_api_mqtt_timeout_fallback(
# Verify run_logs was called with only the static IP (MQTT failed)
mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"])
def test_detect_external_components_no_external(
mock_get_esphome_components: Mock,
) -> None:
"""Test detect_external_components with no external components."""
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {},
"api": {},
}
result = detect_external_components(config)
assert result == set()
mock_get_esphome_components.assert_called_once()
def test_detect_external_components_with_external(
mock_get_esphome_components: Mock,
) -> None:
"""Test detect_external_components detects external components."""
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {}, # Built-in
"api": {}, # Built-in
"my_custom_sensor": {}, # External
"another_custom": {}, # External
"external_components": [], # Special key, not a component
"substitutions": {}, # Special key, not a component
}
result = detect_external_components(config)
assert result == {"my_custom_sensor", "another_custom"}
mock_get_esphome_components.assert_called_once()
def test_detect_external_components_filters_special_keys(
mock_get_esphome_components: Mock,
) -> None:
"""Test detect_external_components filters out special config keys."""
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"substitutions": {"key": "value"},
"packages": {},
"globals": [],
"external_components": [],
"<<": {}, # YAML merge key
}
result = detect_external_components(config)
assert result == set()
mock_get_esphome_components.assert_called_once()
def test_command_analyze_memory_success(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_idedata: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
) -> None:
"""Test command_analyze_memory with successful compilation and analysis."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
# Create firmware.elf file
firmware_path = (
tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device"
)
firmware_path.mkdir(parents=True, exist_ok=True)
firmware_elf = firmware_path / "firmware.elf"
firmware_elf.write_text("mock elf file")
# Mock idedata
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
mock_idedata_obj.objdump_path = "/path/to/objdump"
mock_idedata_obj.readelf_path = "/path/to/readelf"
mock_get_idedata.return_value = mock_idedata_obj
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {},
}
args = MockArgs()
result = command_analyze_memory(args, config)
assert result == 0
# Verify compilation was done
mock_write_cpp.assert_called_once_with(config)
mock_compile_program.assert_called_once_with(args, config)
# Verify analyzer was created with correct parameters
mock_memory_analyzer_cli.assert_called_once_with(
str(firmware_elf),
"/path/to/objdump",
"/path/to/readelf",
set(), # No external components
)
# Verify analysis was run
mock_analyzer = mock_memory_analyzer_cli.return_value
mock_analyzer.analyze.assert_called_once()
mock_analyzer.generate_report.assert_called_once()
# Verify report was printed
captured = capfd.readouterr()
assert "Mock Memory Report" in captured.out
def test_command_analyze_memory_with_external_components(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_idedata: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
) -> None:
"""Test command_analyze_memory detects external components."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
# Create firmware.elf file
firmware_path = (
tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device"
)
firmware_path.mkdir(parents=True, exist_ok=True)
firmware_elf = firmware_path / "firmware.elf"
firmware_elf.write_text("mock elf file")
# Mock idedata
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
mock_idedata_obj.objdump_path = "/path/to/objdump"
mock_idedata_obj.readelf_path = "/path/to/readelf"
mock_get_idedata.return_value = mock_idedata_obj
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {},
"my_custom_component": {"param": "value"}, # External component
"external_components": [{"source": "github://user/repo"}], # Not a component
}
args = MockArgs()
result = command_analyze_memory(args, config)
assert result == 0
# Verify analyzer was created with external components detected
mock_memory_analyzer_cli.assert_called_once_with(
str(firmware_elf),
"/path/to/objdump",
"/path/to/readelf",
{"my_custom_component"}, # External component detected
)
def test_command_analyze_memory_write_cpp_fails(
tmp_path: Path,
mock_write_cpp: Mock,
) -> None:
"""Test command_analyze_memory when write_cpp fails."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
mock_write_cpp.return_value = 1 # Failure
result = command_analyze_memory(args, config)
assert result == 1
mock_write_cpp.assert_called_once_with(config)
def test_command_analyze_memory_compile_fails(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
) -> None:
"""Test command_analyze_memory when compilation fails."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
mock_compile_program.return_value = 1 # Compilation failed
result = command_analyze_memory(args, config)
assert result == 1
mock_write_cpp.assert_called_once_with(config)
mock_compile_program.assert_called_once_with(args, config)
def test_command_analyze_memory_no_idedata(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_idedata: Mock,
) -> None:
"""Test command_analyze_memory when idedata cannot be retrieved."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
mock_get_idedata.return_value = None # Failed to get idedata
with caplog.at_level(logging.ERROR):
result = command_analyze_memory(args, config)
assert result == 1
assert "Failed to get IDE data for memory analysis" in caplog.text