diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 4e2f086f47..dd1bc29d83 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -416,7 +416,7 @@ jobs: } // Generate review messages - function generateReviewMessages(finalLabels) { + function generateReviewMessages(finalLabels, originalLabelCount) { const messages = []; const prAuthor = context.payload.pull_request.user.login; @@ -430,15 +430,15 @@ jobs: .reduce((sum, file) => sum + (file.deletions || 0), 0); const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); - const tooManyLabels = finalLabels.length > MAX_LABELS; + const tooManyLabels = originalLabelCount > MAX_LABELS; const tooManyChanges = nonTestChanges > TOO_BIG_THRESHOLD; let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; if (tooManyLabels && tooManyChanges) { - message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${finalLabels.length} different components/areas.`; + message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`; } else if (tooManyLabels) { - message += `This PR affects ${finalLabels.length} different components/areas.`; + message += `This PR affects ${originalLabelCount} different components/areas.`; } else { message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`; } @@ -466,8 +466,8 @@ jobs: } // Handle reviews - async function handleReviews(finalLabels) { - const reviewMessages = generateReviewMessages(finalLabels); + async function handleReviews(finalLabels, originalLabelCount) { + const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount); const hasReviewableLabels = finalLabels.some(label => ['too-big', 'needs-codeowners'].includes(label) ); @@ -627,6 +627,7 @@ jobs: // Handle too many labels (only for non-mega PRs) const tooManyLabels = finalLabels.length > MAX_LABELS; + const originalLabelCount = finalLabels.length; if (tooManyLabels && !isMegaPR && !finalLabels.includes('too-big')) { finalLabels = ['too-big']; @@ -635,7 +636,7 @@ jobs: console.log('Computed labels:', finalLabels.join(', ')); // Handle reviews - await handleReviews(finalLabels); + await handleReviews(finalLabels, originalLabelCount); // Apply labels if (finalLabels.length > 0) { diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index c122859442..400373679f 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -62,7 +62,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb04f6bf8d..1756d5b765 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,7 @@ jobs: matrix: python-version: - "3.11" - - "3.14" + - "3.13" os: - ubuntu-latest - macOS-latest @@ -123,9 +123,9 @@ jobs: # Minimize CI resource usage # by only running the Python version # version used for docker images on Windows and macOS - - python-version: "3.14" + - python-version: "3.13" os: windows-latest - - python-version: "3.14" + - python-version: "3.13" os: macOS-latest runs-on: ${{ matrix.os }} needs: @@ -180,6 +180,7 @@ jobs: memory_impact: ${{ steps.determine.outputs.memory-impact }} cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }} cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }} + component-test-batches: ${{ steps.determine.outputs.component-test-batches }} steps: - name: Check out code from GitHub uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -214,6 +215,7 @@ jobs: echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT + echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT integration-tests: name: Run integration tests @@ -458,7 +460,7 @@ jobs: GH_TOKEN: ${{ github.token }} strategy: fail-fast: false - max-parallel: 1 + max-parallel: 2 matrix: include: - id: clang-tidy @@ -536,59 +538,18 @@ jobs: 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 - needs: - - common - - determine-jobs - if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 - outputs: - matrix: ${{ steps.split.outputs.components }} - steps: - - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Split components intelligently based on bus configurations - id: split - run: | - . venv/bin/activate - - # Use intelligent splitter that groups components with same bus configs - components='${{ needs.determine-jobs.outputs.changed-components-with-tests }}' - - # Only isolate directly changed components when targeting dev branch - # For beta/release branches, group everything for faster CI - if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then - directly_changed='[]' - echo "Target branch: ${{ github.base_ref }} - grouping all components" - else - directly_changed='${{ needs.determine-jobs.outputs.directly-changed-components-with-tests }}' - echo "Target branch: ${{ github.base_ref }} - isolating directly changed components" - fi - - echo "Splitting components intelligently..." - output=$(python3 script/split_components_for_ci.py --components "$components" --directly-changed "$directly_changed" --batch-size 40 --output github) - - echo "$output" >> $GITHUB_OUTPUT - test-build-components-split: name: Test components batch (${{ matrix.components }}) runs-on: ubuntu-24.04 needs: - common - determine-jobs - - test-build-components-splitter if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 strategy: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} matrix: - components: ${{ fromJson(needs.test-build-components-splitter.outputs.matrix) }} + components: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: - name: Show disk space run: | @@ -849,7 +810,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: memory-analysis-target path: memory-analysis-target.json @@ -913,7 +874,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: memory-analysis-pr path: memory-analysis-pr.json @@ -943,13 +904,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: memory-analysis-pr path: ./memory-analysis @@ -980,7 +941,6 @@ jobs: - clang-tidy-nosplit - clang-tidy-split - determine-jobs - - test-build-components-splitter - test-build-components-split - pre-commit-ci-lite - memory-impact-target-branch diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c24900d378..ab938b3436 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b3b3bdc1b..75d88abf29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,7 +138,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests @@ -171,7 +171,7 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download digests - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: pattern: digests-* path: /tmp/digests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9e0e71d388..5356bffd96 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.1 + rev: v0.14.3 hooks: # Run the linter. - id: ruff diff --git a/CODEOWNERS b/CODEOWNERS index 4f860375d9..667a44fc03 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -201,6 +201,7 @@ esphome/components/havells_solar/* @sourabhjaiswal esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann esphome/components/hbridge/switch/* @dwmw2 +esphome/components/hdc2010/* @optimusprimespace @ssieb esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal diff --git a/ard_esp32_opentherm_tests_pr.md b/ard_esp32_opentherm_tests_pr.md deleted file mode 100644 index b543109d56..0000000000 --- a/ard_esp32_opentherm_tests_pr.md +++ /dev/null @@ -1,86 +0,0 @@ -# What does this implement/fix? - -Removes redundant ESP32 Arduino test files for the `opentherm` component and cleans up redundant preprocessor conditionals. The ESP-IDF tests provide complete coverage since the opentherm component has no framework-specific implementation differences for ESP32. - -Also fixes incorrect preprocessor conditionals - changes `#if defined(ESP32) || defined(USE_ESP_IDF)` to `#ifdef USE_ESP32`. The macro `ESP32` is only defined for the original ESP32 variant, while `USE_ESP32` covers all ESP32 variants (C3, S2, S3, etc.). The `|| defined(USE_ESP_IDF)` was unnecessary since ESP-IDF can only run on ESP32 platforms. - -## Background - -As part of the ongoing effort to reduce Arduino-specific test redundancy (esphome/backlog#66), this PR removes ESP32 Arduino tests that duplicate IDF test coverage. - -**Analysis of opentherm component:** -- Previously used `#if defined(ESP32) || defined(USE_ESP_IDF)` to check for ESP32 **platform** -- This was incorrect: `ESP32` is only defined for the original ESP32 variant, not C3/S2/S3 -- Changed to `#ifdef USE_ESP32` which covers all ESP32 variants -- The `|| defined(USE_ESP_IDF)` part was unnecessary since ESP-IDF can only run on ESP32 platforms -- ESP32 timer APIs (`timer_init`, `timer_set_counter_value`, `timer_isr_callback_add`) are ESP-IDF APIs -- These timer APIs work identically in both Arduino and ESP-IDF frameworks since Arduino is now built on ESP-IDF -- Only ESP8266 has framework-specific code (using Arduino's `timer1_*` functions) -- ESP32 implementation is identical across frameworks - -## Changes - -### Code Cleanup - -**OpenTherm component:** -- Fixed incorrect `#if defined(ESP32) || defined(USE_ESP_IDF)` to `#ifdef USE_ESP32` in: - - `esphome/components/opentherm/opentherm.h` (3 locations) - - `esphome/components/opentherm/opentherm.cpp` (4 locations) -- `ESP32` is only defined for the original ESP32 variant, not C3/S2/S3 -- `USE_ESP32` correctly covers all ESP32 variants -- The `|| defined(USE_ESP_IDF)` part was unnecessary since ESP-IDF can only be defined on ESP32 platforms - -### Test Files Removed - -- `tests/components/opentherm/test.esp32-ard.yaml` -- `tests/components/opentherm/test.esp32-c3-ard.yaml` - -### Test Coverage Maintained - -ESP-IDF test files remain and cover both frameworks: -- `tests/components/opentherm/test.esp32-idf.yaml` -- `tests/components/opentherm/test.esp32-c3-idf.yaml` - -### Platform-Specific Tests Retained - -Arduino tests remain for ESP8266 (uses Arduino-specific `timer1_*` functions): -- `tests/components/opentherm/test.esp8266-ard.yaml` - -## Benefits - -- **Reduces CI test time** - 2 fewer redundant test configurations -- **Simplifies code** - Removes redundant preprocessor conditionals -- **Maintains coverage** - IDF tests cover both frameworks for ESP32 - -## Types of changes - -- [x] Code quality improvements to existing code or addition of tests - -**Related issue or feature (if applicable):** - -- Part of esphome/backlog#66 - Remove redundant ESP32 Arduino tests - -**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):** - -N/A - No user-facing changes - -## Test Environment - -- [x] ESP32 -- [x] ESP32 IDF -- [ ] ESP8266 -- [ ] RP2040 -- [ ] BK72xx -- [ ] RTL87xx -- [ ] nRF52840 - -## Example entry for `config.yaml`: - -N/A - No configuration changes - -## Checklist: - - [x] The code change is tested and works locally. - - [ ] Tests have been added to verify that the new code works (under `tests/` folder). - -If user exposed functionality or configuration variables are added/changed: - - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs). diff --git a/esphome/__main__.py b/esphome/__main__.py index b110d3167f..c531090740 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -207,14 +207,14 @@ def choose_upload_log_host( if has_mqtt_logging(): resolved.append("MQTT") - if has_api() and has_non_ip_address(): + if has_api() and has_non_ip_address() and has_resolvable_address(): resolved.extend(_resolve_with_cache(CORE.address, purpose)) elif purpose == Purpose.UPLOADING: if has_ota() and has_mqtt_ip_lookup(): resolved.append("MQTTIP") - if has_ota() and has_non_ip_address(): + if has_ota() and has_non_ip_address() and has_resolvable_address(): resolved.extend(_resolve_with_cache(CORE.address, purpose)) else: resolved.append(device) @@ -318,7 +318,17 @@ def has_resolvable_address() -> bool: """Check if CORE.address is resolvable (via mDNS, DNS, or is an IP address).""" # Any address (IP, mDNS hostname, or regular DNS hostname) is resolvable # The resolve_ip_address() function in helpers.py handles all types via AsyncResolver - return CORE.address is not None + if CORE.address is None: + return False + + if has_ip_address(): + return True + + if has_mdns(): + return True + + # .local mDNS hostnames are only resolvable if mDNS is enabled + return not CORE.address.endswith(".local") def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str): diff --git a/esphome/analyze_memory.py b/esphome/analyze_memory.py deleted file mode 100644 index 8fac423faa..0000000000 --- a/esphome/analyze_memory.py +++ /dev/null @@ -1,1620 +0,0 @@ -"""Memory usage analyzer for ESPHome compiled binaries.""" - -from collections import defaultdict -import json -import logging -from pathlib import Path -import re -import subprocess - -_LOGGER = logging.getLogger(__name__) - -# Pattern to extract ESPHome component namespaces dynamically -ESPHOME_COMPONENT_PATTERN = re.compile(r"esphome::([a-zA-Z0-9_]+)::") - -# Component identification rules -# Symbol patterns: patterns found in raw symbol names -SYMBOL_PATTERNS = { - "freertos": [ - "vTask", - "xTask", - "xQueue", - "pvPort", - "vPort", - "uxTask", - "pcTask", - "prvTimerTask", - "prvAddNewTaskToReadyList", - "pxReadyTasksLists", - "prvAddCurrentTaskToDelayedList", - "xEventGroupWaitBits", - "xRingbufferSendFromISR", - "prvSendItemDoneNoSplit", - "prvReceiveGeneric", - "prvSendAcquireGeneric", - "prvCopyItemAllowSplit", - "xEventGroup", - "xRingbuffer", - "prvSend", - "prvReceive", - "prvCopy", - "xPort", - "ulTaskGenericNotifyTake", - "prvIdleTask", - "prvInitialiseNewTask", - "prvIsYieldRequiredSMP", - "prvGetItemByteBuf", - "prvInitializeNewRingbuffer", - "prvAcquireItemNoSplit", - "prvNotifyQueueSetContainer", - "ucStaticTimerQueueStorage", - "eTaskGetState", - "main_task", - "do_system_init_fn", - "xSemaphoreCreateGenericWithCaps", - "vListInsert", - "uxListRemove", - "vRingbufferReturnItem", - "vRingbufferReturnItemFromISR", - "prvCheckItemFitsByteBuffer", - "prvGetCurMaxSizeAllowSplit", - "tick_hook", - "sys_sem_new", - "sys_arch_mbox_fetch", - "sys_arch_sem_wait", - "prvDeleteTCB", - "vQueueDeleteWithCaps", - "vRingbufferDeleteWithCaps", - "vSemaphoreDeleteWithCaps", - "prvCheckItemAvail", - "prvCheckTaskCanBeScheduledSMP", - "prvGetCurMaxSizeNoSplit", - "prvResetNextTaskUnblockTime", - "prvReturnItemByteBuf", - "vApplicationStackOverflowHook", - "vApplicationGetIdleTaskMemory", - "sys_init", - "sys_mbox_new", - "sys_arch_mbox_tryfetch", - ], - "xtensa": ["xt_", "_xt_", "xPortEnterCriticalTimeout"], - "heap": ["heap_", "multi_heap"], - "spi_flash": ["spi_flash"], - "rtc": ["rtc_", "rtcio_ll_"], - "gpio_driver": ["gpio_", "pins"], - "uart_driver": ["uart", "_uart", "UART"], - "timer": ["timer_", "esp_timer"], - "peripherals": ["periph_", "periman"], - "network_stack": [ - "vj_compress", - "raw_sendto", - "raw_input", - "etharp_", - "icmp_input", - "socket_ipv6", - "ip_napt", - "socket_ipv4_multicast", - "socket_ipv6_multicast", - "netconn_", - "recv_raw", - "accept_function", - "netconn_recv_data", - "netconn_accept", - "netconn_write_vectors_partly", - "netconn_drain", - "raw_connect", - "raw_bind", - "icmp_send_response", - "sockets", - "icmp_dest_unreach", - "inet_chksum_pseudo", - "alloc_socket", - "done_socket", - "set_global_fd_sets", - "inet_chksum_pbuf", - "tryget_socket_unconn_locked", - "tryget_socket_unconn", - "cs_create_ctrl_sock", - "netbuf_alloc", - ], - "ipv6_stack": ["nd6_", "ip6_", "mld6_", "icmp6_", "icmp6_input"], - "wifi_stack": [ - "ieee80211", - "hostap", - "sta_", - "ap_", - "scan_", - "wifi_", - "wpa_", - "wps_", - "esp_wifi", - "cnx_", - "wpa3_", - "sae_", - "wDev_", - "ic_", - "mac_", - "esf_buf", - "gWpaSm", - "sm_WPA", - "eapol_", - "owe_", - "wifiLowLevelInit", - "s_do_mapping", - "gScanStruct", - "ppSearchTxframe", - "ppMapWaitTxq", - "ppFillAMPDUBar", - "ppCheckTxConnTrafficIdle", - "ppCalTkipMic", - ], - "bluetooth": ["bt_", "ble_", "l2c_", "gatt_", "gap_", "hci_", "BT_init"], - "wifi_bt_coex": ["coex"], - "bluetooth_rom": ["r_ble", "r_lld", "r_llc", "r_llm"], - "bluedroid_bt": [ - "bluedroid", - "btc_", - "bta_", - "btm_", - "btu_", - "BTM_", - "GATT", - "L2CA_", - "smp_", - "gatts_", - "attp_", - "l2cu_", - "l2cb", - "smp_cb", - "BTA_GATTC_", - "SMP_", - "BTU_", - "BTA_Dm", - "GAP_Ble", - "BT_tx_if", - "host_recv_pkt_cb", - "saved_local_oob_data", - "string_to_bdaddr", - "string_is_bdaddr", - "CalConnectParamTimeout", - "transmit_fragment", - "transmit_data", - "event_command_ready", - "read_command_complete_header", - "parse_read_local_extended_features_response", - "parse_read_local_version_info_response", - "should_request_high", - "btdm_wakeup_request", - "BTA_SetAttributeValue", - "BTA_EnableBluetooth", - "transmit_command_futured", - "transmit_command", - "get_waiting_command", - "make_command", - "transmit_downward", - "host_recv_adv_packet", - "copy_extra_byte_in_db", - "parse_read_local_supported_commands_response", - ], - "crypto_math": [ - "ecp_", - "bignum_", - "mpi_", - "sswu", - "modp", - "dragonfly_", - "gcm_mult", - "__multiply", - "quorem", - "__mdiff", - "__lshift", - "__mprec_tens", - "ECC_", - "multiprecision_", - "mix_sub_columns", - "sbox", - "gfm2_sbox", - "gfm3_sbox", - "curve_p256", - "curve", - "p_256_init_curve", - "shift_sub_rows", - "rshift", - ], - "hw_crypto": ["esp_aes", "esp_sha", "esp_rsa", "esp_bignum", "esp_mpi"], - "libc": [ - "printf", - "scanf", - "malloc", - "free", - "memcpy", - "memset", - "strcpy", - "strlen", - "_dtoa", - "_fopen", - "__sfvwrite_r", - "qsort", - "__sf", - "__sflush_r", - "__srefill_r", - "_impure_data", - "_reclaim_reent", - "_open_r", - "strncpy", - "_strtod_l", - "__gethex", - "__hexnan", - "_setenv_r", - "_tzset_unlocked_r", - "__tzcalc_limits", - "select", - "scalbnf", - "strtof", - "strtof_l", - "__d2b", - "__b2d", - "__s2b", - "_Balloc", - "__multadd", - "__lo0bits", - "__atexit0", - "__smakebuf_r", - "__swhatbuf_r", - "_sungetc_r", - "_close_r", - "_link_r", - "_unsetenv_r", - "_rename_r", - "__month_lengths", - "tzinfo", - "__ratio", - "__hi0bits", - "__ulp", - "__any_on", - "__copybits", - "L_shift", - "_fcntl_r", - "_lseek_r", - "_read_r", - "_write_r", - "_unlink_r", - "_fstat_r", - "access", - "fsync", - "tcsetattr", - "tcgetattr", - "tcflush", - "tcdrain", - "__ssrefill_r", - "_stat_r", - "__hexdig_fun", - "__mcmp", - "_fwalk_sglue", - "__fpclassifyf", - "_setlocale_r", - "_mbrtowc_r", - "fcntl", - "__match", - "_lock_close", - "__c$", - "__func__$", - "__FUNCTION__$", - "DAYS_IN_MONTH", - "_DAYS_BEFORE_MONTH", - "CSWTCH$", - "dst$", - "sulp", - ], - "string_ops": ["strcmp", "strncmp", "strchr", "strstr", "strtok", "strdup"], - "memory_alloc": ["malloc", "calloc", "realloc", "free", "_sbrk"], - "file_io": [ - "fread", - "fwrite", - "fopen", - "fclose", - "fseek", - "ftell", - "fflush", - "s_fd_table", - ], - "string_formatting": [ - "snprintf", - "vsnprintf", - "sprintf", - "vsprintf", - "sscanf", - "vsscanf", - ], - "cpp_anonymous": ["_GLOBAL__N_", "n$"], - "cpp_runtime": ["__cxx", "_ZN", "_ZL", "_ZSt", "__gxx_personality", "_Z16"], - "exception_handling": ["__cxa_", "_Unwind_", "__gcc_personality", "uw_frame_state"], - "static_init": ["_GLOBAL__sub_I_"], - "mdns_lib": ["mdns"], - "phy_radio": [ - "phy_", - "rf_", - "chip_", - "register_chipv7", - "pbus_", - "bb_", - "fe_", - "rfcal_", - "ram_rfcal", - "tx_pwctrl", - "rx_chan", - "set_rx_gain", - "set_chan", - "agc_reg", - "ram_txiq", - "ram_txdc", - "ram_gen_rx_gain", - "rx_11b_opt", - "set_rx_sense", - "set_rx_gain_cal", - "set_chan_dig_gain", - "tx_pwctrl_init_cal", - "rfcal_txiq", - "set_tx_gain_table", - "correct_rfpll_offset", - "pll_correct_dcap", - "txiq_cal_init", - "pwdet_sar", - "pwdet_sar2_init", - "ram_iq_est_enable", - "ram_rfpll_set_freq", - "ant_wifirx_cfg", - "ant_btrx_cfg", - "force_txrxoff", - "force_txrx_off", - "tx_paon_set", - "opt_11b_resart", - "rfpll_1p2_opt", - "ram_dc_iq_est", - "ram_start_tx_tone", - "ram_en_pwdet", - "ram_cbw2040_cfg", - "rxdc_est_min", - "i2cmst_reg_init", - "temprature_sens_read", - "ram_restart_cal", - "ram_write_gain_mem", - "ram_wait_rfpll_cal_end", - "txcal_debuge_mode", - "ant_wifitx_cfg", - "reg_init_begin", - ], - "wifi_phy_pp": ["pp_", "ppT", "ppR", "ppP", "ppInstall", "ppCalTxAMPDULength"], - "wifi_lmac": ["lmac"], - "wifi_device": ["wdev", "wDev_"], - "power_mgmt": [ - "pm_", - "sleep", - "rtc_sleep", - "light_sleep", - "deep_sleep", - "power_down", - "g_pm", - ], - "memory_mgmt": [ - "mem_", - "memory_", - "tlsf_", - "memp_", - "pbuf_", - "pbuf_alloc", - "pbuf_copy_partial_pbuf", - ], - "hal_layer": ["hal_"], - "clock_mgmt": [ - "clk_", - "clock_", - "rtc_clk", - "apb_", - "cpu_freq", - "setCpuFrequencyMhz", - ], - "cache_mgmt": ["cache"], - "flash_ops": ["flash", "image_load"], - "interrupt_handlers": [ - "isr", - "interrupt", - "intr_", - "exc_", - "exception", - "port_IntStack", - ], - "wrapper_functions": ["_wrapper"], - "error_handling": ["panic", "abort", "assert", "error_", "fault"], - "authentication": ["auth"], - "ppp_protocol": ["ppp", "ipcp_", "lcp_", "chap_", "LcpEchoCheck"], - "dhcp": ["dhcp", "handle_dhcp"], - "ethernet_phy": [ - "emac_", - "eth_phy_", - "phy_tlk110", - "phy_lan87", - "phy_ip101", - "phy_rtl", - "phy_dp83", - "phy_ksz", - "lan87xx_", - "rtl8201_", - "ip101_", - "ksz80xx_", - "jl1101_", - "dp83848_", - "eth_on_state_changed", - ], - "threading": ["pthread_", "thread_", "_task_"], - "pthread": ["pthread"], - "synchronization": ["mutex", "semaphore", "spinlock", "portMUX"], - "math_lib": [ - "sin", - "cos", - "tan", - "sqrt", - "pow", - "exp", - "log", - "atan", - "asin", - "acos", - "floor", - "ceil", - "fabs", - "round", - ], - "random": ["rand", "random", "rng_", "prng"], - "time_lib": [ - "time", - "clock", - "gettimeofday", - "settimeofday", - "localtime", - "gmtime", - "mktime", - "strftime", - ], - "console_io": ["console_", "uart_tx", "uart_rx", "puts", "putchar", "getchar"], - "rom_functions": ["r_", "rom_"], - "compiler_runtime": [ - "__divdi3", - "__udivdi3", - "__moddi3", - "__muldi3", - "__ashldi3", - "__ashrdi3", - "__lshrdi3", - "__cmpdi2", - "__fixdfdi", - "__floatdidf", - ], - "libgcc": ["libgcc", "_divdi3", "_udivdi3"], - "boot_startup": ["boot", "start_cpu", "call_start", "startup", "bootloader"], - "bootloader": ["bootloader_", "esp_bootloader"], - "app_framework": ["app_", "initArduino", "setup", "loop", "Update"], - "weak_symbols": ["__weak_"], - "compiler_builtins": ["__builtin_"], - "vfs": ["vfs_", "VFS"], - "esp32_sdk": ["esp32_", "esp32c", "esp32s"], - "usb": ["usb_", "USB", "cdc_", "CDC"], - "i2c_driver": ["i2c_", "I2C"], - "i2s_driver": ["i2s_", "I2S"], - "spi_driver": ["spi_", "SPI"], - "adc_driver": ["adc_", "ADC"], - "dac_driver": ["dac_", "DAC"], - "touch_driver": ["touch_", "TOUCH"], - "pwm_driver": ["pwm_", "PWM", "ledc_", "LEDC"], - "rmt_driver": ["rmt_", "RMT"], - "pcnt_driver": ["pcnt_", "PCNT"], - "can_driver": ["can_", "CAN", "twai_", "TWAI"], - "sdmmc_driver": ["sdmmc_", "SDMMC", "sdcard", "sd_card"], - "temp_sensor": ["temp_sensor", "tsens_"], - "watchdog": ["wdt_", "WDT", "watchdog"], - "brownout": ["brownout", "bod_"], - "ulp": ["ulp_", "ULP"], - "psram": ["psram", "PSRAM", "spiram", "SPIRAM"], - "efuse": ["efuse", "EFUSE"], - "partition": ["partition", "esp_partition"], - "esp_event": ["esp_event", "event_loop", "event_callback"], - "esp_console": ["esp_console", "console_"], - "chip_specific": ["chip_", "esp_chip"], - "esp_system_utils": ["esp_system", "esp_hw", "esp_clk", "esp_sleep"], - "ipc": ["esp_ipc", "ipc_"], - "wifi_config": [ - "g_cnxMgr", - "gChmCxt", - "g_ic", - "TxRxCxt", - "s_dp", - "s_ni", - "s_reg_dump", - "packet$", - "d_mult_table", - "K", - "fcstab", - ], - "smartconfig": ["sc_ack_send"], - "rc_calibration": ["rc_cal", "rcUpdate"], - "noise_floor": ["noise_check"], - "rf_calibration": [ - "set_rx_sense", - "set_rx_gain_cal", - "set_chan_dig_gain", - "tx_pwctrl_init_cal", - "rfcal_txiq", - "set_tx_gain_table", - "correct_rfpll_offset", - "pll_correct_dcap", - "txiq_cal_init", - "pwdet_sar", - "rx_11b_opt", - ], - "wifi_crypto": [ - "pk_use_ecparams", - "process_segments", - "ccmp_", - "rc4_", - "aria_", - "mgf_mask", - "dh_group", - "ccmp_aad_nonce", - "ccmp_encrypt", - "rc4_skip", - "aria_sb1", - "aria_sb2", - "aria_is1", - "aria_is2", - "aria_sl", - "aria_a", - ], - "radio_control": ["fsm_input", "fsm_sconfreq"], - "pbuf": [ - "pbuf_", - ], - "event_group": ["xEventGroup"], - "ringbuffer": ["xRingbuffer", "prvSend", "prvReceive", "prvCopy"], - "provisioning": ["prov_", "prov_stop_and_notify"], - "scan": ["gScanStruct"], - "port": ["xPort"], - "elf_loader": [ - "elf_add", - "elf_add_note", - "elf_add_segment", - "process_image", - "read_encoded", - "read_encoded_value", - "read_encoded_value_with_base", - "process_image_header", - ], - "socket_api": [ - "sockets", - "netconn_", - "accept_function", - "recv_raw", - "socket_ipv4_multicast", - "socket_ipv6_multicast", - ], - "igmp": ["igmp_", "igmp_send", "igmp_input"], - "icmp6": ["icmp6_"], - "arp": ["arp_table"], - "ampdu": [ - "ampdu_", - "rcAmpdu", - "trc_onAmpduOp", - "rcAmpduLowerRate", - "ampdu_dispatch_upto", - ], - "ieee802_11": ["ieee802_11_", "ieee802_11_parse_elems"], - "rate_control": ["rssi_margin", "rcGetSched", "get_rate_fcc_index"], - "nan": ["nan_dp_", "nan_dp_post_tx", "nan_dp_delete_peer"], - "channel_mgmt": ["chm_init", "chm_set_current_channel"], - "trace": ["trc_init", "trc_onAmpduOp"], - "country_code": ["country_info", "country_info_24ghz"], - "multicore": ["do_multicore_settings"], - "Update_lib": ["Update"], - "stdio": [ - "__sf", - "__sflush_r", - "__srefill_r", - "_impure_data", - "_reclaim_reent", - "_open_r", - ], - "strncpy_ops": ["strncpy"], - "math_internal": ["__mdiff", "__lshift", "__mprec_tens", "quorem"], - "character_class": ["__chclass"], - "camellia": ["camellia_", "camellia_feistel"], - "crypto_tables": ["FSb", "FSb2", "FSb3", "FSb4"], - "event_buffer": ["g_eb_list_desc", "eb_space"], - "base_node": ["base_node_", "base_node_add_handler"], - "file_descriptor": ["s_fd_table"], - "tx_delay": ["tx_delay_cfg"], - "deinit": ["deinit_functions"], - "lcp_echo": ["LcpEchoCheck"], - "raw_api": ["raw_bind", "raw_connect"], - "checksum": ["process_checksum"], - "entry_management": ["add_entry"], - "esp_ota": ["esp_ota", "ota_", "read_otadata"], - "http_server": [ - "httpd_", - "parse_url_char", - "cb_headers_complete", - "delete_entry", - "validate_structure", - "config_save", - "config_new", - "verify_url", - "cb_url", - ], - "misc_system": [ - "alarm_cbs", - "start_up", - "tokens", - "unhex", - "osi_funcs_ro", - "enum_function", - "fragment_and_dispatch", - "alarm_set", - "osi_alarm_new", - "config_set_string", - "config_update_newest_section", - "config_remove_key", - "method_strings", - "interop_match", - "interop_database", - "__state_table", - "__action_table", - "s_stub_table", - "s_context", - "s_mmu_ctx", - "s_get_bus_mask", - "hli_queue_put", - "list_remove", - "list_delete", - "lock_acquire_generic", - "is_vect_desc_usable", - "io_mode_str", - "__c$20233", - "interface", - "read_id_core", - "subscribe_idle", - "unsubscribe_idle", - "s_clkout_handle", - "lock_release_generic", - "config_set_int", - "config_get_int", - "config_get_string", - "config_has_key", - "config_remove_section", - "osi_alarm_init", - "osi_alarm_deinit", - "fixed_queue_enqueue", - "fixed_queue_dequeue", - "fixed_queue_new", - "fixed_pkt_queue_enqueue", - "fixed_pkt_queue_new", - "list_append", - "list_prepend", - "list_insert_after", - "list_contains", - "list_get_node", - "hash_function_blob", - "cb_no_body", - "cb_on_body", - "profile_tab", - "get_arg", - "trim", - "buf$", - "process_appended_hash_and_sig$constprop$0", - "uuidType", - "allocate_svc_db_buf", - "_hostname_is_ours", - "s_hli_handlers", - "tick_cb", - "idle_cb", - "input", - "entry_find", - "section_find", - "find_bucket_entry_", - "config_has_section", - "hli_queue_create", - "hli_queue_get", - "hli_c_handler", - "future_ready", - "future_await", - "future_new", - "pkt_queue_enqueue", - "pkt_queue_dequeue", - "pkt_queue_cleanup", - "pkt_queue_create", - "pkt_queue_destroy", - "fixed_pkt_queue_dequeue", - "osi_alarm_cancel", - "osi_alarm_is_active", - "osi_sem_take", - "osi_event_create", - "osi_event_bind", - "alarm_cb_handler", - "list_foreach", - "list_back", - "list_front", - "list_clear", - "fixed_queue_try_peek_first", - "translate_path", - "get_idx", - "find_key", - "init", - "end", - "start", - "set_read_value", - "copy_address_list", - "copy_and_key", - "sdk_cfg_opts", - "leftshift_onebit", - "config_section_end", - "config_section_begin", - "find_entry_and_check_all_reset", - "image_validate", - "xPendingReadyList", - "vListInitialise", - "lock_init_generic", - "ant_bttx_cfg", - "ant_dft_cfg", - "cs_send_to_ctrl_sock", - "config_llc_util_funcs_reset", - "make_set_adv_report_flow_control", - "make_set_event_mask", - "raw_new", - "raw_remove", - "BTE_InitStack", - "parse_read_local_supported_features_response", - "__math_invalidf", - "tinytens", - "__mprec_tinytens", - "__mprec_bigtens", - "vRingbufferDelete", - "vRingbufferDeleteWithCaps", - "vRingbufferReturnItem", - "vRingbufferReturnItemFromISR", - "get_acl_data_size_ble", - "get_features_ble", - "get_features_classic", - "get_acl_packet_size_ble", - "get_acl_packet_size_classic", - "supports_extended_inquiry_response", - "supports_rssi_with_inquiry_results", - "supports_interlaced_inquiry_scan", - "supports_reading_remote_extended_features", - ], - "bluetooth_ll": [ - "lld_pdu_", - "ld_acl_", - "lld_stop_ind_handler", - "lld_evt_winsize_change", - "config_lld_evt_funcs_reset", - "config_lld_funcs_reset", - "config_llm_funcs_reset", - "llm_set_long_adv_data", - "lld_retry_tx_prog", - "llc_link_sup_to_ind_handler", - "config_llc_funcs_reset", - "lld_evt_rxwin_compute", - "config_btdm_funcs_reset", - "config_ea_funcs_reset", - "llc_defalut_state_tab_reset", - "config_rwip_funcs_reset", - "ke_lmp_rx_flooding_detect", - ], -} - -# Demangled patterns: patterns found in demangled C++ names -DEMANGLED_PATTERNS = { - "gpio_driver": ["GPIO"], - "uart_driver": ["UART"], - "network_stack": [ - "lwip", - "tcp", - "udp", - "ip4", - "ip6", - "dhcp", - "dns", - "netif", - "ethernet", - "ppp", - "slip", - ], - "wifi_stack": ["NetworkInterface"], - "nimble_bt": [ - "nimble", - "NimBLE", - "ble_hs", - "ble_gap", - "ble_gatt", - "ble_att", - "ble_l2cap", - "ble_sm", - ], - "crypto": ["mbedtls", "crypto", "sha", "aes", "rsa", "ecc", "tls", "ssl"], - "cpp_stdlib": ["std::", "__gnu_cxx::", "__cxxabiv"], - "static_init": ["__static_initialization"], - "rtti": ["__type_info", "__class_type_info"], - "web_server_lib": ["AsyncWebServer", "AsyncWebHandler", "WebServer"], - "async_tcp": ["AsyncClient", "AsyncServer"], - "mdns_lib": ["mdns"], - "json_lib": [ - "ArduinoJson", - "JsonDocument", - "JsonArray", - "JsonObject", - "deserialize", - "serialize", - ], - "http_lib": ["HTTP", "http_", "Request", "Response", "Uri", "WebSocket"], - "logging": ["log", "Log", "print", "Print", "diag_"], - "authentication": ["checkDigestAuthentication"], - "libgcc": ["libgcc"], - "esp_system": ["esp_", "ESP"], - "arduino": ["arduino"], - "nvs": ["nvs_", "_ZTVN3nvs", "nvs::"], - "filesystem": ["spiffs", "vfs"], - "libc": ["newlib"], -} - - -# Get the list of actual ESPHome components by scanning the components directory -def get_esphome_components(): - """Get set of actual ESPHome components from the components directory.""" - components = set() - - # Find the components directory relative to this file - current_dir = Path(__file__).parent - components_dir = current_dir / "components" - - if components_dir.exists() and components_dir.is_dir(): - for item in components_dir.iterdir(): - if ( - item.is_dir() - and not item.name.startswith(".") - and not item.name.startswith("__") - ): - components.add(item.name) - - return components - - -# Cache the component list -ESPHOME_COMPONENTS = get_esphome_components() - - -class MemorySection: - """Represents a memory section with its symbols.""" - - def __init__(self, name: str): - self.name = name - self.symbols: list[tuple[str, int, str]] = [] # (symbol_name, size, component) - self.total_size = 0 - - -class ComponentMemory: - """Tracks memory usage for a component.""" - - def __init__(self, name: str): - self.name = name - self.text_size = 0 # Code in flash - self.rodata_size = 0 # Read-only data in flash - self.data_size = 0 # Initialized data (flash + ram) - self.bss_size = 0 # Uninitialized data (ram only) - self.symbol_count = 0 - - @property - def flash_total(self) -> int: - return self.text_size + self.rodata_size + self.data_size - - @property - def ram_total(self) -> int: - return self.data_size + self.bss_size - - -class MemoryAnalyzer: - """Analyzes memory usage from ELF files.""" - - def __init__( - self, - elf_path: str, - objdump_path: str | None = None, - readelf_path: str | None = None, - external_components: set[str] | None = None, - ): - self.elf_path = Path(elf_path) - if not self.elf_path.exists(): - raise FileNotFoundError(f"ELF file not found: {elf_path}") - - self.objdump_path = objdump_path or "objdump" - self.readelf_path = readelf_path or "readelf" - self.external_components = external_components or set() - - self.sections: dict[str, MemorySection] = {} - self.components: dict[str, ComponentMemory] = defaultdict( - lambda: ComponentMemory("") - ) - self._demangle_cache: dict[str, str] = {} - self._uncategorized_symbols: list[tuple[str, str, int]] = [] - self._esphome_core_symbols: list[ - tuple[str, str, int] - ] = [] # Track core symbols - self._component_symbols: dict[str, list[tuple[str, str, int]]] = defaultdict( - list - ) # Track symbols for all components - - def analyze(self) -> dict[str, ComponentMemory]: - """Analyze the ELF file and return component memory usage.""" - self._parse_sections() - self._parse_symbols() - self._categorize_symbols() - return dict(self.components) - - def _parse_sections(self) -> None: - """Parse section headers from ELF file.""" - try: - result = subprocess.run( - [self.readelf_path, "-S", str(self.elf_path)], - capture_output=True, - text=True, - check=True, - ) - - # Parse section headers - for line in result.stdout.splitlines(): - # Look for section entries - match = re.match( - r"\s*\[\s*\d+\]\s+([\.\w]+)\s+\w+\s+[\da-fA-F]+\s+[\da-fA-F]+\s+([\da-fA-F]+)", - line, - ) - if match: - section_name = match.group(1) - size_hex = match.group(2) - size = int(size_hex, 16) - - # Map various section names to standard categories - mapped_section = None - if ".text" in section_name or ".iram" in section_name: - mapped_section = ".text" - elif ".rodata" in section_name: - mapped_section = ".rodata" - elif ".data" in section_name and "bss" not in section_name: - mapped_section = ".data" - elif ".bss" in section_name: - mapped_section = ".bss" - - if mapped_section: - if mapped_section not in self.sections: - self.sections[mapped_section] = MemorySection( - mapped_section - ) - self.sections[mapped_section].total_size += size - - except subprocess.CalledProcessError as e: - _LOGGER.error(f"Failed to parse sections: {e}") - raise - - def _parse_symbols(self) -> None: - """Parse symbols from ELF file.""" - # Section mapping - centralizes the logic - SECTION_MAPPING = { - ".text": [".text", ".iram"], - ".rodata": [".rodata"], - ".data": [".data", ".dram"], - ".bss": [".bss"], - } - - def map_section_name(raw_section: str) -> str | None: - """Map raw section name to standard section.""" - for standard_section, patterns in SECTION_MAPPING.items(): - if any(pattern in raw_section for pattern in patterns): - return standard_section - return None - - def parse_symbol_line(line: str) -> tuple[str, str, int, str] | None: - """Parse a single symbol line from objdump output. - - Returns (section, name, size, address) or None if not a valid symbol. - Format: address l/g w/d F/O section size name - Example: 40084870 l F .iram0.text 00000000 _xt_user_exc - """ - parts = line.split() - if len(parts) < 5: - return None - - try: - # Validate and extract address - address = parts[0] - int(address, 16) - except ValueError: - return None - - # Look for F (function) or O (object) flag - if "F" not in parts and "O" not in parts: - return None - - # Find section, size, and name - for i, part in enumerate(parts): - if part.startswith("."): - section = map_section_name(part) - if section and i + 1 < len(parts): - try: - size = int(parts[i + 1], 16) - if i + 2 < len(parts) and size > 0: - name = " ".join(parts[i + 2 :]) - return (section, name, size, address) - except ValueError: - pass - break - return None - - try: - result = subprocess.run( - [self.objdump_path, "-t", str(self.elf_path)], - capture_output=True, - text=True, - check=True, - ) - - # Track seen addresses to avoid duplicates - seen_addresses: set[str] = set() - - for line in result.stdout.splitlines(): - symbol_info = parse_symbol_line(line) - if symbol_info: - section, name, size, address = symbol_info - # Skip duplicate symbols at the same address (e.g., C1/C2 constructors) - if address not in seen_addresses and section in self.sections: - self.sections[section].symbols.append((name, size, "")) - seen_addresses.add(address) - - except subprocess.CalledProcessError as e: - _LOGGER.error(f"Failed to parse symbols: {e}") - raise - - def _categorize_symbols(self) -> None: - """Categorize symbols by component.""" - # First, collect all unique symbol names for batch demangling - all_symbols = set() - for section in self.sections.values(): - for symbol_name, _, _ in section.symbols: - all_symbols.add(symbol_name) - - # Batch demangle all symbols at once - self._batch_demangle_symbols(list(all_symbols)) - - # Now categorize with cached demangled names - for section_name, section in self.sections.items(): - for symbol_name, size, _ in section.symbols: - component = self._identify_component(symbol_name) - - if component not in self.components: - self.components[component] = ComponentMemory(component) - - comp_mem = self.components[component] - comp_mem.symbol_count += 1 - - if section_name == ".text": - comp_mem.text_size += size - elif section_name == ".rodata": - comp_mem.rodata_size += size - elif section_name == ".data": - comp_mem.data_size += size - elif section_name == ".bss": - comp_mem.bss_size += size - - # Track uncategorized symbols - if component == "other" and size > 0: - demangled = self._demangle_symbol(symbol_name) - self._uncategorized_symbols.append((symbol_name, demangled, size)) - - # Track ESPHome core symbols for detailed analysis - if component == "[esphome]core" and size > 0: - demangled = self._demangle_symbol(symbol_name) - self._esphome_core_symbols.append((symbol_name, demangled, size)) - - # Track all component symbols for detailed analysis - if size > 0: - demangled = self._demangle_symbol(symbol_name) - self._component_symbols[component].append( - (symbol_name, demangled, size) - ) - - def _identify_component(self, symbol_name: str) -> str: - """Identify which component a symbol belongs to.""" - # Demangle C++ names if needed - demangled = self._demangle_symbol(symbol_name) - - # Check for special component classes first (before namespace pattern) - # This handles cases like esphome::ESPHomeOTAComponent which should map to ota - if "esphome::" in demangled: - # Check for special component classes that include component name in the class - # For example: esphome::ESPHomeOTAComponent -> ota component - for component_name in ESPHOME_COMPONENTS: - # Check various naming patterns - component_upper = component_name.upper() - component_camel = component_name.replace("_", "").title() - patterns = [ - f"esphome::{component_upper}Component", # e.g., esphome::OTAComponent - f"esphome::ESPHome{component_upper}Component", # e.g., esphome::ESPHomeOTAComponent - f"esphome::{component_camel}Component", # e.g., esphome::OtaComponent - f"esphome::ESPHome{component_camel}Component", # e.g., esphome::ESPHomeOtaComponent - ] - - if any(pattern in demangled for pattern in patterns): - return f"[esphome]{component_name}" - - # Check for ESPHome component namespaces - match = ESPHOME_COMPONENT_PATTERN.search(demangled) - if match: - component_name = match.group(1) - # Strip trailing underscore if present (e.g., switch_ -> switch) - component_name = component_name.rstrip("_") - - # Check if this is an actual component in the components directory - if component_name in ESPHOME_COMPONENTS: - return f"[esphome]{component_name}" - # Check if this is a known external component from the config - elif component_name in self.external_components: - return f"[external]{component_name}" - else: - # Everything else in esphome:: namespace is core - return "[esphome]core" - - # Check for esphome core namespace (no component namespace) - if "esphome::" in demangled: - # If no component match found, it's core - return "[esphome]core" - - # Check against symbol patterns - for component, patterns in SYMBOL_PATTERNS.items(): - if any(pattern in symbol_name for pattern in patterns): - return component - - # Check against demangled patterns - for component, patterns in DEMANGLED_PATTERNS.items(): - if any(pattern in demangled for pattern in patterns): - return component - - # Special cases that need more complex logic - - # Check if spi_flash vs spi_driver - if "spi_" in symbol_name or "SPI" in symbol_name: - if "spi_flash" in symbol_name: - return "spi_flash" - else: - return "spi_driver" - - # libc special printf variants - if symbol_name.startswith("_") and symbol_name[1:].replace("_r", "").replace( - "v", "" - ).replace("s", "") in ["printf", "fprintf", "sprintf", "scanf"]: - return "libc" - - # Track uncategorized symbols for analysis - return "other" - - def _batch_demangle_symbols(self, symbols: list[str]) -> None: - """Batch demangle C++ symbol names for efficiency.""" - if not symbols: - return - - # Try to find the appropriate c++filt for the platform - cppfilt_cmd = "c++filt" - - # Check if we have a toolchain-specific c++filt - if self.objdump_path and self.objdump_path != "objdump": - # Replace objdump with c++filt in the path - potential_cppfilt = self.objdump_path.replace("objdump", "c++filt") - if Path(potential_cppfilt).exists(): - cppfilt_cmd = potential_cppfilt - - try: - # Send all symbols to c++filt at once - result = subprocess.run( - [cppfilt_cmd], - input="\n".join(symbols), - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - demangled_lines = result.stdout.strip().split("\n") - # Map original to demangled names - for original, demangled in zip(symbols, demangled_lines): - self._demangle_cache[original] = demangled - else: - # If batch fails, cache originals - for symbol in symbols: - self._demangle_cache[symbol] = symbol - except Exception: - # On error, cache originals - for symbol in symbols: - self._demangle_cache[symbol] = symbol - - def _demangle_symbol(self, symbol: str) -> str: - """Get demangled C++ symbol name from cache.""" - return self._demangle_cache.get(symbol, symbol) - - def _categorize_esphome_core_symbol(self, demangled: str) -> str: - """Categorize ESPHome core symbols into subcategories.""" - # Dictionary of patterns for core subcategories - CORE_SUBCATEGORY_PATTERNS = { - "Component Framework": ["Component"], - "Application Core": ["Application"], - "Scheduler": ["Scheduler"], - "Logging": ["Logger", "log_"], - "Preferences": ["preferences", "Preferences"], - "Synchronization": ["Mutex", "Lock"], - "Helpers": ["Helper"], - "Network Utilities": ["network", "Network"], - "Time Management": ["time", "Time"], - "String Utilities": ["str_", "string"], - "Parsing/Formatting": ["parse_", "format_"], - "Optional Types": ["optional", "Optional"], - "Callbacks": ["Callback", "callback"], - "Color Utilities": ["Color"], - "C++ Operators": ["operator"], - "Global Variables": ["global_", "_GLOBAL"], - "Setup/Loop": ["setup", "loop"], - "System Control": ["reboot", "restart"], - "GPIO Management": ["GPIO", "gpio"], - "Interrupt Handling": ["ISR", "interrupt"], - "Hooks": ["Hook", "hook"], - "Entity Base Classes": ["Entity"], - "Automation Framework": ["automation", "Automation"], - "Automation Components": ["Condition", "Action", "Trigger"], - "Lambda Support": ["lambda"], - } - - # Special patterns that need to be checked separately - if any(pattern in demangled for pattern in ["vtable", "typeinfo", "thunk"]): - return "C++ Runtime (vtables/RTTI)" - - if demangled.startswith("std::"): - return "C++ STL" - - # Check against patterns - for category, patterns in CORE_SUBCATEGORY_PATTERNS.items(): - if any(pattern in demangled for pattern in patterns): - return category - - return "Other Core" - - def generate_report(self, detailed: bool = False) -> str: - """Generate a formatted memory report.""" - components = sorted( - self.components.items(), key=lambda x: x[1].flash_total, reverse=True - ) - - # Calculate totals - total_flash = sum(c.flash_total for _, c in components) - total_ram = sum(c.ram_total for _, c in components) - - # Build report - lines = [] - - # Column width constants - COL_COMPONENT = 29 - COL_FLASH_TEXT = 14 - COL_FLASH_DATA = 14 - COL_RAM_DATA = 12 - COL_RAM_BSS = 12 - COL_TOTAL_FLASH = 15 - COL_TOTAL_RAM = 12 - COL_SEPARATOR = 3 # " | " - - # Core analysis column widths - COL_CORE_SUBCATEGORY = 30 - COL_CORE_SIZE = 12 - COL_CORE_COUNT = 6 - COL_CORE_PERCENT = 10 - - # Calculate the exact table width - table_width = ( - COL_COMPONENT - + COL_SEPARATOR - + COL_FLASH_TEXT - + COL_SEPARATOR - + COL_FLASH_DATA - + COL_SEPARATOR - + COL_RAM_DATA - + COL_SEPARATOR - + COL_RAM_BSS - + COL_SEPARATOR - + COL_TOTAL_FLASH - + COL_SEPARATOR - + COL_TOTAL_RAM - ) - - lines.append("=" * table_width) - lines.append("Component Memory Analysis".center(table_width)) - lines.append("=" * table_width) - lines.append("") - - # Main table - fixed column widths - lines.append( - f"{'Component':<{COL_COMPONENT}} | {'Flash (text)':>{COL_FLASH_TEXT}} | {'Flash (data)':>{COL_FLASH_DATA}} | {'RAM (data)':>{COL_RAM_DATA}} | {'RAM (bss)':>{COL_RAM_BSS}} | {'Total Flash':>{COL_TOTAL_FLASH}} | {'Total RAM':>{COL_TOTAL_RAM}}" - ) - lines.append( - "-" * COL_COMPONENT - + "-+-" - + "-" * COL_FLASH_TEXT - + "-+-" - + "-" * COL_FLASH_DATA - + "-+-" - + "-" * COL_RAM_DATA - + "-+-" - + "-" * COL_RAM_BSS - + "-+-" - + "-" * COL_TOTAL_FLASH - + "-+-" - + "-" * COL_TOTAL_RAM - ) - - for name, mem in components: - if mem.flash_total > 0 or mem.ram_total > 0: - flash_rodata = mem.rodata_size + mem.data_size - lines.append( - f"{name:<{COL_COMPONENT}} | {mem.text_size:>{COL_FLASH_TEXT - 2},} B | {flash_rodata:>{COL_FLASH_DATA - 2},} B | " - f"{mem.data_size:>{COL_RAM_DATA - 2},} B | {mem.bss_size:>{COL_RAM_BSS - 2},} B | " - f"{mem.flash_total:>{COL_TOTAL_FLASH - 2},} B | {mem.ram_total:>{COL_TOTAL_RAM - 2},} B" - ) - - lines.append( - "-" * COL_COMPONENT - + "-+-" - + "-" * COL_FLASH_TEXT - + "-+-" - + "-" * COL_FLASH_DATA - + "-+-" - + "-" * COL_RAM_DATA - + "-+-" - + "-" * COL_RAM_BSS - + "-+-" - + "-" * COL_TOTAL_FLASH - + "-+-" - + "-" * COL_TOTAL_RAM - ) - lines.append( - f"{'TOTAL':<{COL_COMPONENT}} | {' ':>{COL_FLASH_TEXT}} | {' ':>{COL_FLASH_DATA}} | " - f"{' ':>{COL_RAM_DATA}} | {' ':>{COL_RAM_BSS}} | " - f"{total_flash:>{COL_TOTAL_FLASH - 2},} B | {total_ram:>{COL_TOTAL_RAM - 2},} B" - ) - - # Top consumers - lines.append("") - lines.append("Top Flash Consumers:") - for i, (name, mem) in enumerate(components[:10]): - if mem.flash_total > 0: - percentage = ( - (mem.flash_total / total_flash * 100) if total_flash > 0 else 0 - ) - lines.append( - f"{i + 1}. {name} ({mem.flash_total:,} B) - {percentage:.1f}% of analyzed flash" - ) - - lines.append("") - lines.append("Top RAM Consumers:") - ram_components = sorted(components, key=lambda x: x[1].ram_total, reverse=True) - for i, (name, mem) in enumerate(ram_components[:10]): - if mem.ram_total > 0: - percentage = (mem.ram_total / total_ram * 100) if total_ram > 0 else 0 - lines.append( - f"{i + 1}. {name} ({mem.ram_total:,} B) - {percentage:.1f}% of analyzed RAM" - ) - - lines.append("") - lines.append( - "Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included." - ) - lines.append("=" * table_width) - - # Add ESPHome core detailed analysis if there are core symbols - if self._esphome_core_symbols: - lines.append("") - lines.append("=" * table_width) - lines.append("[esphome]core Detailed Analysis".center(table_width)) - lines.append("=" * table_width) - lines.append("") - - # Group core symbols by subcategory - core_subcategories: dict[str, list[tuple[str, str, int]]] = defaultdict( - list - ) - - for symbol, demangled, size in self._esphome_core_symbols: - # Categorize based on demangled name patterns - subcategory = self._categorize_esphome_core_symbol(demangled) - core_subcategories[subcategory].append((symbol, demangled, size)) - - # Sort subcategories by total size - sorted_subcategories = sorted( - [ - (name, symbols, sum(s[2] for s in symbols)) - for name, symbols in core_subcategories.items() - ], - key=lambda x: x[2], - reverse=True, - ) - - lines.append( - f"{'Subcategory':<{COL_CORE_SUBCATEGORY}} | {'Size':>{COL_CORE_SIZE}} | " - f"{'Count':>{COL_CORE_COUNT}} | {'% of Core':>{COL_CORE_PERCENT}}" - ) - lines.append( - "-" * COL_CORE_SUBCATEGORY - + "-+-" - + "-" * COL_CORE_SIZE - + "-+-" - + "-" * COL_CORE_COUNT - + "-+-" - + "-" * COL_CORE_PERCENT - ) - - core_total = sum(size for _, _, size in self._esphome_core_symbols) - - for subcategory, symbols, total_size in sorted_subcategories: - percentage = (total_size / core_total * 100) if core_total > 0 else 0 - lines.append( - f"{subcategory:<{COL_CORE_SUBCATEGORY}} | {total_size:>{COL_CORE_SIZE - 2},} B | " - f"{len(symbols):>{COL_CORE_COUNT}} | {percentage:>{COL_CORE_PERCENT - 1}.1f}%" - ) - - # Top 10 largest core symbols - lines.append("") - lines.append("Top 10 Largest [esphome]core Symbols:") - sorted_core_symbols = sorted( - self._esphome_core_symbols, key=lambda x: x[2], reverse=True - ) - - for i, (symbol, demangled, size) in enumerate(sorted_core_symbols[:10]): - lines.append(f"{i + 1}. {demangled} ({size:,} B)") - - lines.append("=" * table_width) - - # Add detailed analysis for top 5 ESPHome components - esphome_components = [ - (name, mem) - for name, mem in components - if name.startswith("[esphome]") and name != "[esphome]core" - ] - top_esphome_components = sorted( - esphome_components, key=lambda x: x[1].flash_total, reverse=True - )[:5] - - # Check if API component exists and ensure it's included - api_component = None - for name, mem in components: - if name == "[esphome]api": - api_component = (name, mem) - break - - # If API exists and not in top 5, add it to the list - components_to_analyze = list(top_esphome_components) - if api_component and api_component not in components_to_analyze: - components_to_analyze.append(api_component) - - if components_to_analyze: - for comp_name, comp_mem in components_to_analyze: - comp_symbols = self._component_symbols.get(comp_name, []) - if comp_symbols: - lines.append("") - lines.append("=" * table_width) - lines.append(f"{comp_name} Detailed Analysis".center(table_width)) - lines.append("=" * table_width) - lines.append("") - - # Sort symbols by size - sorted_symbols = sorted( - comp_symbols, key=lambda x: x[2], reverse=True - ) - - lines.append(f"Total symbols: {len(sorted_symbols)}") - lines.append(f"Total size: {comp_mem.flash_total:,} B") - lines.append("") - - # For API component, show all symbols; for others show top 10 - if comp_name == "[esphome]api": - lines.append(f"All {comp_name} Symbols (sorted by size):") - for i, (symbol, demangled, size) in enumerate(sorted_symbols): - lines.append(f"{i + 1}. {demangled} ({size:,} B)") - else: - lines.append(f"Top 10 Largest {comp_name} Symbols:") - for i, (symbol, demangled, size) in enumerate( - sorted_symbols[:10] - ): - lines.append(f"{i + 1}. {demangled} ({size:,} B)") - - lines.append("=" * table_width) - - return "\n".join(lines) - - def to_json(self) -> str: - """Export analysis results as JSON.""" - data = { - "components": { - name: { - "text": mem.text_size, - "rodata": mem.rodata_size, - "data": mem.data_size, - "bss": mem.bss_size, - "flash_total": mem.flash_total, - "ram_total": mem.ram_total, - "symbol_count": mem.symbol_count, - } - for name, mem in self.components.items() - }, - "totals": { - "flash": sum(c.flash_total for c in self.components.values()), - "ram": sum(c.ram_total for c in self.components.values()), - }, - } - return json.dumps(data, indent=2) - - def dump_uncategorized_symbols(self, output_file: str | None = None) -> None: - """Dump uncategorized symbols for analysis.""" - # Sort by size descending - sorted_symbols = sorted( - self._uncategorized_symbols, key=lambda x: x[2], reverse=True - ) - - lines = ["Uncategorized Symbols Analysis", "=" * 80] - lines.append(f"Total uncategorized symbols: {len(sorted_symbols)}") - lines.append( - f"Total uncategorized size: {sum(s[2] for s in sorted_symbols):,} bytes" - ) - lines.append("") - lines.append(f"{'Size':>10} | {'Symbol':<60} | Demangled") - lines.append("-" * 10 + "-+-" + "-" * 60 + "-+-" + "-" * 40) - - for symbol, demangled, size in sorted_symbols[:100]: # Top 100 - if symbol != demangled: - lines.append(f"{size:>10,} | {symbol[:60]:<60} | {demangled[:100]}") - else: - lines.append(f"{size:>10,} | {symbol[:60]:<60} | [not demangled]") - - if len(sorted_symbols) > 100: - lines.append(f"\n... and {len(sorted_symbols) - 100} more symbols") - - content = "\n".join(lines) - - if output_file: - with open(output_file, "w") as f: - f.write(content) - else: - print(content) - - -def analyze_elf( - elf_path: str, - objdump_path: str | None = None, - readelf_path: str | None = None, - detailed: bool = False, - external_components: set[str] | None = None, -) -> str: - """Analyze an ELF file and return a memory report.""" - analyzer = MemoryAnalyzer(elf_path, objdump_path, readelf_path, external_components) - analyzer.analyze() - return analyzer.generate_report(detailed) - - -if __name__ == "__main__": - import sys - - if len(sys.argv) < 2: - print("Usage: analyze_memory.py ") - sys.exit(1) - - try: - report = analyze_elf(sys.argv[1]) - print(report) - except Exception as e: - print(f"Error: {e}") - sys.exit(1) diff --git a/esphome/automation.py b/esphome/automation.py index 99def9f273..99be12451e 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -16,7 +16,12 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, ) from esphome.core import ID -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import ( + LambdaExpression, + MockObj, + MockObjClass, + TemplateArgsType, +) from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from esphome.util import Registry @@ -87,6 +92,7 @@ def validate_potentially_or_condition(value): DelayAction = cg.esphome_ns.class_("DelayAction", Action, cg.Component) LambdaAction = cg.esphome_ns.class_("LambdaAction", Action) +StatelessLambdaAction = cg.esphome_ns.class_("StatelessLambdaAction", Action) IfAction = cg.esphome_ns.class_("IfAction", Action) WhileAction = cg.esphome_ns.class_("WhileAction", Action) RepeatAction = cg.esphome_ns.class_("RepeatAction", Action) @@ -97,9 +103,40 @@ ResumeComponentAction = cg.esphome_ns.class_("ResumeComponentAction", Action) Automation = cg.esphome_ns.class_("Automation") LambdaCondition = cg.esphome_ns.class_("LambdaCondition", Condition) +StatelessLambdaCondition = cg.esphome_ns.class_("StatelessLambdaCondition", Condition) ForCondition = cg.esphome_ns.class_("ForCondition", Condition, cg.Component) +def new_lambda_pvariable( + id_obj: ID, + lambda_expr: LambdaExpression, + stateless_class: MockObjClass, + template_arg: cg.TemplateArguments | None = None, +) -> MockObj: + """Create Pvariable for lambda, using stateless class if applicable. + + Combines ID selection and Pvariable creation in one call. For stateless + lambdas (empty capture), uses function pointer instead of std::function. + + Args: + id_obj: The ID object (action_id, condition_id, or filter_id) + lambda_expr: The lambda expression object + stateless_class: The stateless class to use for stateless lambdas + template_arg: Optional template arguments (for actions/conditions) + + Returns: + The created Pvariable + """ + # For stateless lambdas, use function pointer instead of std::function + if lambda_expr.capture == "": + id_obj = id_obj.copy() + id_obj.type = stateless_class + + if template_arg is not None: + return cg.new_Pvariable(id_obj, template_arg, lambda_expr) + return cg.new_Pvariable(id_obj, lambda_expr) + + def validate_automation(extra_schema=None, extra_validators=None, single=False): if extra_schema is None: extra_schema = {} @@ -145,7 +182,7 @@ def validate_automation(extra_schema=None, extra_validators=None, single=False): value = cv.Schema([extra_validators])(value) if single: if len(value) != 1: - raise cv.Invalid("Cannot have more than 1 automation for templates") + raise cv.Invalid("This trigger allows only a single automation") return value[0] return value @@ -240,7 +277,9 @@ async def lambda_condition_to_code( args: TemplateArgsType, ) -> MockObj: lambda_ = await cg.process_lambda(config, args, return_type=bool) - return cg.new_Pvariable(condition_id, template_arg, lambda_) + return new_lambda_pvariable( + condition_id, lambda_, StatelessLambdaCondition, template_arg + ) @register_condition( @@ -406,7 +445,7 @@ async def lambda_action_to_code( args: TemplateArgsType, ) -> MockObj: lambda_ = await cg.process_lambda(config, args, return_type=cg.void) - return cg.new_Pvariable(action_id, template_arg, lambda_) + return new_lambda_pvariable(action_id, lambda_, StatelessLambdaAction, template_arg) @register_action( diff --git a/esphome/codegen.py b/esphome/codegen.py index 6decd77c62..6d55c6023d 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -62,6 +62,7 @@ from esphome.cpp_types import ( # noqa: F401 EntityBase, EntityCategory, ESPTime, + FixedVector, GPIOPin, InternalGPIOPin, JsonObject, diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 174a9d9e0a..b1e2252ce7 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -172,12 +172,6 @@ def alarm_control_panel_schema( return _ALARM_CONTROL_PANEL_SCHEMA.extend(schema) -# Remove before 2025.11.0 -ALARM_CONTROL_PANEL_SCHEMA = alarm_control_panel_schema(AlarmControlPanel) -ALARM_CONTROL_PANEL_SCHEMA.add_extra( - cv.deprecated_schema_constant("alarm_control_panel") -) - ALARM_CONTROL_PANEL_ACTION_SCHEMA = maybe_simple_id( { cv.GenerateID(): cv.use_id(AlarmControlPanel), diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index e91e922204..449572c0e5 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -71,10 +71,12 @@ SERVICE_ARG_NATIVE_TYPES = { "int": cg.int32, "float": float, "string": cg.std_string, - "bool[]": cg.std_vector.template(bool), - "int[]": cg.std_vector.template(cg.int32), - "float[]": cg.std_vector.template(float), - "string[]": cg.std_vector.template(cg.std_string), + "bool[]": cg.FixedVector.template(bool).operator("const").operator("ref"), + "int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"), + "float[]": cg.FixedVector.template(float).operator("const").operator("ref"), + "string[]": cg.FixedVector.template(cg.std_string) + .operator("const") + .operator("ref"), } CONF_ENCRYPTION = "encryption" CONF_BATCH_DELAY = "batch_delay" @@ -258,6 +260,10 @@ async def to_code(config): if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: cg.add_define("USE_API_SERVICES") + # Set USE_API_CUSTOM_SERVICES if external components need dynamic service registration + if config[CONF_CUSTOM_SERVICES]: + cg.add_define("USE_API_CUSTOM_SERVICES") + if config[CONF_HOMEASSISTANT_SERVICES]: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") @@ -265,6 +271,8 @@ async def to_code(config): cg.add_define("USE_API_HOMEASSISTANT_STATES") if actions := config.get(CONF_ACTIONS, []): + # Collect all triggers first, then register all at once with initializer_list + triggers: list[cg.Pvariable] = [] for conf in actions: template_args = [] func_args = [] @@ -278,8 +286,10 @@ async def to_code(config): trigger = cg.new_Pvariable( conf[CONF_TRIGGER_ID], templ, conf[CONF_ACTION], service_arg_names ) - cg.add(var.register_user_service(trigger)) + triggers.append(trigger) await automation.build_automation(trigger, func_args, conf) + # Register all services at once - single allocation, no reallocations + cg.add(var.initialize_user_services(triggers)) if CONF_ON_CLIENT_CONNECTED in config: cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER") diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index a4c2557ffe..7a50fa6b17 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -425,7 +425,7 @@ message ListEntitiesFanResponse { bool disabled_by_default = 9; string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"]; EntityCategory entity_category = 11; - repeated string supported_preset_modes = 12 [(container_pointer) = "std::vector"]; + repeated string supported_preset_modes = 12 [(container_pointer_no_template) = "std::vector"]; uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"]; } // Deprecated in API version 1.6 - only used in deprecated fields @@ -989,7 +989,7 @@ message ListEntitiesClimateResponse { bool supports_current_temperature = 5; // Deprecated: use feature_flags bool supports_two_point_target_temperature = 6; // Deprecated: use feature_flags - repeated ClimateMode supported_modes = 7 [(container_pointer) = "std::set"]; + repeated ClimateMode supported_modes = 7 [(container_pointer_no_template) = "climate::ClimateModeMask"]; float visual_min_temperature = 8; float visual_max_temperature = 9; float visual_target_temperature_step = 10; @@ -998,11 +998,11 @@ message ListEntitiesClimateResponse { // Deprecated in API version 1.5 bool legacy_supports_away = 11 [deprecated=true]; bool supports_action = 12; // Deprecated: use feature_flags - repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer) = "std::set"]; - repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer) = "std::set"]; - repeated string supported_custom_fan_modes = 15 [(container_pointer) = "std::set"]; - repeated ClimatePreset supported_presets = 16 [(container_pointer) = "std::set"]; - repeated string supported_custom_presets = 17 [(container_pointer) = "std::set"]; + repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer_no_template) = "climate::ClimateFanModeMask"]; + repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer_no_template) = "climate::ClimateSwingModeMask"]; + repeated string supported_custom_fan_modes = 15 [(container_pointer_no_template) = "std::vector"]; + repeated ClimatePreset supported_presets = 16 [(container_pointer_no_template) = "climate::ClimatePresetMask"]; + repeated string supported_custom_presets = 17 [(container_pointer_no_template) = "std::vector"]; bool disabled_by_default = 18; string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON"]; EntityCategory entity_category = 20; @@ -1143,7 +1143,7 @@ message ListEntitiesSelectResponse { reserved 4; // Deprecated: was string unique_id string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; - repeated string options = 6 [(container_pointer) = "std::vector"]; + repeated string options = 6 [(container_pointer_no_template) = "FixedVector"]; bool disabled_by_default = 7; EntityCategory entity_category = 8; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7c135946f8..5ab8a6eb05 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -410,8 +410,8 @@ uint16_t APIConnection::try_send_fan_state(EntityBase *entity, APIConnection *co } if (traits.supports_direction()) msg.direction = static_cast(fan->direction); - if (traits.supports_preset_modes()) - msg.set_preset_mode(StringRef(fan->preset_mode)); + if (traits.supports_preset_modes() && fan->has_preset_mode()) + msg.set_preset_mode(StringRef(fan->get_preset_mode())); return fill_and_encode_entity_state(fan, msg, FanStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -423,7 +423,7 @@ uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *con msg.supports_speed = traits.supports_speed(); msg.supports_direction = traits.supports_direction(); msg.supported_speed_count = traits.supported_speed_count(); - msg.supported_preset_modes = &traits.supported_preset_modes_for_api_(); + msg.supported_preset_modes = &traits.supported_preset_modes(); return fill_and_encode_entity_info(fan, msg, ListEntitiesFanResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::fan_command(const FanCommandRequest &msg) { @@ -486,7 +486,7 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c if (light->supports_effects()) { msg.effects.emplace_back("None"); for (auto *effect : light->get_effects()) { - msg.effects.push_back(effect->get_name()); + msg.effects.emplace_back(effect->get_name()); } } return fill_and_encode_entity_info(light, msg, ListEntitiesLightResponse::MESSAGE_TYPE, conn, remaining_size, @@ -637,14 +637,14 @@ uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection } if (traits.get_supports_fan_modes() && climate->fan_mode.has_value()) resp.fan_mode = static_cast(climate->fan_mode.value()); - if (!traits.get_supported_custom_fan_modes().empty() && climate->custom_fan_mode.has_value()) { - resp.set_custom_fan_mode(StringRef(climate->custom_fan_mode.value())); + if (!traits.get_supported_custom_fan_modes().empty() && climate->has_custom_fan_mode()) { + resp.set_custom_fan_mode(StringRef(climate->get_custom_fan_mode())); } if (traits.get_supports_presets() && climate->preset.has_value()) { resp.preset = static_cast(climate->preset.value()); } - if (!traits.get_supported_custom_presets().empty() && climate->custom_preset.has_value()) { - resp.set_custom_preset(StringRef(climate->custom_preset.value())); + if (!traits.get_supported_custom_presets().empty() && climate->has_custom_preset()) { + resp.set_custom_preset(StringRef(climate->get_custom_preset())); } if (traits.get_supports_swing_modes()) resp.swing_mode = static_cast(climate->swing_mode); @@ -669,18 +669,18 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION); // Current feature flags and other supported parameters msg.feature_flags = traits.get_feature_flags(); - msg.supported_modes = &traits.get_supported_modes_for_api_(); + msg.supported_modes = &traits.get_supported_modes(); msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature(); msg.visual_target_temperature_step = traits.get_visual_target_temperature_step(); msg.visual_current_temperature_step = traits.get_visual_current_temperature_step(); msg.visual_min_humidity = traits.get_visual_min_humidity(); msg.visual_max_humidity = traits.get_visual_max_humidity(); - msg.supported_fan_modes = &traits.get_supported_fan_modes_for_api_(); - msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes_for_api_(); - msg.supported_presets = &traits.get_supported_presets_for_api_(); - msg.supported_custom_presets = &traits.get_supported_custom_presets_for_api_(); - msg.supported_swing_modes = &traits.get_supported_swing_modes_for_api_(); + msg.supported_fan_modes = &traits.get_supported_fan_modes(); + msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes(); + msg.supported_presets = &traits.get_supported_presets(); + msg.supported_custom_presets = &traits.get_supported_custom_presets(); + msg.supported_swing_modes = &traits.get_supported_swing_modes(); return fill_and_encode_entity_info(climate, msg, ListEntitiesClimateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -877,7 +877,7 @@ uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection bool is_single) { auto *select = static_cast(entity); SelectStateResponse resp; - resp.set_state(StringRef(select->state)); + resp.set_state(StringRef(select->current_option())); resp.missing_state = !select->has_state(); return fill_and_encode_entity_state(select, resp, SelectStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index e952ea670b..633b07a7fa 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -434,8 +434,7 @@ APIError APINoiseFrameHelper::write_protobuf_packets(ProtoWriteBuffer buffer, st return APIError::OK; } - std::vector *raw_buffer = buffer.get_buffer(); - uint8_t *buffer_data = raw_buffer->data(); // Cache buffer pointer + uint8_t *buffer_data = buffer.get_buffer()->data(); this->reusable_iovs_.clear(); this->reusable_iovs_.reserve(packets.size()); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 471e6c5404..dcbd35aa32 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -230,8 +230,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_packets(ProtoWriteBuffer buffer return APIError::OK; } - std::vector *raw_buffer = buffer.get_buffer(); - uint8_t *buffer_data = raw_buffer->data(); // Cache buffer pointer + uint8_t *buffer_data = buffer.get_buffer()->data(); this->reusable_iovs_.clear(); this->reusable_iovs_.reserve(packets.size()); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 37bcf5d8a0..dfa1a1320f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -355,8 +355,8 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(10, this->icon_ref_); #endif buffer.encode_uint32(11, static_cast(this->entity_category)); - for (const auto &it : *this->supported_preset_modes) { - buffer.encode_string(12, it, true); + for (const char *it : *this->supported_preset_modes) { + buffer.encode_string(12, it, strlen(it), true); } #ifdef USE_DEVICES buffer.encode_uint32(13, this->device_id); @@ -376,8 +376,8 @@ void ListEntitiesFanResponse::calculate_size(ProtoSize &size) const { #endif size.add_uint32(1, static_cast(this->entity_category)); if (!this->supported_preset_modes->empty()) { - for (const auto &it : *this->supported_preset_modes) { - size.add_length_force(1, it.size()); + for (const char *it : *this->supported_preset_modes) { + size.add_length_force(1, strlen(it)); } } #ifdef USE_DEVICES @@ -1179,14 +1179,14 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { for (const auto &it : *this->supported_swing_modes) { buffer.encode_uint32(14, static_cast(it), true); } - for (const auto &it : *this->supported_custom_fan_modes) { - buffer.encode_string(15, it, true); + for (const char *it : *this->supported_custom_fan_modes) { + buffer.encode_string(15, it, strlen(it), true); } for (const auto &it : *this->supported_presets) { buffer.encode_uint32(16, static_cast(it), true); } - for (const auto &it : *this->supported_custom_presets) { - buffer.encode_string(17, it, true); + for (const char *it : *this->supported_custom_presets) { + buffer.encode_string(17, it, strlen(it), true); } buffer.encode_bool(18, this->disabled_by_default); #ifdef USE_ENTITY_ICON @@ -1229,8 +1229,8 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { } } if (!this->supported_custom_fan_modes->empty()) { - for (const auto &it : *this->supported_custom_fan_modes) { - size.add_length_force(1, it.size()); + for (const char *it : *this->supported_custom_fan_modes) { + size.add_length_force(1, strlen(it)); } } if (!this->supported_presets->empty()) { @@ -1239,8 +1239,8 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { } } if (!this->supported_custom_presets->empty()) { - for (const auto &it : *this->supported_custom_presets) { - size.add_length_force(2, it.size()); + for (const char *it : *this->supported_custom_presets) { + size.add_length_force(2, strlen(it)); } } size.add_bool(2, this->disabled_by_default); @@ -1475,8 +1475,8 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { #ifdef USE_ENTITY_ICON buffer.encode_string(5, this->icon_ref_); #endif - for (const auto &it : *this->options) { - buffer.encode_string(6, it, true); + for (const char *it : *this->options) { + buffer.encode_string(6, it, strlen(it), true); } buffer.encode_bool(7, this->disabled_by_default); buffer.encode_uint32(8, static_cast(this->entity_category)); @@ -1492,8 +1492,8 @@ void ListEntitiesSelectResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->icon_ref_.size()); #endif if (!this->options->empty()) { - for (const auto &it : *this->options) { - size.add_length_force(1, it.size()); + for (const char *it : *this->options) { + size.add_length_force(1, strlen(it)); } } size.add_bool(1, this->disabled_by_default); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index e71ad2c64e..716f1a6e9b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -725,7 +725,7 @@ class ListEntitiesFanResponse final : public InfoResponseProtoMessage { bool supports_speed{false}; bool supports_direction{false}; int32_t supported_speed_count{0}; - const std::vector *supported_preset_modes{}; + const std::vector *supported_preset_modes{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1377,16 +1377,16 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { #endif bool supports_current_temperature{false}; bool supports_two_point_target_temperature{false}; - const std::set *supported_modes{}; + const climate::ClimateModeMask *supported_modes{}; float visual_min_temperature{0.0f}; float visual_max_temperature{0.0f}; float visual_target_temperature_step{0.0f}; bool supports_action{false}; - const std::set *supported_fan_modes{}; - const std::set *supported_swing_modes{}; - const std::set *supported_custom_fan_modes{}; - const std::set *supported_presets{}; - const std::set *supported_custom_presets{}; + const climate::ClimateFanModeMask *supported_fan_modes{}; + const climate::ClimateSwingModeMask *supported_swing_modes{}; + const std::vector *supported_custom_fan_modes{}; + const climate::ClimatePresetMask *supported_presets{}; + const std::vector *supported_custom_presets{}; float visual_current_temperature_step{0.0f}; bool supports_current_humidity{false}; bool supports_target_humidity{false}; @@ -1534,7 +1534,7 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_select_response"; } #endif - const std::vector *options{}; + const FixedVector *options{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index e803125f53..d94ceaaa9c 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -88,6 +88,12 @@ static void dump_field(std::string &out, const char *field_name, StringRef value out.append("\n"); } +static void dump_field(std::string &out, const char *field_name, const char *value, int indent = 2) { + append_field_prefix(out, field_name, indent); + out.append("'").append(value).append("'"); + out.append("\n"); +} + template static void dump_field(std::string &out, const char *field_name, T value, int indent = 2) { append_field_prefix(out, field_name, indent); out.append(proto_enum_to_string(value)); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index e0e23301d0..d29181250e 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -125,8 +125,14 @@ class APIServer : public Component, public Controller { #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES #endif // USE_API_HOMEASSISTANT_SERVICES #ifdef USE_API_SERVICES + void initialize_user_services(std::initializer_list services) { + this->user_services_.assign(services); + } +#ifdef USE_API_CUSTOM_SERVICES + // Only compile push_back method when custom_services: true (external components) void register_user_service(UserServiceDescriptor *descriptor) { this->user_services_.push_back(descriptor); } #endif +#endif #ifdef USE_HOMEASSISTANT_TIME void request_time(); #endif diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 711eba2444..d34ccfa0ce 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -53,8 +53,14 @@ class CustomAPIDevice { template void register_service(void (T::*callback)(Ts...), const std::string &name, const std::array &arg_names) { +#ifdef USE_API_CUSTOM_SERVICES auto *service = new CustomAPIDeviceService(name, arg_names, (T *) this, callback); // NOLINT global_api_server->register_user_service(service); +#else + static_assert( + sizeof(T) == 0, + "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); +#endif } #else template @@ -86,8 +92,14 @@ class CustomAPIDevice { */ #ifdef USE_API_SERVICES template void register_service(void (T::*callback)(), const std::string &name) { +#ifdef USE_API_CUSTOM_SERVICES auto *service = new CustomAPIDeviceService(name, {}, (T *) this, callback); // NOLINT global_api_server->register_user_service(service); +#else + static_assert( + sizeof(T) == 0, + "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); +#endif } #else template void register_service(void (T::*callback)(), const std::string &name) { diff --git a/esphome/components/api/user_services.cpp b/esphome/components/api/user_services.cpp index 3cbf2ab5f9..9c2b4aa79a 100644 --- a/esphome/components/api/user_services.cpp +++ b/esphome/components/api/user_services.cpp @@ -11,23 +11,58 @@ template<> int32_t get_execute_arg_value(const ExecuteServiceArgument & } template<> float get_execute_arg_value(const ExecuteServiceArgument &arg) { return arg.float_; } template<> std::string get_execute_arg_value(const ExecuteServiceArgument &arg) { return arg.string_; } + +// Legacy std::vector versions for external components using custom_api_device.h - optimized with reserve template<> std::vector get_execute_arg_value>(const ExecuteServiceArgument &arg) { - return std::vector(arg.bool_array.begin(), arg.bool_array.end()); + std::vector result; + result.reserve(arg.bool_array.size()); + result.insert(result.end(), arg.bool_array.begin(), arg.bool_array.end()); + return result; } template<> std::vector get_execute_arg_value>(const ExecuteServiceArgument &arg) { - return std::vector(arg.int_array.begin(), arg.int_array.end()); + std::vector result; + result.reserve(arg.int_array.size()); + result.insert(result.end(), arg.int_array.begin(), arg.int_array.end()); + return result; } template<> std::vector get_execute_arg_value>(const ExecuteServiceArgument &arg) { - return std::vector(arg.float_array.begin(), arg.float_array.end()); + std::vector result; + result.reserve(arg.float_array.size()); + result.insert(result.end(), arg.float_array.begin(), arg.float_array.end()); + return result; } template<> std::vector get_execute_arg_value>(const ExecuteServiceArgument &arg) { - return std::vector(arg.string_array.begin(), arg.string_array.end()); + std::vector result; + result.reserve(arg.string_array.size()); + result.insert(result.end(), arg.string_array.begin(), arg.string_array.end()); + return result; +} + +// New FixedVector const reference versions for YAML-generated services - zero-copy +template<> +const FixedVector &get_execute_arg_value &>(const ExecuteServiceArgument &arg) { + return arg.bool_array; +} +template<> +const FixedVector &get_execute_arg_value &>(const ExecuteServiceArgument &arg) { + return arg.int_array; +} +template<> +const FixedVector &get_execute_arg_value &>(const ExecuteServiceArgument &arg) { + return arg.float_array; +} +template<> +const FixedVector &get_execute_arg_value &>( + const ExecuteServiceArgument &arg) { + return arg.string_array; } template<> enums::ServiceArgType to_service_arg_type() { return enums::SERVICE_ARG_TYPE_BOOL; } template<> enums::ServiceArgType to_service_arg_type() { return enums::SERVICE_ARG_TYPE_INT; } template<> enums::ServiceArgType to_service_arg_type() { return enums::SERVICE_ARG_TYPE_FLOAT; } template<> enums::ServiceArgType to_service_arg_type() { return enums::SERVICE_ARG_TYPE_STRING; } + +// Legacy std::vector versions for external components using custom_api_device.h template<> enums::ServiceArgType to_service_arg_type>() { return enums::SERVICE_ARG_TYPE_BOOL_ARRAY; } template<> enums::ServiceArgType to_service_arg_type>() { return enums::SERVICE_ARG_TYPE_INT_ARRAY; @@ -39,4 +74,18 @@ template<> enums::ServiceArgType to_service_arg_type>() return enums::SERVICE_ARG_TYPE_STRING_ARRAY; } +// New FixedVector const reference versions for YAML-generated services +template<> enums::ServiceArgType to_service_arg_type &>() { + return enums::SERVICE_ARG_TYPE_BOOL_ARRAY; +} +template<> enums::ServiceArgType to_service_arg_type &>() { + return enums::SERVICE_ARG_TYPE_INT_ARRAY; +} +template<> enums::ServiceArgType to_service_arg_type &>() { + return enums::SERVICE_ARG_TYPE_FLOAT_ARRAY; +} +template<> enums::ServiceArgType to_service_arg_type &>() { + return enums::SERVICE_ARG_TYPE_STRING_ARRAY; +} + } // namespace esphome::api diff --git a/esphome/components/bedjet/bedjet_const.h b/esphome/components/bedjet/bedjet_const.h index 7cac1b61ff..0693be1092 100644 --- a/esphome/components/bedjet/bedjet_const.h +++ b/esphome/components/bedjet/bedjet_const.h @@ -99,9 +99,8 @@ enum BedjetCommand : uint8_t { static const uint8_t BEDJET_FAN_SPEED_COUNT = 20; -static const char *const BEDJET_FAN_STEP_NAMES[BEDJET_FAN_SPEED_COUNT] = BEDJET_FAN_STEP_NAMES_; +static constexpr const char *const BEDJET_FAN_STEP_NAMES[BEDJET_FAN_SPEED_COUNT] = BEDJET_FAN_STEP_NAMES_; static const std::string BEDJET_FAN_STEP_NAME_STRINGS[BEDJET_FAN_SPEED_COUNT] = BEDJET_FAN_STEP_NAMES_; -static const std::set BEDJET_FAN_STEP_NAMES_SET BEDJET_FAN_STEP_NAMES_; } // namespace bedjet } // namespace esphome diff --git a/esphome/components/bedjet/climate/bedjet_climate.cpp b/esphome/components/bedjet/climate/bedjet_climate.cpp index f22d312b5a..877fd6f771 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.cpp +++ b/esphome/components/bedjet/climate/bedjet_climate.cpp @@ -48,7 +48,7 @@ void BedJetClimate::dump_config() { ESP_LOGCONFIG(TAG, " - %s", LOG_STR_ARG(climate_fan_mode_to_string(mode))); } for (const auto &mode : traits.get_supported_custom_fan_modes()) { - ESP_LOGCONFIG(TAG, " - %s (c)", mode.c_str()); + ESP_LOGCONFIG(TAG, " - %s (c)", mode); } ESP_LOGCONFIG(TAG, " Supported presets:"); @@ -56,7 +56,7 @@ void BedJetClimate::dump_config() { ESP_LOGCONFIG(TAG, " - %s", LOG_STR_ARG(climate_preset_to_string(preset))); } for (const auto &preset : traits.get_supported_custom_presets()) { - ESP_LOGCONFIG(TAG, " - %s (c)", preset.c_str()); + ESP_LOGCONFIG(TAG, " - %s (c)", preset); } } @@ -79,7 +79,7 @@ void BedJetClimate::reset_state_() { this->target_temperature = NAN; this->current_temperature = NAN; this->preset.reset(); - this->custom_preset.reset(); + this->clear_custom_preset_(); this->publish_state(); } @@ -120,7 +120,7 @@ void BedJetClimate::control(const ClimateCall &call) { if (button_result) { this->mode = mode; // We're using (custom) preset for Turbo, EXT HT, & M1-3 presets, so changing climate mode will clear those - this->custom_preset.reset(); + this->clear_custom_preset_(); this->preset.reset(); } } @@ -144,8 +144,7 @@ void BedJetClimate::control(const ClimateCall &call) { if (result) { this->mode = CLIMATE_MODE_HEAT; - this->preset = CLIMATE_PRESET_BOOST; - this->custom_preset.reset(); + this->set_preset_(CLIMATE_PRESET_BOOST); } } else if (preset == CLIMATE_PRESET_NONE && this->preset.has_value()) { if (this->mode == CLIMATE_MODE_HEAT && this->preset == CLIMATE_PRESET_BOOST) { @@ -153,7 +152,7 @@ void BedJetClimate::control(const ClimateCall &call) { result = this->parent_->send_button(heat_button(this->heating_mode_)); if (result) { this->preset.reset(); - this->custom_preset.reset(); + this->clear_custom_preset_(); } } else { ESP_LOGD(TAG, "Ignoring preset '%s' call; with current mode '%s' and preset '%s'", @@ -184,8 +183,7 @@ void BedJetClimate::control(const ClimateCall &call) { } if (result) { - this->custom_preset = preset; - this->preset.reset(); + this->set_custom_preset_(preset.c_str()); } } @@ -207,8 +205,7 @@ void BedJetClimate::control(const ClimateCall &call) { } if (result) { - this->fan_mode = fan_mode; - this->custom_fan_mode.reset(); + this->set_fan_mode_(fan_mode); } } else if (call.get_custom_fan_mode().has_value()) { auto fan_mode = *call.get_custom_fan_mode(); @@ -218,8 +215,7 @@ void BedJetClimate::control(const ClimateCall &call) { fan_index); bool result = this->parent_->set_fan_index(fan_index); if (result) { - this->custom_fan_mode = fan_mode; - this->fan_mode.reset(); + this->set_custom_fan_mode_(fan_mode.c_str()); } } } @@ -245,7 +241,7 @@ void BedJetClimate::on_status(const BedjetStatusPacket *data) { const auto *fan_mode_name = bedjet_fan_step_to_fan_mode(data->fan_step); if (fan_mode_name != nullptr) { - this->custom_fan_mode = *fan_mode_name; + this->set_custom_fan_mode_(fan_mode_name->c_str()); } // TODO: Get biorhythm data to determine which preset (M1-3) is running, if any. @@ -255,7 +251,7 @@ void BedJetClimate::on_status(const BedjetStatusPacket *data) { this->mode = CLIMATE_MODE_OFF; this->action = CLIMATE_ACTION_IDLE; this->fan_mode = CLIMATE_FAN_OFF; - this->custom_preset.reset(); + this->clear_custom_preset_(); this->preset.reset(); break; @@ -266,7 +262,7 @@ void BedJetClimate::on_status(const BedjetStatusPacket *data) { if (this->heating_mode_ == HEAT_MODE_EXTENDED) { this->set_custom_preset_("LTD HT"); } else { - this->custom_preset.reset(); + this->clear_custom_preset_(); } break; @@ -275,7 +271,7 @@ void BedJetClimate::on_status(const BedjetStatusPacket *data) { this->action = CLIMATE_ACTION_HEATING; this->preset.reset(); if (this->heating_mode_ == HEAT_MODE_EXTENDED) { - this->custom_preset.reset(); + this->clear_custom_preset_(); } else { this->set_custom_preset_("EXT HT"); } @@ -284,20 +280,19 @@ void BedJetClimate::on_status(const BedjetStatusPacket *data) { case MODE_COOL: this->mode = CLIMATE_MODE_FAN_ONLY; this->action = CLIMATE_ACTION_COOLING; - this->custom_preset.reset(); + this->clear_custom_preset_(); this->preset.reset(); break; case MODE_DRY: this->mode = CLIMATE_MODE_DRY; this->action = CLIMATE_ACTION_DRYING; - this->custom_preset.reset(); + this->clear_custom_preset_(); this->preset.reset(); break; case MODE_TURBO: - this->preset = CLIMATE_PRESET_BOOST; - this->custom_preset.reset(); + this->set_preset_(CLIMATE_PRESET_BOOST); this->mode = CLIMATE_MODE_HEAT; this->action = CLIMATE_ACTION_HEATING; break; diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index 963f2e585a..05f4a849e0 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -43,28 +43,20 @@ class BedJetClimate : public climate::Climate, public BedJetClient, public Polli }); // It would be better if we had a slider for the fan modes. - traits.set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES_SET); + traits.set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES); traits.set_supported_presets({ // If we support NONE, then have to decide what happens if the user switches to it (turn off?) // climate::CLIMATE_PRESET_NONE, // Climate doesn't have a "TURBO" mode, but we can use the BOOST preset instead. climate::CLIMATE_PRESET_BOOST, }); + // String literals are stored in rodata and valid for program lifetime traits.set_supported_custom_presets({ - // We could fetch biodata from bedjet and set these names that way. - // But then we have to invert the lookup in order to send the right preset. - // For now, we can leave them as M1-3 to match the remote buttons. - // EXT HT added to match remote button. - "EXT HT", + this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT", "M1", "M2", "M3", }); - if (this->heating_mode_ == HEAT_MODE_EXTENDED) { - traits.add_supported_custom_preset("LTD HT"); - } else { - traits.add_supported_custom_preset("EXT HT"); - } traits.set_visual_min_temperature(19.0); traits.set_visual_max_temperature(43.0); traits.set_visual_temperature_step(1.0); diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 26e784a0b8..cbf935a501 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -155,6 +155,7 @@ DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter, cg.Compon InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter) AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter, cg.Component) LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter) +StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter) SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter, cg.Component) _LOGGER = getLogger(__name__) @@ -299,7 +300,7 @@ async def lambda_filter_to_code(config, filter_id): lambda_ = await cg.process_lambda( config, [(bool, "x")], return_type=cg.optional.template(bool) ) - return cg.new_Pvariable(filter_id, lambda_) + return automation.new_lambda_pvariable(filter_id, lambda_, StatelessLambdaFilter) @register_filter( @@ -547,11 +548,6 @@ def binary_sensor_schema( return _BINARY_SENSOR_SCHEMA.extend(schema) -# Remove before 2025.11.0 -BINARY_SENSOR_SCHEMA = binary_sensor_schema() -BINARY_SENSOR_SCHEMA.add_extra(cv.deprecated_schema_constant("binary_sensor")) - - async def setup_binary_sensor_core_(var, config): await setup_entity(var, config, "binary_sensor") diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index a7eb080feb..2d473c3b64 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -111,6 +111,21 @@ class LambdaFilter : public Filter { std::function(bool)> f_; }; +/** Optimized lambda filter for stateless lambdas (no capture). + * + * Uses function pointer instead of std::function to reduce memory overhead. + * Memory: 4 bytes (function pointer on 32-bit) vs 32 bytes (std::function). + */ +class StatelessLambdaFilter : public Filter { + public: + explicit StatelessLambdaFilter(optional (*f)(bool)) : f_(f) {} + + optional new_value(bool value) override { return this->f_(value); } + + protected: + optional (*f_)(bool); +}; + class SettleFilter : public Filter, public Component { public: optional new_value(bool value) override; diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index a5c661e2f5..55f1cb2f46 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -96,8 +96,11 @@ template class BLEClientWriteAction : public Action, publ BLEClientWriteAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; + this->construct_simple_value_(); } + ~BLEClientWriteAction() { this->destroy_simple_value_(); } + void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); } void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); } void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_raw(uuid); } @@ -106,14 +109,18 @@ template class BLEClientWriteAction : public Action, publ void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); } void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_raw(uuid); } - void set_value_template(std::function(Ts...)> func) { - this->value_template_ = std::move(func); - has_simple_value_ = false; + void set_value_template(std::vector (*func)(Ts...)) { + this->destroy_simple_value_(); + this->value_.template_func = func; + this->has_simple_value_ = false; } void set_value_simple(const std::vector &value) { - this->value_simple_ = value; - has_simple_value_ = true; + if (!this->has_simple_value_) { + this->construct_simple_value_(); + } + this->value_.simple = value; + this->has_simple_value_ = true; } void play(Ts... x) override {} @@ -121,7 +128,7 @@ template class BLEClientWriteAction : public Action, publ void play_complex(Ts... x) override { this->num_running_++; this->var_ = std::make_tuple(x...); - auto value = this->has_simple_value_ ? this->value_simple_ : this->value_template_(x...); + auto value = this->has_simple_value_ ? this->value_.simple : this->value_.template_func(x...); // on write failure, continue the automation chain rather than stopping so that e.g. disconnect can work. if (!write(value)) this->play_next_(x...); @@ -194,10 +201,22 @@ template class BLEClientWriteAction : public Action, publ } private: + void construct_simple_value_() { new (&this->value_.simple) std::vector(); } + + void destroy_simple_value_() { + if (this->has_simple_value_) { + this->value_.simple.~vector(); + } + } + BLEClient *ble_client_; bool has_simple_value_ = true; - std::vector value_simple_; - std::function(Ts...)> value_template_{}; + union Value { + std::vector simple; + std::vector (*template_func)(Ts...); + Value() {} // trivial constructor + ~Value() {} // trivial destructor - we manage lifetime via discriminator + } value_; espbt::ESPBTUUID service_uuid_; espbt::ESPBTUUID char_uuid_; std::tuple var_{}; @@ -213,9 +232,9 @@ template class BLEClientPasskeyReplyAction : public Actionvalue_simple_; + passkey = this->value_.simple; } else { - passkey = this->value_template_(x...); + passkey = this->value_.template_func(x...); } if (passkey > 999999) return; @@ -224,21 +243,23 @@ template class BLEClientPasskeyReplyAction : public Action func) { - this->value_template_ = std::move(func); - has_simple_value_ = false; + void set_value_template(uint32_t (*func)(Ts...)) { + this->value_.template_func = func; + this->has_simple_value_ = false; } void set_value_simple(const uint32_t &value) { - this->value_simple_ = value; - has_simple_value_ = true; + this->value_.simple = value; + this->has_simple_value_ = true; } private: BLEClient *parent_{nullptr}; bool has_simple_value_ = true; - uint32_t value_simple_{0}; - std::function value_template_{}; + union { + uint32_t simple; + uint32_t (*template_func)(Ts...); + } value_{.simple = 0}; }; template class BLEClientNumericComparisonReplyAction : public Action { @@ -249,27 +270,29 @@ template class BLEClientNumericComparisonReplyAction : public Ac esp_bd_addr_t remote_bda; memcpy(remote_bda, parent_->get_remote_bda(), sizeof(esp_bd_addr_t)); if (has_simple_value_) { - esp_ble_confirm_reply(remote_bda, this->value_simple_); + esp_ble_confirm_reply(remote_bda, this->value_.simple); } else { - esp_ble_confirm_reply(remote_bda, this->value_template_(x...)); + esp_ble_confirm_reply(remote_bda, this->value_.template_func(x...)); } } - void set_value_template(std::function func) { - this->value_template_ = std::move(func); - has_simple_value_ = false; + void set_value_template(bool (*func)(Ts...)) { + this->value_.template_func = func; + this->has_simple_value_ = false; } void set_value_simple(const bool &value) { - this->value_simple_ = value; - has_simple_value_ = true; + this->value_.simple = value; + this->has_simple_value_ = true; } private: BLEClient *parent_{nullptr}; bool has_simple_value_ = true; - bool value_simple_{false}; - std::function value_template_{}; + union { + bool simple; + bool (*template_func)(Ts...); + } value_{.simple = false}; }; template class BLEClientRemoveBondAction : public Action { diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index d0ccfe1f2e..6d293528c6 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -117,9 +117,9 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga } float BLESensor::parse_data_(uint8_t *value, uint16_t value_len) { - if (this->data_to_value_func_.has_value()) { + if (this->has_data_to_value_) { std::vector data(value, value + value_len); - return (*this->data_to_value_func_)(data); + return this->data_to_value_func_(data); } else { return value[0]; } diff --git a/esphome/components/ble_client/sensor/ble_sensor.h b/esphome/components/ble_client/sensor/ble_sensor.h index 24d1ed2fd2..c6335d5836 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.h +++ b/esphome/components/ble_client/sensor/ble_sensor.h @@ -15,8 +15,6 @@ namespace ble_client { namespace espbt = esphome::esp32_ble_tracker; -using data_to_value_t = std::function)>; - class BLESensor : public sensor::Sensor, public PollingComponent, public BLEClientNode { public: void loop() override; @@ -33,13 +31,17 @@ class BLESensor : public sensor::Sensor, public PollingComponent, public BLEClie void set_descr_uuid16(uint16_t uuid) { this->descr_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); } void set_descr_uuid32(uint32_t uuid) { this->descr_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); } void set_descr_uuid128(uint8_t *uuid) { this->descr_uuid_ = espbt::ESPBTUUID::from_raw(uuid); } - void set_data_to_value(data_to_value_t &&lambda) { this->data_to_value_func_ = lambda; } + void set_data_to_value(float (*lambda)(const std::vector &)) { + this->data_to_value_func_ = lambda; + this->has_data_to_value_ = true; + } void set_enable_notify(bool notify) { this->notify_ = notify; } uint16_t handle; protected: float parse_data_(uint8_t *value, uint16_t value_len); - optional data_to_value_func_{}; + bool has_data_to_value_{false}; + float (*data_to_value_func_)(const std::vector &){}; bool notify_; espbt::ESPBTUUID service_uuid_; espbt::ESPBTUUID char_uuid_; diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index e1ac875cb0..d2f143b97e 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -84,11 +84,6 @@ def button_schema( return _BUTTON_SCHEMA.extend(schema) -# Remove before 2025.11.0 -BUTTON_SCHEMA = button_schema(Button) -BUTTON_SCHEMA.add_extra(cv.deprecated_schema_constant("button")) - - async def setup_button_core_(var, config): await setup_entity(var, config, "button") diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index c0c33d7242..5824e68141 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -270,11 +270,6 @@ def climate_schema( return _CLIMATE_SCHEMA.extend(schema) -# Remove before 2025.11.0 -CLIMATE_SCHEMA = climate_schema(Climate) -CLIMATE_SCHEMA.add_extra(cv.deprecated_schema_constant("climate")) - - async def setup_climate_core_(var, config): await setup_entity(var, config, "climate") diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 19fe241729..e596582de8 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -50,21 +50,21 @@ void ClimateCall::perform() { const LogString *mode_s = climate_mode_to_string(*this->mode_); ESP_LOGD(TAG, " Mode: %s", LOG_STR_ARG(mode_s)); } - if (this->custom_fan_mode_.has_value()) { + if (this->custom_fan_mode_ != nullptr) { this->fan_mode_.reset(); - ESP_LOGD(TAG, " Custom Fan: %s", this->custom_fan_mode_.value().c_str()); + ESP_LOGD(TAG, " Custom Fan: %s", this->custom_fan_mode_); } if (this->fan_mode_.has_value()) { - this->custom_fan_mode_.reset(); + this->custom_fan_mode_ = nullptr; const LogString *fan_mode_s = climate_fan_mode_to_string(*this->fan_mode_); ESP_LOGD(TAG, " Fan: %s", LOG_STR_ARG(fan_mode_s)); } - if (this->custom_preset_.has_value()) { + if (this->custom_preset_ != nullptr) { this->preset_.reset(); - ESP_LOGD(TAG, " Custom Preset: %s", this->custom_preset_.value().c_str()); + ESP_LOGD(TAG, " Custom Preset: %s", this->custom_preset_); } if (this->preset_.has_value()) { - this->custom_preset_.reset(); + this->custom_preset_ = nullptr; const LogString *preset_s = climate_preset_to_string(*this->preset_); ESP_LOGD(TAG, " Preset: %s", LOG_STR_ARG(preset_s)); } @@ -96,11 +96,10 @@ void ClimateCall::validate_() { 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 not supported", custom_fan_mode.c_str()); - this->custom_fan_mode_.reset(); + if (this->custom_fan_mode_ != nullptr) { + if (!traits.supports_custom_fan_mode(this->custom_fan_mode_)) { + ESP_LOGW(TAG, " Fan Mode %s not supported", this->custom_fan_mode_); + this->custom_fan_mode_ = nullptr; } } else if (this->fan_mode_.has_value()) { auto fan_mode = *this->fan_mode_; @@ -109,11 +108,10 @@ void ClimateCall::validate_() { 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 not supported", custom_preset.c_str()); - this->custom_preset_.reset(); + if (this->custom_preset_ != nullptr) { + if (!traits.supports_custom_preset(this->custom_preset_)) { + ESP_LOGW(TAG, " Preset %s not supported", this->custom_preset_); + this->custom_preset_ = nullptr; } } else if (this->preset_.has_value()) { auto preset = *this->preset_; @@ -186,26 +184,29 @@ ClimateCall &ClimateCall::set_mode(const std::string &mode) { ClimateCall &ClimateCall::set_fan_mode(ClimateFanMode fan_mode) { this->fan_mode_ = fan_mode; - this->custom_fan_mode_.reset(); + this->custom_fan_mode_ = nullptr; return *this; } -ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) { +ClimateCall &ClimateCall::set_fan_mode(const char *custom_fan_mode) { + // Check if it's a standard enum mode first 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(mode_entry.value)); - return *this; + if (str_equals_case_insensitive(custom_fan_mode, mode_entry.str)) { + return this->set_fan_mode(static_cast(mode_entry.value)); } } - if (this->parent_->get_traits().supports_custom_fan_mode(fan_mode)) { - this->custom_fan_mode_ = fan_mode; + // Find the matching pointer from parent climate device + if (const char *mode_ptr = this->parent_->find_custom_fan_mode_(custom_fan_mode)) { + this->custom_fan_mode_ = mode_ptr; 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; } + ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %s", this->parent_->get_name().c_str(), custom_fan_mode); return *this; } +ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) { return this->set_fan_mode(fan_mode.c_str()); } + ClimateCall &ClimateCall::set_fan_mode(optional fan_mode) { if (fan_mode.has_value()) { this->set_fan_mode(fan_mode.value()); @@ -215,26 +216,29 @@ ClimateCall &ClimateCall::set_fan_mode(optional fan_mode) { ClimateCall &ClimateCall::set_preset(ClimatePreset preset) { this->preset_ = preset; - this->custom_preset_.reset(); + this->custom_preset_ = nullptr; return *this; } -ClimateCall &ClimateCall::set_preset(const std::string &preset) { +ClimateCall &ClimateCall::set_preset(const char *custom_preset) { + // Check if it's a standard enum preset first for (const auto &preset_entry : CLIMATE_PRESETS_BY_STR) { - if (str_equals_case_insensitive(preset, preset_entry.str)) { - this->set_preset(static_cast(preset_entry.value)); - return *this; + if (str_equals_case_insensitive(custom_preset, preset_entry.str)) { + return this->set_preset(static_cast(preset_entry.value)); } } - if (this->parent_->get_traits().supports_custom_preset(preset)) { - this->custom_preset_ = preset; + // Find the matching pointer from parent climate device + if (const char *preset_ptr = this->parent_->find_custom_preset_(custom_preset)) { + this->custom_preset_ = preset_ptr; this->preset_.reset(); - } else { - ESP_LOGW(TAG, "'%s' - Unrecognized preset %s", this->parent_->get_name().c_str(), preset.c_str()); + return *this; } + ESP_LOGW(TAG, "'%s' - Unrecognized preset %s", this->parent_->get_name().c_str(), custom_preset); return *this; } +ClimateCall &ClimateCall::set_preset(const std::string &preset) { return this->set_preset(preset.c_str()); } + ClimateCall &ClimateCall::set_preset(optional preset) { if (preset.has_value()) { this->set_preset(preset.value()); @@ -287,8 +291,14 @@ const optional &ClimateCall::get_mode() const { return this->mode_; const optional &ClimateCall::get_fan_mode() const { return this->fan_mode_; } const optional &ClimateCall::get_swing_mode() const { return this->swing_mode_; } const optional &ClimateCall::get_preset() const { return this->preset_; } -const optional &ClimateCall::get_custom_fan_mode() const { return this->custom_fan_mode_; } -const optional &ClimateCall::get_custom_preset() const { return this->custom_preset_; } + +optional ClimateCall::get_custom_fan_mode() const { + return this->custom_fan_mode_ != nullptr ? std::string(this->custom_fan_mode_) : optional{}; +} + +optional ClimateCall::get_custom_preset() const { + return this->custom_preset_ != nullptr ? std::string(this->custom_preset_) : optional{}; +} ClimateCall &ClimateCall::set_target_temperature_high(optional target_temperature_high) { this->target_temperature_high_ = target_temperature_high; @@ -317,13 +327,13 @@ ClimateCall &ClimateCall::set_mode(optional mode) { ClimateCall &ClimateCall::set_fan_mode(optional fan_mode) { this->fan_mode_ = fan_mode; - this->custom_fan_mode_.reset(); + this->custom_fan_mode_ = nullptr; return *this; } ClimateCall &ClimateCall::set_preset(optional preset) { this->preset_ = preset; - this->custom_preset_.reset(); + this->custom_preset_ = nullptr; return *this; } @@ -382,13 +392,13 @@ void Climate::save_state_() { state.uses_custom_fan_mode = false; state.fan_mode = this->fan_mode.value(); } - if (!traits.get_supported_custom_fan_modes().empty() && custom_fan_mode.has_value()) { + if (!traits.get_supported_custom_fan_modes().empty() && this->has_custom_fan_mode()) { state.uses_custom_fan_mode = true; const auto &supported = traits.get_supported_custom_fan_modes(); - // std::set has consistent order (lexicographic for strings) + // std::vector maintains insertion order size_t i = 0; - for (const auto &mode : supported) { - if (mode == custom_fan_mode) { + for (const char *mode : supported) { + if (strcmp(mode, this->custom_fan_mode_) == 0) { state.custom_fan_mode = i; break; } @@ -399,13 +409,13 @@ void Climate::save_state_() { state.uses_custom_preset = false; state.preset = this->preset.value(); } - if (!traits.get_supported_custom_presets().empty() && custom_preset.has_value()) { + if (!traits.get_supported_custom_presets().empty() && this->has_custom_preset()) { state.uses_custom_preset = true; const auto &supported = traits.get_supported_custom_presets(); - // std::set has consistent order (lexicographic for strings) + // std::vector maintains insertion order size_t i = 0; - for (const auto &preset : supported) { - if (preset == custom_preset) { + for (const char *preset : supported) { + if (strcmp(preset, this->custom_preset_) == 0) { state.custom_preset = i; break; } @@ -430,14 +440,14 @@ void Climate::publish_state() { if (traits.get_supports_fan_modes() && this->fan_mode.has_value()) { ESP_LOGD(TAG, " Fan Mode: %s", LOG_STR_ARG(climate_fan_mode_to_string(this->fan_mode.value()))); } - if (!traits.get_supported_custom_fan_modes().empty() && this->custom_fan_mode.has_value()) { - ESP_LOGD(TAG, " Custom Fan Mode: %s", this->custom_fan_mode.value().c_str()); + if (!traits.get_supported_custom_fan_modes().empty() && this->has_custom_fan_mode()) { + ESP_LOGD(TAG, " Custom Fan Mode: %s", this->custom_fan_mode_); } if (traits.get_supports_presets() && this->preset.has_value()) { ESP_LOGD(TAG, " Preset: %s", LOG_STR_ARG(climate_preset_to_string(this->preset.value()))); } - if (!traits.get_supported_custom_presets().empty() && this->custom_preset.has_value()) { - ESP_LOGD(TAG, " Custom Preset: %s", this->custom_preset.value().c_str()); + if (!traits.get_supported_custom_presets().empty() && this->has_custom_preset()) { + ESP_LOGD(TAG, " Custom Preset: %s", this->custom_preset_); } if (traits.get_supports_swing_modes()) { ESP_LOGD(TAG, " Swing Mode: %s", LOG_STR_ARG(climate_swing_mode_to_string(this->swing_mode))); @@ -527,7 +537,7 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { if (this->uses_custom_fan_mode) { if (this->custom_fan_mode < traits.get_supported_custom_fan_modes().size()) { call.fan_mode_.reset(); - call.custom_fan_mode_ = *std::next(traits.get_supported_custom_fan_modes().cbegin(), this->custom_fan_mode); + call.custom_fan_mode_ = traits.get_supported_custom_fan_modes()[this->custom_fan_mode]; } } else if (traits.supports_fan_mode(this->fan_mode)) { call.set_fan_mode(this->fan_mode); @@ -535,7 +545,7 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { if (this->uses_custom_preset) { if (this->custom_preset < traits.get_supported_custom_presets().size()) { call.preset_.reset(); - call.custom_preset_ = *std::next(traits.get_supported_custom_presets().cbegin(), this->custom_preset); + call.custom_preset_ = traits.get_supported_custom_presets()[this->custom_preset]; } } else if (traits.supports_preset(this->preset)) { call.set_preset(this->preset); @@ -562,20 +572,20 @@ void ClimateDeviceRestoreState::apply(Climate *climate) { if (this->uses_custom_fan_mode) { if (this->custom_fan_mode < traits.get_supported_custom_fan_modes().size()) { climate->fan_mode.reset(); - climate->custom_fan_mode = *std::next(traits.get_supported_custom_fan_modes().cbegin(), this->custom_fan_mode); + climate->custom_fan_mode_ = traits.get_supported_custom_fan_modes()[this->custom_fan_mode]; } } else if (traits.supports_fan_mode(this->fan_mode)) { climate->fan_mode = this->fan_mode; - climate->custom_fan_mode.reset(); + climate->clear_custom_fan_mode_(); } if (this->uses_custom_preset) { if (this->custom_preset < traits.get_supported_custom_presets().size()) { climate->preset.reset(); - climate->custom_preset = *std::next(traits.get_supported_custom_presets().cbegin(), this->custom_preset); + climate->custom_preset_ = traits.get_supported_custom_presets()[this->custom_preset]; } } else if (traits.supports_preset(this->preset)) { climate->preset = this->preset; - climate->custom_preset.reset(); + climate->clear_custom_preset_(); } if (traits.supports_swing_mode(this->swing_mode)) { climate->swing_mode = this->swing_mode; @@ -583,28 +593,107 @@ void ClimateDeviceRestoreState::apply(Climate *climate) { climate->publish_state(); } -template bool set_alternative(optional &dst, optional &alt, const T1 &src) { - bool is_changed = alt.has_value(); - alt.reset(); - if (is_changed || dst != src) { - dst = src; - is_changed = true; +/** Template helper for setting primary modes (fan_mode, preset) with mutual exclusion. + * + * Climate devices have mutually exclusive mode pairs: + * - fan_mode (enum) vs custom_fan_mode_ (const char*) + * - preset (enum) vs custom_preset_ (const char*) + * + * Only one mode in each pair can be active at a time. This helper ensures setting a primary + * mode automatically clears its corresponding custom mode. + * + * Example state transitions: + * Before: custom_fan_mode_="Turbo", fan_mode=nullopt + * Call: set_fan_mode_(CLIMATE_FAN_HIGH) + * After: custom_fan_mode_=nullptr, fan_mode=CLIMATE_FAN_HIGH + * + * @param primary The primary mode optional (fan_mode or preset) + * @param custom_ptr Reference to the custom mode pointer (custom_fan_mode_ or custom_preset_) + * @param value The new primary mode value to set + * @return true if state changed, false if already set to this value + */ +template bool set_primary_mode(optional &primary, const char *&custom_ptr, T value) { + // Clear the custom mode (mutual exclusion) + bool changed = custom_ptr != nullptr; + custom_ptr = nullptr; + // Set the primary mode + if (changed || !primary.has_value() || primary.value() != value) { + primary = value; + return true; } - return is_changed; + return false; +} + +/** Template helper for setting custom modes (custom_fan_mode_, custom_preset_) with mutual exclusion. + * + * This helper ensures setting a custom mode automatically clears its corresponding primary mode. + * It also validates that the custom mode exists in the device's supported modes (lifetime safety). + * + * Example state transitions: + * Before: fan_mode=CLIMATE_FAN_HIGH, custom_fan_mode_=nullptr + * Call: set_custom_fan_mode_("Turbo") + * After: fan_mode=nullopt, custom_fan_mode_="Turbo" (pointer from traits) + * + * Lifetime Safety: + * - found_ptr must come from traits.find_custom_*_mode_() + * - Only pointers found in traits are stored, ensuring they remain valid + * - Prevents dangling pointers from temporary strings + * + * @param custom_ptr Reference to the custom mode pointer to set + * @param primary The primary mode optional to clear + * @param found_ptr The validated pointer from traits (nullptr if not found) + * @param has_custom Whether a custom mode is currently active + * @return true if state changed, false otherwise + */ +template +bool set_custom_mode(const char *&custom_ptr, optional &primary, const char *found_ptr, bool has_custom) { + if (found_ptr != nullptr) { + // Clear the primary mode (mutual exclusion) + bool changed = primary.has_value(); + primary.reset(); + // Set the custom mode (pointer is validated by caller from traits) + if (changed || custom_ptr != found_ptr) { + custom_ptr = found_ptr; + return true; + } + return false; + } + // Mode not found in supported modes, clear it if currently set + if (has_custom) { + custom_ptr = nullptr; + return true; + } + return false; } bool Climate::set_fan_mode_(ClimateFanMode mode) { - return set_alternative(this->fan_mode, this->custom_fan_mode, mode); + return set_primary_mode(this->fan_mode, this->custom_fan_mode_, mode); } -bool Climate::set_custom_fan_mode_(const std::string &mode) { - return set_alternative(this->custom_fan_mode, this->fan_mode, mode); +bool Climate::set_custom_fan_mode_(const char *mode) { + auto traits = this->get_traits(); + return set_custom_mode(this->custom_fan_mode_, this->fan_mode, traits.find_custom_fan_mode_(mode), + this->has_custom_fan_mode()); } -bool Climate::set_preset_(ClimatePreset preset) { return set_alternative(this->preset, this->custom_preset, preset); } +void Climate::clear_custom_fan_mode_() { this->custom_fan_mode_ = nullptr; } -bool Climate::set_custom_preset_(const std::string &preset) { - return set_alternative(this->custom_preset, this->preset, preset); +bool Climate::set_preset_(ClimatePreset preset) { return set_primary_mode(this->preset, this->custom_preset_, preset); } + +bool Climate::set_custom_preset_(const char *preset) { + auto traits = this->get_traits(); + return set_custom_mode(this->custom_preset_, this->preset, traits.find_custom_preset_(preset), + this->has_custom_preset()); +} + +void Climate::clear_custom_preset_() { this->custom_preset_ = nullptr; } + +const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode) { + return this->get_traits().find_custom_fan_mode_(custom_fan_mode); +} + +const char *Climate::find_custom_preset_(const char *custom_preset) { + return this->get_traits().find_custom_preset_(custom_preset); } void Climate::dump_traits_(const char *tag) { @@ -656,8 +745,8 @@ void Climate::dump_traits_(const char *tag) { } if (!traits.get_supported_custom_fan_modes().empty()) { 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()); + for (const char *s : traits.get_supported_custom_fan_modes()) + ESP_LOGCONFIG(tag, " - %s", s); } if (!traits.get_supported_presets().empty()) { ESP_LOGCONFIG(tag, " Supported presets:"); @@ -666,8 +755,8 @@ void Climate::dump_traits_(const char *tag) { } if (!traits.get_supported_custom_presets().empty()) { ESP_LOGCONFIG(tag, " Supported custom presets:"); - for (const std::string &s : traits.get_supported_custom_presets()) - ESP_LOGCONFIG(tag, " - %s", s.c_str()); + for (const char *s : traits.get_supported_custom_presets()) + ESP_LOGCONFIG(tag, " - %s", s); } if (!traits.get_supported_swing_modes().empty()) { ESP_LOGCONFIG(tag, " Supported swing modes:"); diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 0c3e3ebe16..091483a033 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -77,6 +77,8 @@ class ClimateCall { ClimateCall &set_fan_mode(const std::string &fan_mode); /// Set the fan mode of the climate device based on a string. ClimateCall &set_fan_mode(optional fan_mode); + /// Set the custom fan mode of the climate device. + ClimateCall &set_fan_mode(const char *custom_fan_mode); /// Set the swing mode of the climate device. ClimateCall &set_swing_mode(ClimateSwingMode swing_mode); /// Set the swing mode of the climate device. @@ -91,6 +93,8 @@ class ClimateCall { ClimateCall &set_preset(const std::string &preset); /// Set the preset of the climate device based on a string. ClimateCall &set_preset(optional preset); + /// Set the custom preset of the climate device. + ClimateCall &set_preset(const char *custom_preset); void perform(); @@ -103,8 +107,8 @@ class ClimateCall { const optional &get_fan_mode() const; const optional &get_swing_mode() const; const optional &get_preset() const; - const optional &get_custom_fan_mode() const; - const optional &get_custom_preset() const; + optional get_custom_fan_mode() const; + optional get_custom_preset() const; protected: void validate_(); @@ -118,8 +122,8 @@ class ClimateCall { optional fan_mode_; optional swing_mode_; optional preset_; - optional custom_fan_mode_; - optional custom_preset_; + const char *custom_fan_mode_{nullptr}; + const char *custom_preset_{nullptr}; }; /// Struct used to save the state of the climate device in restore memory. @@ -212,6 +216,12 @@ 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); + /// Check if a custom fan mode is currently active. + bool has_custom_fan_mode() const { return this->custom_fan_mode_ != nullptr; } + + /// Check if a custom preset is currently active. + bool has_custom_preset() const { return this->custom_preset_ != nullptr; } + /// The current temperature of the climate device, as reported from the integration. float current_temperature{NAN}; @@ -238,12 +248,6 @@ class Climate : public EntityBase { /// The active preset of the climate device. optional preset; - /// The active custom fan mode of the climate device. - optional custom_fan_mode; - - /// The active custom preset mode of the climate device. - optional custom_preset; - /// The active mode of the climate device. ClimateMode mode{CLIMATE_MODE_OFF}; @@ -253,20 +257,37 @@ class Climate : public EntityBase { /// The active swing mode of the climate device. ClimateSwingMode swing_mode{CLIMATE_SWING_OFF}; + /// Get the active custom fan mode (read-only access). + const char *get_custom_fan_mode() const { return this->custom_fan_mode_; } + + /// Get the active custom preset (read-only access). + const char *get_custom_preset() const { return this->custom_preset_; } + protected: friend ClimateCall; + friend struct ClimateDeviceRestoreState; /// Set fan mode. Reset custom fan mode. Return true if fan mode has been changed. bool set_fan_mode_(ClimateFanMode mode); /// Set custom fan mode. Reset primary fan mode. Return true if fan mode has been changed. - bool set_custom_fan_mode_(const std::string &mode); + bool set_custom_fan_mode_(const char *mode); + /// Clear custom fan mode. + void clear_custom_fan_mode_(); /// Set preset. Reset custom preset. Return true if preset has been changed. bool set_preset_(ClimatePreset preset); /// Set custom preset. Reset primary preset. Return true if preset has been changed. - bool set_custom_preset_(const std::string &preset); + bool set_custom_preset_(const char *preset); + /// Clear custom preset. + void clear_custom_preset_(); + + /// Find and return the matching custom fan mode pointer from traits, or nullptr if not found. + const char *find_custom_fan_mode_(const char *custom_fan_mode); + + /// Find and return the matching custom preset pointer from traits, or nullptr if not found. + const char *find_custom_preset_(const char *custom_preset); /** Get the default traits of this climate device. * @@ -303,6 +324,21 @@ class Climate : public EntityBase { optional visual_current_temperature_step_override_{}; optional visual_min_humidity_override_{}; optional visual_max_humidity_override_{}; + + private: + /** The active custom fan mode (private - enforces use of safe setters). + * + * Points to an entry in traits.supported_custom_fan_modes_ or nullptr. + * Use get_custom_fan_mode() to read, set_custom_fan_mode_() to modify. + */ + const char *custom_fan_mode_{nullptr}; + + /** The active custom preset (private - enforces use of safe setters). + * + * Points to an entry in traits.supported_custom_presets_ or nullptr. + * Use get_custom_preset() to read, set_custom_preset_() to modify. + */ + const char *custom_preset_{nullptr}; }; } // namespace climate diff --git a/esphome/components/climate/climate_mode.h b/esphome/components/climate/climate_mode.h index faec5d2537..44423d2f22 100644 --- a/esphome/components/climate/climate_mode.h +++ b/esphome/components/climate/climate_mode.h @@ -7,6 +7,7 @@ namespace esphome { namespace climate { /// Enum for all modes a climate device can be in. +/// NOTE: If adding values, update ClimateModeMask in climate_traits.h to use the new last value enum ClimateMode : uint8_t { /// The climate device is off CLIMATE_MODE_OFF = 0, @@ -24,7 +25,7 @@ enum ClimateMode : uint8_t { * For example, the target temperature can be adjusted based on a schedule, or learned behavior. * The target temperature can't be adjusted when in this mode. */ - CLIMATE_MODE_AUTO = 6 + CLIMATE_MODE_AUTO = 6 // Update ClimateModeMask in climate_traits.h if adding values after this }; /// Enum for the current action of the climate device. Values match those of ClimateMode. @@ -43,6 +44,7 @@ enum ClimateAction : uint8_t { CLIMATE_ACTION_FAN = 6, }; +/// NOTE: If adding values, update ClimateFanModeMask in climate_traits.h to use the new last value enum ClimateFanMode : uint8_t { /// The fan mode is set to On CLIMATE_FAN_ON = 0, @@ -63,10 +65,11 @@ enum ClimateFanMode : uint8_t { /// The fan mode is set to Diffuse CLIMATE_FAN_DIFFUSE = 8, /// The fan mode is set to Quiet - CLIMATE_FAN_QUIET = 9, + CLIMATE_FAN_QUIET = 9, // Update ClimateFanModeMask in climate_traits.h if adding values after this }; /// Enum for all modes a climate swing can be in +/// NOTE: If adding values, update ClimateSwingModeMask in climate_traits.h to use the new last value enum ClimateSwingMode : uint8_t { /// The swing mode is set to Off CLIMATE_SWING_OFF = 0, @@ -75,10 +78,11 @@ enum ClimateSwingMode : uint8_t { /// The fan mode is set to Vertical CLIMATE_SWING_VERTICAL = 2, /// The fan mode is set to Horizontal - CLIMATE_SWING_HORIZONTAL = 3, + CLIMATE_SWING_HORIZONTAL = 3, // Update ClimateSwingModeMask in climate_traits.h if adding values after this }; /// Enum for all preset modes +/// NOTE: If adding values, update ClimatePresetMask in climate_traits.h to use the new last value enum ClimatePreset : uint8_t { /// No preset is active CLIMATE_PRESET_NONE = 0, @@ -95,7 +99,7 @@ enum ClimatePreset : uint8_t { /// Device is prepared for sleep CLIMATE_PRESET_SLEEP = 6, /// Device is reacting to activity (e.g., movement sensors) - CLIMATE_PRESET_ACTIVITY = 7, + CLIMATE_PRESET_ACTIVITY = 7, // Update ClimatePresetMask in climate_traits.h if adding values after this }; enum ClimateFeature : uint32_t { diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 2962a147d7..0eecf9789f 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -1,19 +1,43 @@ #pragma once -#include +#include +#include #include "climate_mode.h" +#include "esphome/core/finite_set_mask.h" #include "esphome/core/helpers.h" namespace esphome { - -#ifdef USE_API -namespace api { -class APIConnection; -} // namespace api -#endif - namespace climate { +// Type aliases for climate enum bitmasks +// These replace std::set to eliminate red-black tree overhead +// For contiguous enums starting at 0, DefaultBitPolicy provides 1:1 mapping (enum value = bit position) +// Bitmask size is automatically calculated from the last enum value +using ClimateModeMask = FiniteSetMask>; +using ClimateFanModeMask = FiniteSetMask>; +using ClimateSwingModeMask = + FiniteSetMask>; +using ClimatePresetMask = FiniteSetMask>; + +// Lightweight linear search for small vectors (1-20 items) of const char* pointers +// Avoids std::find template overhead +inline bool vector_contains(const std::vector &vec, const char *value) { + for (const char *item : vec) { + if (strcmp(item, value) == 0) + return true; + } + return false; +} + +// Find and return matching pointer from vector, or nullptr if not found +inline const char *vector_find(const std::vector &vec, const char *value) { + for (const char *item : vec) { + if (strcmp(item, value) == 0) + return item; + } + return nullptr; +} + /** This class contains all static data for climate devices. * * All climate devices must support these features: @@ -41,7 +65,11 @@ namespace climate { * - temperature step - the step with which to increase/decrease target temperature. * This also affects with how many decimal places the temperature is shown */ +class Climate; // Forward declaration + class ClimateTraits { + friend class Climate; // Allow Climate to access protected find methods + public: /// Get/set feature flags (see ClimateFeatures enum in climate_mode.h) uint32_t get_feature_flags() const { return this->feature_flags_; } @@ -107,48 +135,74 @@ class ClimateTraits { } } - void set_supported_modes(std::set modes) { this->supported_modes_ = std::move(modes); } + void set_supported_modes(ClimateModeMask modes) { this->supported_modes_ = modes; } void add_supported_mode(ClimateMode mode) { this->supported_modes_.insert(mode); } bool supports_mode(ClimateMode mode) const { return this->supported_modes_.count(mode); } - const std::set &get_supported_modes() const { return this->supported_modes_; } + const ClimateModeMask &get_supported_modes() const { return this->supported_modes_; } - void set_supported_fan_modes(std::set modes) { this->supported_fan_modes_ = std::move(modes); } + void set_supported_fan_modes(ClimateFanModeMask modes) { this->supported_fan_modes_ = modes; } void add_supported_fan_mode(ClimateFanMode mode) { this->supported_fan_modes_.insert(mode); } - void add_supported_custom_fan_mode(const std::string &mode) { this->supported_custom_fan_modes_.insert(mode); } bool supports_fan_mode(ClimateFanMode fan_mode) const { return this->supported_fan_modes_.count(fan_mode); } bool get_supports_fan_modes() const { return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty(); } - const std::set &get_supported_fan_modes() const { return this->supported_fan_modes_; } + const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_fan_modes_; } - void set_supported_custom_fan_modes(std::set supported_custom_fan_modes) { - this->supported_custom_fan_modes_ = std::move(supported_custom_fan_modes); + void set_supported_custom_fan_modes(std::initializer_list modes) { + this->supported_custom_fan_modes_ = modes; + } + void set_supported_custom_fan_modes(const std::vector &modes) { + this->supported_custom_fan_modes_ = modes; + } + template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { + this->supported_custom_fan_modes_.assign(modes, modes + N); + } + + // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages + void set_supported_custom_fan_modes(const std::vector &modes) = delete; + void set_supported_custom_fan_modes(std::initializer_list modes) = delete; + + const std::vector &get_supported_custom_fan_modes() const { return this->supported_custom_fan_modes_; } + bool supports_custom_fan_mode(const char *custom_fan_mode) const { + return vector_contains(this->supported_custom_fan_modes_, custom_fan_mode); } - const std::set &get_supported_custom_fan_modes() const { return this->supported_custom_fan_modes_; } bool supports_custom_fan_mode(const std::string &custom_fan_mode) const { - return this->supported_custom_fan_modes_.count(custom_fan_mode); + return this->supports_custom_fan_mode(custom_fan_mode.c_str()); } - void set_supported_presets(std::set presets) { this->supported_presets_ = std::move(presets); } + void set_supported_presets(ClimatePresetMask presets) { this->supported_presets_ = presets; } void add_supported_preset(ClimatePreset preset) { this->supported_presets_.insert(preset); } - void add_supported_custom_preset(const std::string &preset) { this->supported_custom_presets_.insert(preset); } bool supports_preset(ClimatePreset preset) const { return this->supported_presets_.count(preset); } bool get_supports_presets() const { return !this->supported_presets_.empty(); } - const std::set &get_supported_presets() const { return this->supported_presets_; } + const ClimatePresetMask &get_supported_presets() const { return this->supported_presets_; } - void set_supported_custom_presets(std::set supported_custom_presets) { - this->supported_custom_presets_ = std::move(supported_custom_presets); + void set_supported_custom_presets(std::initializer_list presets) { + this->supported_custom_presets_ = presets; + } + void set_supported_custom_presets(const std::vector &presets) { + this->supported_custom_presets_ = presets; + } + template void set_supported_custom_presets(const char *const (&presets)[N]) { + this->supported_custom_presets_.assign(presets, presets + N); + } + + // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages + void set_supported_custom_presets(const std::vector &presets) = delete; + void set_supported_custom_presets(std::initializer_list presets) = delete; + + const std::vector &get_supported_custom_presets() const { return this->supported_custom_presets_; } + bool supports_custom_preset(const char *custom_preset) const { + return vector_contains(this->supported_custom_presets_, custom_preset); } - const std::set &get_supported_custom_presets() const { return this->supported_custom_presets_; } bool supports_custom_preset(const std::string &custom_preset) const { - return this->supported_custom_presets_.count(custom_preset); + return this->supports_custom_preset(custom_preset.c_str()); } - void set_supported_swing_modes(std::set modes) { this->supported_swing_modes_ = std::move(modes); } + void set_supported_swing_modes(ClimateSwingModeMask modes) { this->supported_swing_modes_ = modes; } void add_supported_swing_mode(ClimateSwingMode mode) { this->supported_swing_modes_.insert(mode); } bool supports_swing_mode(ClimateSwingMode swing_mode) const { return this->supported_swing_modes_.count(swing_mode); } bool get_supports_swing_modes() const { return !this->supported_swing_modes_.empty(); } - const std::set &get_supported_swing_modes() const { return this->supported_swing_modes_; } + const ClimateSwingModeMask &get_supported_swing_modes() const { return this->supported_swing_modes_; } float get_visual_min_temperature() const { return this->visual_min_temperature_; } void set_visual_min_temperature(float visual_min_temperature) { @@ -179,23 +233,6 @@ class ClimateTraits { void set_visual_max_humidity(float visual_max_humidity) { this->visual_max_humidity_ = visual_max_humidity; } protected: -#ifdef USE_API - // The API connection is a friend class to access internal methods - friend class api::APIConnection; - // These methods return references to internal data structures. - // They are used by the API to avoid copying data when encoding messages. - // Warning: Do not use these methods outside of the API connection code. - // They return references to internal data that can be invalidated. - const std::set &get_supported_modes_for_api_() const { return this->supported_modes_; } - const std::set &get_supported_fan_modes_for_api_() const { return this->supported_fan_modes_; } - const std::set &get_supported_custom_fan_modes_for_api_() const { - return this->supported_custom_fan_modes_; - } - const std::set &get_supported_presets_for_api_() const { return this->supported_presets_; } - const std::set &get_supported_custom_presets_for_api_() const { return this->supported_custom_presets_; } - const std::set &get_supported_swing_modes_for_api_() const { return this->supported_swing_modes_; } -#endif - void set_mode_support_(climate::ClimateMode mode, bool supported) { if (supported) { this->supported_modes_.insert(mode); @@ -218,6 +255,18 @@ class ClimateTraits { } } + /// Find and return the matching custom fan mode pointer from supported modes, or nullptr if not found + /// This is protected as it's an implementation detail - use Climate::find_custom_fan_mode_() instead + const char *find_custom_fan_mode_(const char *custom_fan_mode) const { + return vector_find(this->supported_custom_fan_modes_, custom_fan_mode); + } + + /// Find and return the matching custom preset pointer from supported presets, or nullptr if not found + /// This is protected as it's an implementation detail - use Climate::find_custom_preset_() instead + const char *find_custom_preset_(const char *custom_preset) const { + return vector_find(this->supported_custom_presets_, custom_preset); + } + uint32_t feature_flags_{0}; float visual_min_temperature_{10}; float visual_max_temperature_{30}; @@ -226,12 +275,21 @@ class ClimateTraits { float visual_min_humidity_{30}; float visual_max_humidity_{99}; - std::set supported_modes_ = {climate::CLIMATE_MODE_OFF}; - std::set supported_fan_modes_; - std::set supported_swing_modes_; - std::set supported_presets_; - std::set supported_custom_fan_modes_; - std::set supported_custom_presets_; + climate::ClimateModeMask supported_modes_{climate::CLIMATE_MODE_OFF}; + climate::ClimateFanModeMask supported_fan_modes_; + climate::ClimateSwingModeMask supported_swing_modes_; + climate::ClimatePresetMask supported_presets_; + + /** Custom mode storage using const char* pointers to eliminate std::string overhead. + * + * Pointers must remain valid for the ClimateTraits lifetime. Safe patterns: + * - String literals: set_supported_custom_fan_modes({"Turbo", "Silent"}) + * - Static const data: static const char* MODE = "Eco"; + * + * Climate class setters validate pointers are from these vectors before storing. + */ + std::vector supported_custom_fan_modes_; + std::vector supported_custom_presets_; }; } // namespace climate diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index 312b2ad900..6d66abf4cd 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -1,10 +1,9 @@ import logging -from esphome import core import esphome.codegen as cg from esphome.components import climate, remote_base, sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SENSOR, CONF_SUPPORTS_COOL, CONF_SUPPORTS_HEAT +from esphome.const import CONF_SENSOR, CONF_SUPPORTS_COOL, CONF_SUPPORTS_HEAT from esphome.cpp_generator import MockObjClass _LOGGER = logging.getLogger(__name__) @@ -52,26 +51,6 @@ def climate_ir_with_receiver_schema( ) -# Remove before 2025.11.0 -def deprecated_schema_constant(config): - type: str = "unknown" - if (id := config.get(CONF_ID)) is not None and isinstance(id, core.ID): - type = str(id.type).split("::", maxsplit=1)[0] - _LOGGER.warning( - "Using `climate_ir.CLIMATE_IR_WITH_RECEIVER_SCHEMA` is deprecated and will be removed in ESPHome 2025.11.0. " - "Please use `climate_ir.climate_ir_with_receiver_schema(...)` instead. " - "If you are seeing this, report an issue to the external_component author and ask them to update it. " - "https://developers.esphome.io/blog/2025/05/14/_schema-deprecations/. " - "Component using this schema: %s", - type, - ) - return config - - -CLIMATE_IR_WITH_RECEIVER_SCHEMA = climate_ir_with_receiver_schema(ClimateIR) -CLIMATE_IR_WITH_RECEIVER_SCHEMA.add_extra(deprecated_schema_constant) - - async def register_climate_ir(var, config): await cg.register_component(var, config) await remote_base.register_transmittable(var, config) diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index ea0656121f..62a43f0b2d 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -24,16 +24,18 @@ class ClimateIR : public Component, public remote_base::RemoteTransmittable { public: ClimateIR(float minimum_temperature, float maximum_temperature, float temperature_step = 1.0f, - bool supports_dry = false, bool supports_fan_only = false, std::set fan_modes = {}, - std::set swing_modes = {}, std::set presets = {}) { + bool supports_dry = false, bool supports_fan_only = false, + climate::ClimateFanModeMask fan_modes = climate::ClimateFanModeMask(), + climate::ClimateSwingModeMask swing_modes = climate::ClimateSwingModeMask(), + climate::ClimatePresetMask presets = climate::ClimatePresetMask()) { this->minimum_temperature_ = minimum_temperature; this->maximum_temperature_ = maximum_temperature; this->temperature_step_ = temperature_step; this->supports_dry_ = supports_dry; this->supports_fan_only_ = supports_fan_only; - this->fan_modes_ = std::move(fan_modes); - this->swing_modes_ = std::move(swing_modes); - this->presets_ = std::move(presets); + this->fan_modes_ = fan_modes; + this->swing_modes_ = swing_modes; + this->presets_ = presets; } void setup() override; @@ -60,9 +62,9 @@ class ClimateIR : public Component, bool supports_heat_{true}; bool supports_dry_{false}; bool supports_fan_only_{false}; - std::set fan_modes_ = {}; - std::set swing_modes_ = {}; - std::set presets_ = {}; + climate::ClimateFanModeMask fan_modes_{}; + climate::ClimateSwingModeMask swing_modes_{}; + climate::ClimatePresetMask presets_{}; sensor::Sensor *sensor_{nullptr}; }; diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index 15a7f5e025..d35ece950b 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -12,7 +12,7 @@ void CopyFan::setup() { this->oscillating = source_->oscillating; this->speed = source_->speed; this->direction = source_->direction; - this->preset_mode = source_->preset_mode; + this->set_preset_mode_(source_->get_preset_mode()); this->publish_state(); }); @@ -20,7 +20,7 @@ void CopyFan::setup() { this->oscillating = source_->oscillating; this->speed = source_->speed; this->direction = source_->direction; - this->preset_mode = source_->preset_mode; + this->set_preset_mode_(source_->get_preset_mode()); this->publish_state(); } @@ -49,7 +49,7 @@ void CopyFan::control(const fan::FanCall &call) { call2.set_speed(*call.get_speed()); if (call.get_direction().has_value()) call2.set_direction(*call.get_direction()); - if (!call.get_preset_mode().empty()) + if (call.has_preset_mode()) call2.set_preset_mode(call.get_preset_mode()); call2.perform(); } diff --git a/esphome/components/copy/select/copy_select.cpp b/esphome/components/copy/select/copy_select.cpp index bdcbd0b42c..e45338e785 100644 --- a/esphome/components/copy/select/copy_select.cpp +++ b/esphome/components/copy/select/copy_select.cpp @@ -7,19 +7,19 @@ namespace copy { static const char *const TAG = "copy.select"; void CopySelect::setup() { - source_->add_on_state_callback([this](const std::string &value, size_t index) { this->publish_state(value); }); + source_->add_on_state_callback([this](const std::string &value, size_t index) { this->publish_state(index); }); traits.set_options(source_->traits.get_options()); if (source_->has_state()) - this->publish_state(source_->state); + this->publish_state(source_->active_index().value()); } void CopySelect::dump_config() { LOG_SELECT("", "Copy Select", this); } -void CopySelect::control(const std::string &value) { +void CopySelect::control(size_t index) { auto call = source_->make_call(); - call.set_option(value); + call.set_index(index); call.perform(); } diff --git a/esphome/components/copy/select/copy_select.h b/esphome/components/copy/select/copy_select.h index fb0aee86f6..bd74a93e82 100644 --- a/esphome/components/copy/select/copy_select.h +++ b/esphome/components/copy/select/copy_select.h @@ -13,7 +13,7 @@ class CopySelect : public select::Select, public Component { void dump_config() override; protected: - void control(const std::string &value) override; + void control(size_t index) override; select::Select *source_; }; diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index bec6dcbdac..383daee083 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -151,11 +151,6 @@ def cover_schema( return _COVER_SCHEMA.extend(schema) -# Remove before 2025.11.0 -COVER_SCHEMA = cover_schema(Cover) -COVER_SCHEMA.add_extra(cv.deprecated_schema_constant("cover")) - - async def setup_cover_core_(var, config): await setup_entity(var, config, "cover") diff --git a/esphome/components/demo/demo_climate.h b/esphome/components/demo/demo_climate.h index 84b16e7ec5..f8944b0735 100644 --- a/esphome/components/demo/demo_climate.h +++ b/esphome/components/demo/demo_climate.h @@ -28,16 +28,16 @@ class DemoClimate : public climate::Climate, public Component { this->mode = climate::CLIMATE_MODE_AUTO; this->action = climate::CLIMATE_ACTION_COOLING; this->fan_mode = climate::CLIMATE_FAN_HIGH; - this->custom_preset = {"My Preset"}; + this->set_custom_preset_("My Preset"); break; case DemoClimateType::TYPE_3: this->current_temperature = 21.5; this->target_temperature_low = 21.0; this->target_temperature_high = 22.5; this->mode = climate::CLIMATE_MODE_HEAT_COOL; - this->custom_fan_mode = {"Auto Low"}; + this->set_custom_fan_mode_("Auto Low"); this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; - this->preset = climate::CLIMATE_PRESET_AWAY; + this->set_preset_(climate::CLIMATE_PRESET_AWAY); break; } this->publish_state(); @@ -58,23 +58,19 @@ class DemoClimate : public climate::Climate, public Component { this->target_temperature_high = *call.get_target_temperature_high(); } if (call.get_fan_mode().has_value()) { - this->fan_mode = *call.get_fan_mode(); - this->custom_fan_mode.reset(); + this->set_fan_mode_(*call.get_fan_mode()); } if (call.get_swing_mode().has_value()) { this->swing_mode = *call.get_swing_mode(); } if (call.get_custom_fan_mode().has_value()) { - this->custom_fan_mode = *call.get_custom_fan_mode(); - this->fan_mode.reset(); + this->set_custom_fan_mode_(call.get_custom_fan_mode()->c_str()); } if (call.get_preset().has_value()) { - this->preset = *call.get_preset(); - this->custom_preset.reset(); + this->set_preset_(*call.get_preset()); } if (call.get_custom_preset().has_value()) { - this->custom_preset = *call.get_custom_preset(); - this->preset.reset(); + this->set_custom_preset_(call.get_custom_preset()->c_str()); } this->publish_state(); } diff --git a/esphome/components/display_menu_base/menu_item.cpp b/esphome/components/display_menu_base/menu_item.cpp index 2c7f34c493..8224adf3fe 100644 --- a/esphome/components/display_menu_base/menu_item.cpp +++ b/esphome/components/display_menu_base/menu_item.cpp @@ -42,7 +42,7 @@ std::string MenuItemSelect::get_value_text() const { result = this->value_getter_.value()(this); } else { if (this->select_var_ != nullptr) { - result = this->select_var_->state; + result = this->select_var_->current_option(); } } diff --git a/esphome/components/e131/e131.cpp b/esphome/components/e131/e131.cpp index d18d945cec..c10c88faf2 100644 --- a/esphome/components/e131/e131.cpp +++ b/esphome/components/e131/e131.cpp @@ -3,6 +3,8 @@ #include "e131_addressable_light_effect.h" #include "esphome/core/log.h" +#include + namespace esphome { namespace e131 { @@ -76,14 +78,14 @@ void E131Component::loop() { } void E131Component::add_effect(E131AddressableLightEffect *light_effect) { - if (light_effects_.count(light_effect)) { + if (std::find(light_effects_.begin(), light_effects_.end(), light_effect) != light_effects_.end()) { return; } ESP_LOGD(TAG, "Registering '%s' for universes %d-%d.", light_effect->get_name(), light_effect->get_first_universe(), light_effect->get_last_universe()); - light_effects_.insert(light_effect); + light_effects_.push_back(light_effect); for (auto universe = light_effect->get_first_universe(); universe <= light_effect->get_last_universe(); ++universe) { join_(universe); @@ -91,14 +93,17 @@ void E131Component::add_effect(E131AddressableLightEffect *light_effect) { } void E131Component::remove_effect(E131AddressableLightEffect *light_effect) { - if (!light_effects_.count(light_effect)) { + auto it = std::find(light_effects_.begin(), light_effects_.end(), light_effect); + if (it == light_effects_.end()) { return; } ESP_LOGD(TAG, "Unregistering '%s' for universes %d-%d.", light_effect->get_name(), light_effect->get_first_universe(), light_effect->get_last_universe()); - light_effects_.erase(light_effect); + // Swap with last element and pop for O(1) removal (order doesn't matter) + *it = light_effects_.back(); + light_effects_.pop_back(); for (auto universe = light_effect->get_first_universe(); universe <= light_effect->get_last_universe(); ++universe) { leave_(universe); diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index d0e38fa98c..831138a545 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -7,7 +7,6 @@ #include #include #include -#include #include namespace esphome { @@ -47,9 +46,8 @@ class E131Component : public esphome::Component { E131ListenMethod listen_method_{E131_MULTICAST}; std::unique_ptr socket_; - std::set light_effects_; + std::vector light_effects_; std::map universe_consumers_; - std::map universe_packets_; }; } // namespace e131 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 48d11f46fa..ddb8dbb1f0 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -304,9 +304,13 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: def _format_framework_espidf_version(ver: cv.Version, release: str) -> str: # format the given espidf (https://github.com/pioarduino/esp-idf/releases) version to # a PIO platformio/framework-espidf value + if ver == cv.Version(5, 4, 3) or ver >= cv.Version(5, 5, 1): + ext = "tar.xz" + else: + ext = "zip" if release: - return f"pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v{str(ver)}.{release}/esp-idf-v{str(ver)}.zip" - return f"pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v{str(ver)}/esp-idf-v{str(ver)}.zip" + return f"pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v{str(ver)}.{release}/esp-idf-v{str(ver)}.{ext}" + return f"pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v{str(ver)}/esp-idf-v{str(ver)}.{ext}" def _is_framework_url(source: str) -> str: @@ -355,6 +359,7 @@ ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version(5, 5, 1): cv.Version(55, 3, 31, "1"), cv.Version(5, 5, 0): cv.Version(55, 3, 31, "1"), + cv.Version(5, 4, 3): cv.Version(55, 3, 32), cv.Version(5, 4, 2): cv.Version(54, 3, 21, "2"), cv.Version(5, 4, 1): cv.Version(54, 3, 21, "2"), cv.Version(5, 4, 0): cv.Version(54, 3, 21, "2"), diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 3427c96e70..1c8f29fa95 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -96,7 +96,11 @@ void loop_task(void *pv_params) { extern "C" void app_main() { esp32::setup_preferences(); +#if CONFIG_FREERTOS_UNICORE xTaskCreate(loop_task, "loopTask", 8192, nullptr, 1, &loop_task_handle); +#else + xTaskCreatePinnedToCore(loop_task, "loopTask", 8192, nullptr, 1, &loop_task_handle, 1); +#endif } #endif // USE_ESP_IDF diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index 565e276ea8..d30f4bdcba 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -40,13 +40,13 @@ class ESP32InternalGPIOPin : public InternalGPIOPin { // - 3 bytes for members below // - 1 byte padding for alignment // - 4 bytes for vtable pointer - uint8_t pin_; // GPIO pin number (0-255, actual max ~54 on ESP32) - gpio::Flags flags_; // GPIO flags (1 byte) + uint8_t pin_; // GPIO pin number (0-255, actual max ~54 on ESP32) + gpio::Flags flags_{}; // GPIO flags (1 byte) struct PinFlags { uint8_t inverted : 1; // Invert pin logic (1 bit) uint8_t drive_strength : 2; // Drive strength 0-3 (2 bits) uint8_t reserved : 5; // Reserved for future use (5 bits) - } pin_flags_; // Total: 1 byte + } pin_flags_{}; // Total: 1 byte // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static bool isr_service_installed; }; diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 513f463d57..954891ea8d 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -223,7 +223,10 @@ async def esp32_pin_to_code(config): var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}"))) - cg.add(var.set_inverted(config[CONF_INVERTED])) + # Only set if true to avoid bloating setup() function + # (inverted bit in pin_flags_ bitfield is zero-initialized to false) + if config[CONF_INVERTED]: + cg.add(var.set_inverted(True)) if CONF_DRIVE_STRENGTH in config: cg.add(var.set_drive_strength(config[CONF_DRIVE_STRENGTH])) cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 411c2add71..1ae8df6f5e 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -7,6 +7,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.components import socket from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant import esphome.config_validation as cv from esphome.const import ( @@ -481,6 +482,14 @@ async def to_code(config): cg.add(var.set_name(name)) await cg.register_component(var, config) + # BLE uses 1 UDP socket for event notification to wake up main loop from select() + # This enables low-latency (~12μs) BLE event processing instead of waiting for + # select() timeout (0-16ms). The socket is created in ble_setup_() and used to + # wake lwip_select() when BLE events arrive from the BLE thread. + # Note: Called during config generation, socket is created at runtime. In practice, + # always used since esp32_ble only runs on ESP32 which always has USE_SOCKET_SELECT_SUPPORT. + socket.consume_sockets(1, "esp32_ble")(config) + # Define max connections for use in C++ code (e.g., ble_server.h) max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index b66acb38ad..b503d4377c 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -27,10 +27,34 @@ extern "C" { #include #endif +#ifdef USE_SOCKET_SELECT_SUPPORT +#include +#endif + namespace esphome::esp32_ble { static const char *const TAG = "esp32_ble"; +// GAP event groups for deduplication across gap_event_handler and dispatch_gap_event_ +#define GAP_SCAN_COMPLETE_EVENTS \ + case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: \ + case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT: \ + case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT + +#define GAP_ADV_COMPLETE_EVENTS \ + case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: \ + case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: \ + case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: \ + case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: \ + case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT + +#define GAP_SECURITY_EVENTS \ + case ESP_GAP_BLE_AUTH_CMPL_EVT: \ + case ESP_GAP_BLE_SEC_REQ_EVT: \ + case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: \ + case ESP_GAP_BLE_PASSKEY_REQ_EVT: \ + case ESP_GAP_BLE_NC_REQ_EVT + void ESP32BLE::setup() { global_ble = this; if (!ble_pre_setup_()) { @@ -277,10 +301,21 @@ bool ESP32BLE::ble_setup_() { // BLE takes some time to be fully set up, 200ms should be more than enough delay(200); // NOLINT + // Set up notification socket to wake main loop for BLE events + // This enables low-latency (~12μs) event processing instead of waiting for select() timeout +#ifdef USE_SOCKET_SELECT_SUPPORT + this->setup_event_notification_(); +#endif + return true; } bool ESP32BLE::ble_dismantle_() { + // Clean up notification socket first before dismantling BLE stack +#ifdef USE_SOCKET_SELECT_SUPPORT + this->cleanup_event_notification_(); +#endif + esp_err_t err = esp_bluedroid_disable(); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err); @@ -378,6 +413,12 @@ void ESP32BLE::loop() { break; } +#ifdef USE_SOCKET_SELECT_SUPPORT + // Drain any notification socket events first + // This clears the socket so it doesn't stay "ready" in subsequent select() calls + this->drain_event_notifications_(); +#endif + BLEEvent *ble_event = this->ble_events_.pop(); while (ble_event != nullptr) { switch (ble_event->type_) { @@ -418,60 +459,48 @@ void ESP32BLE::loop() { break; // Scan complete events - case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: - case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT: - case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT: - // All three scan complete events have the same structure with just status - // The scan_complete struct matches ESP-IDF's layout exactly, so this reinterpret_cast is safe - // This is verified at compile-time by static_assert checks in ble_event.h - // The struct already contains our copy of the status (copied in BLEEvent constructor) - ESP_LOGV(TAG, "gap_event_handler - %d", gap_event); -#ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT - for (auto *gap_handler : this->gap_event_handlers_) { - gap_handler->gap_event_handler( - gap_event, reinterpret_cast(&ble_event->event_.gap.scan_complete)); - } -#endif - break; - + GAP_SCAN_COMPLETE_EVENTS: // Advertising complete events - case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: - case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: - case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: - case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: - case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: - // All advertising complete events have the same structure with just status - ESP_LOGV(TAG, "gap_event_handler - %d", gap_event); -#ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT - for (auto *gap_handler : this->gap_event_handlers_) { - gap_handler->gap_event_handler( - gap_event, reinterpret_cast(&ble_event->event_.gap.adv_complete)); - } -#endif - break; - + GAP_ADV_COMPLETE_EVENTS: // RSSI complete event case ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT: - ESP_LOGV(TAG, "gap_event_handler - %d", gap_event); -#ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT - for (auto *gap_handler : this->gap_event_handlers_) { - gap_handler->gap_event_handler( - gap_event, reinterpret_cast(&ble_event->event_.gap.read_rssi_complete)); - } -#endif - break; - // Security events - case ESP_GAP_BLE_AUTH_CMPL_EVT: - case ESP_GAP_BLE_SEC_REQ_EVT: - case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: - case ESP_GAP_BLE_PASSKEY_REQ_EVT: - case ESP_GAP_BLE_NC_REQ_EVT: + GAP_SECURITY_EVENTS: ESP_LOGV(TAG, "gap_event_handler - %d", gap_event); #ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT - for (auto *gap_handler : this->gap_event_handlers_) { - gap_handler->gap_event_handler( - gap_event, reinterpret_cast(&ble_event->event_.gap.security)); + { + esp_ble_gap_cb_param_t *param; + // clang-format off + switch (gap_event) { + // All three scan complete events have the same structure with just status + // The scan_complete struct matches ESP-IDF's layout exactly, so this reinterpret_cast is safe + // This is verified at compile-time by static_assert checks in ble_event.h + // The struct already contains our copy of the status (copied in BLEEvent constructor) + GAP_SCAN_COMPLETE_EVENTS: + param = reinterpret_cast(&ble_event->event_.gap.scan_complete); + break; + + // All advertising complete events have the same structure with just status + GAP_ADV_COMPLETE_EVENTS: + param = reinterpret_cast(&ble_event->event_.gap.adv_complete); + break; + + case ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT: + param = reinterpret_cast(&ble_event->event_.gap.read_rssi_complete); + break; + + GAP_SECURITY_EVENTS: + param = reinterpret_cast(&ble_event->event_.gap.security); + break; + + default: + break; + } + // clang-format on + // Dispatch to all registered handlers + for (auto *gap_handler : this->gap_event_handlers_) { + gap_handler->gap_event_handler(gap_event, param); + } } #endif break; @@ -551,23 +580,13 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa // Queue GAP events that components need to handle // Scanning events - used by esp32_ble_tracker case ESP_GAP_BLE_SCAN_RESULT_EVT: - case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: - case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT: - case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT: + GAP_SCAN_COMPLETE_EVENTS: // Advertising events - used by esp32_ble_beacon and esp32_ble server - case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: - case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: - case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: - case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: - case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: + GAP_ADV_COMPLETE_EVENTS: // Connection events - used by ble_client case ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT: // Security events - used by ble_client and bluetooth_proxy - case ESP_GAP_BLE_AUTH_CMPL_EVT: - case ESP_GAP_BLE_SEC_REQ_EVT: - case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: - case ESP_GAP_BLE_PASSKEY_REQ_EVT: - case ESP_GAP_BLE_NC_REQ_EVT: + GAP_SECURITY_EVENTS: enqueue_ble_event(event, param); return; @@ -588,6 +607,10 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) { enqueue_ble_event(event, gatts_if, param); + // Wake up main loop to process GATT event immediately +#ifdef USE_SOCKET_SELECT_SUPPORT + global_ble->notify_main_loop_(); +#endif } #endif @@ -595,6 +618,10 @@ void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gat void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { enqueue_ble_event(event, gattc_if, param); + // Wake up main loop to process GATT event immediately +#ifdef USE_SOCKET_SELECT_SUPPORT + global_ble->notify_main_loop_(); +#endif } #endif @@ -634,6 +661,89 @@ void ESP32BLE::dump_config() { } } +#ifdef USE_SOCKET_SELECT_SUPPORT +void ESP32BLE::setup_event_notification_() { + // Create UDP socket for event notifications + this->notify_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (this->notify_fd_ < 0) { + ESP_LOGW(TAG, "Event socket create failed: %d", errno); + return; + } + + // Bind to loopback with auto-assigned port + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = lwip_htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // Auto-assign port + + if (lwip_bind(this->notify_fd_, (struct sockaddr *) &addr, sizeof(addr)) < 0) { + ESP_LOGW(TAG, "Event socket bind failed: %d", errno); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + return; + } + + // Get the assigned address and connect to it + // Connecting a UDP socket allows using send() instead of sendto() for better performance + struct sockaddr_in notify_addr; + socklen_t len = sizeof(notify_addr); + if (lwip_getsockname(this->notify_fd_, (struct sockaddr *) ¬ify_addr, &len) < 0) { + ESP_LOGW(TAG, "Event socket address failed: %d", errno); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + return; + } + + // Connect to self (loopback) - allows using send() instead of sendto() + // After connect(), no need to store notify_addr - the socket remembers it + if (lwip_connect(this->notify_fd_, (struct sockaddr *) ¬ify_addr, sizeof(notify_addr)) < 0) { + ESP_LOGW(TAG, "Event socket connect failed: %d", errno); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + return; + } + + // Set non-blocking mode + int flags = lwip_fcntl(this->notify_fd_, F_GETFL, 0); + lwip_fcntl(this->notify_fd_, F_SETFL, flags | O_NONBLOCK); + + // Register with application's select() loop + if (!App.register_socket_fd(this->notify_fd_)) { + ESP_LOGW(TAG, "Event socket register failed"); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + return; + } + + ESP_LOGD(TAG, "Event socket ready"); +} + +void ESP32BLE::cleanup_event_notification_() { + if (this->notify_fd_ >= 0) { + App.unregister_socket_fd(this->notify_fd_); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + ESP_LOGD(TAG, "Event socket closed"); + } +} + +void ESP32BLE::drain_event_notifications_() { + // Called from main loop to drain any pending notifications + // Must check is_socket_ready() to avoid blocking on empty socket + if (this->notify_fd_ >= 0 && App.is_socket_ready(this->notify_fd_)) { + char buffer[BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads + // Multiple BLE events may have triggered multiple writes, so drain until EWOULDBLOCK + // We control both ends of this loopback socket (always write 1 byte per event), + // so no error checking needed - any errors indicate catastrophic system failure + while (lwip_recvfrom(this->notify_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + // Just draining, no action needed - actual BLE events are already queued + } + } +} + +#endif // USE_SOCKET_SELECT_SUPPORT + uint64_t ble_addr_to_uint64(const esp_bd_addr_t address) { uint64_t u = 0; u |= uint64_t(address[0] & 0xFF) << 40; diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 03dd147bb2..e77453a020 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -25,6 +25,10 @@ #include #include +#ifdef USE_SOCKET_SELECT_SUPPORT +#include +#endif + namespace esphome::esp32_ble { // Maximum size of the BLE event queue @@ -163,6 +167,13 @@ class ESP32BLE : public Component { void advertising_init_(); #endif +#ifdef USE_SOCKET_SELECT_SUPPORT + void setup_event_notification_(); // Create notification socket + void cleanup_event_notification_(); // Close and unregister socket + inline void notify_main_loop_(); // Wake up select() from BLE thread (hot path - inlined) + void drain_event_notifications_(); // Read pending notifications in main loop +#endif + private: template friend void enqueue_ble_event(Args... args); @@ -197,6 +208,13 @@ class ESP32BLE : public Component { esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum) uint32_t advertising_cycle_time_{}; // 4 bytes +#ifdef USE_SOCKET_SELECT_SUPPORT + // Event notification socket for waking up main loop from BLE thread + // Uses connected UDP loopback socket to wake lwip_select() with ~12μs latency vs 0-16ms timeout + // Socket is connected during setup, allowing use of send() instead of sendto() for efficiency + int notify_fd_{-1}; // 4 bytes (file descriptor) +#endif + // 2-byte aligned members uint16_t appearance_{0}; // 2 bytes @@ -208,6 +226,29 @@ class ESP32BLE : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern ESP32BLE *global_ble; +#ifdef USE_SOCKET_SELECT_SUPPORT +// Inline implementations for hot-path functions +// These are called from BLE thread (notify) and main loop (drain) on every event + +// Small buffer for draining notification bytes (1 byte sent per BLE event) +// Size allows draining multiple notifications per recvfrom() without wasting stack +static constexpr size_t BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE = 16; + +inline void ESP32BLE::notify_main_loop_() { + // Called from BLE thread context when events are queued + // Wakes up lwip_select() in main loop by writing to connected loopback socket + if (this->notify_fd_ >= 0) { + const char dummy = 1; + // Non-blocking send - if it fails (unlikely), select() will wake on timeout anyway + // No error checking needed: we control both ends of this loopback socket, and the + // BLE event is already queued. Notification is best-effort to reduce latency. + // This is safe to call from BLE thread - send() is thread-safe in lwip + // Socket is already connected to loopback address, so send() is faster than sendto() + lwip_send(this->notify_fd_, &dummy, 1, 0); + } +} +#endif // USE_SOCKET_SELECT_SUPPORT + template class BLEEnabledCondition : public Condition { public: bool check(Ts... x) override { return global_ble->is_active(); } diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 55310f3275..a7e2522fac 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -461,7 +461,9 @@ async def parse_value(value_config, args): if isinstance(value, str): value = list(value.encode(value_config[CONF_STRING_ENCODING])) if isinstance(value, list): - return cg.std_vector.template(cg.uint8)(value) + # Generate initializer list {1, 2, 3} instead of std::vector({1, 2, 3}) + # This calls the set_value(std::initializer_list) overload + return cg.ArrayInitializer(*value) val = cg.RawExpression(f"{value_config[CONF_TYPE]}({cg.safe_exp(value)})") return ByteBuffer_ns.wrap(val, value_config[CONF_ENDIANNESS]) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 87f562a250..7627a58338 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -35,13 +35,18 @@ BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) void BLECharacteristic::set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } -void BLECharacteristic::set_value(const std::vector &buffer) { +void BLECharacteristic::set_value(std::vector &&buffer) { xSemaphoreTake(this->set_value_lock_, 0L); - this->value_ = buffer; + this->value_ = std::move(buffer); xSemaphoreGive(this->set_value_lock_); } + +void BLECharacteristic::set_value(std::initializer_list data) { + this->set_value(std::vector(data)); // Delegate to move overload +} + void BLECharacteristic::set_value(const std::string &buffer) { - this->set_value(std::vector(buffer.begin(), buffer.end())); + this->set_value(std::vector(buffer.begin(), buffer.end())); // Delegate to move overload } void BLECharacteristic::notify() { diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 7cceec0ef1..b913915789 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -33,7 +33,8 @@ class BLECharacteristic { ~BLECharacteristic(); void set_value(ByteBuffer buffer); - void set_value(const std::vector &buffer); + void set_value(std::vector &&buffer); + void set_value(std::initializer_list data); void set_value(const std::string &buffer); void set_broadcast_property(bool value); diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 16941cca0f..2d053c09bd 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -46,15 +46,17 @@ void BLEDescriptor::do_create(BLECharacteristic *characteristic) { this->state_ = CREATING; } -void BLEDescriptor::set_value(std::vector buffer) { - size_t length = buffer.size(); +void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value_impl_(buffer.data(), buffer.size()); } +void BLEDescriptor::set_value(std::initializer_list data) { this->set_value_impl_(data.begin(), data.size()); } + +void BLEDescriptor::set_value_impl_(const uint8_t *data, size_t length) { if (length > this->value_.attr_max_len) { ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", length, this->value_.attr_max_len); return; } this->value_.attr_len = length; - memcpy(this->value_.attr_value, buffer.data(), length); + memcpy(this->value_.attr_value, data, length); } void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 425462a316..5f4f146d6f 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -27,7 +27,8 @@ class BLEDescriptor { void do_create(BLECharacteristic *characteristic); ESPBTUUID get_uuid() const { return this->uuid_; } - void set_value(std::vector buffer); + void set_value(std::vector &&buffer); + void set_value(std::initializer_list data); void set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param); @@ -42,6 +43,8 @@ class BLEDescriptor { } protected: + void set_value_impl_(const uint8_t *data, size_t length); + BLECharacteristic *characteristic_{nullptr}; ESPBTUUID uuid_; uint16_t handle_{0xFFFF}; diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 56436b9d3d..2fa9d8f523 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -270,8 +270,8 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { } } -void ESP32ImprovComponent::send_response_(std::vector &response) { - this->rpc_response_->set_value(ByteBuffer::wrap(response)); +void ESP32ImprovComponent::send_response_(std::vector &&response) { + this->rpc_response_->set_value(std::move(response)); if (this->state_ != improv::STATE_STOPPED) this->rpc_response_->notify(); } @@ -409,10 +409,8 @@ void ESP32ImprovComponent::check_wifi_connection_() { } } #endif - // Pass to build_rpc_response using vector constructor from iterators to avoid extra copies - std::vector data = improv::build_rpc_response( - improv::WIFI_SETTINGS, std::vector(url_strings, url_strings + url_count)); - this->send_response_(data); + this->send_response_(improv::build_rpc_response(improv::WIFI_SETTINGS, + std::vector(url_strings, url_strings + url_count))); } else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) { ESP_LOGD(TAG, "WiFi provisioned externally"); } diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index fd3b2b861d..989552ea56 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -109,7 +109,7 @@ class ESP32ImprovComponent : public Component, public improv_base::ImprovBase { void set_state_(improv::State state, bool update_advertising = true); void set_error_(improv::Error error); improv::State get_initial_state_() const; - void send_response_(std::vector &response); + void send_response_(std::vector &&response); void process_incoming_data_(); void on_wifi_connect_timeout_(); void check_wifi_connection_(); diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index dd6407885e..a1b6d79b3b 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -29,8 +29,8 @@ class ESP8266GPIOPin : public InternalGPIOPin { void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + bool inverted_{}; + gpio::Flags flags_{}; }; } // namespace esp8266 diff --git a/esphome/components/esp8266/gpio.py b/esphome/components/esp8266/gpio.py index e7492fc505..2e8d6496bc 100644 --- a/esphome/components/esp8266/gpio.py +++ b/esphome/components/esp8266/gpio.py @@ -165,7 +165,10 @@ async def esp8266_pin_to_code(config): num = config[CONF_NUMBER] mode = config[CONF_MODE] cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) + # Only set if true to avoid bloating setup() function + # (inverted bit in pin_flags_ bitfield is zero-initialized to false) + if config[CONF_INVERTED]: + cg.add(var.set_inverted(True)) cg.add(var.set_flags(pins.gpio_flags_expr(mode))) if num < 16: initial_state: PinInitialState = CORE.data[KEY_ESP8266][KEY_PIN_INITIAL_STATES][ diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 569268ea15..b85d660272 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -281,19 +281,15 @@ void ESPHomeOTAComponent::handle_data_() { #endif // Acknowledge auth OK - 1 byte - buf[0] = ota::OTA_RESPONSE_AUTH_OK; - this->writeall_(buf, 1); + this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { this->log_read_error_(LOG_STR("size")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - ota_size = 0; - for (uint8_t i = 0; i < 4; i++) { - ota_size <<= 8; - ota_size |= buf[i]; - } + ota_size = (static_cast(buf[0]) << 24) | (static_cast(buf[1]) << 16) | + (static_cast(buf[2]) << 8) | buf[3]; ESP_LOGV(TAG, "Size is %u bytes", ota_size); // Now that we've passed authentication and are actually @@ -313,8 +309,7 @@ void ESPHomeOTAComponent::handle_data_() { update_started = true; // Acknowledge prepare OK - 1 byte - buf[0] = ota::OTA_RESPONSE_UPDATE_PREPARE_OK; - this->writeall_(buf, 1); + this->write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK); // Read binary MD5, 32 bytes if (!this->readall_(buf, 32)) { @@ -326,8 +321,7 @@ void ESPHomeOTAComponent::handle_data_() { this->backend_->set_update_md5(sbuf); // Acknowledge MD5 OK - 1 byte - buf[0] = ota::OTA_RESPONSE_BIN_MD5_OK; - this->writeall_(buf, 1); + this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); while (total < ota_size) { // TODO: timeout check @@ -354,8 +348,7 @@ void ESPHomeOTAComponent::handle_data_() { total += read; #if USE_OTA_VERSION == 2 while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) { - buf[0] = ota::OTA_RESPONSE_CHUNK_OK; - this->writeall_(buf, 1); + this->write_byte_(ota::OTA_RESPONSE_CHUNK_OK); size_acknowledged += OTA_BLOCK_SIZE; } #endif @@ -374,8 +367,7 @@ void ESPHomeOTAComponent::handle_data_() { } // Acknowledge receive OK - 1 byte - buf[0] = ota::OTA_RESPONSE_RECEIVE_OK; - this->writeall_(buf, 1); + this->write_byte_(ota::OTA_RESPONSE_RECEIVE_OK); error_code = this->backend_->end(); if (error_code != ota::OTA_RESPONSE_OK) { @@ -384,8 +376,7 @@ void ESPHomeOTAComponent::handle_data_() { } // Acknowledge Update end OK - 1 byte - buf[0] = ota::OTA_RESPONSE_UPDATE_END_OK; - this->writeall_(buf, 1); + this->write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK); // Read ACK if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { @@ -404,8 +395,7 @@ void ESPHomeOTAComponent::handle_data_() { App.safe_reboot(); error: - buf[0] = static_cast(error_code); - this->writeall_(buf, 1); + this->write_byte_(static_cast(error_code)); this->cleanup_connection_(); if (this->backend_ != nullptr && update_started) { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index d4a8410d35..057461e6a4 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -53,6 +53,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { #endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); + inline bool write_byte_(uint8_t byte) { return this->writeall_(&byte, 1); } bool try_read_(size_t to_read, const LogString *desc); bool try_write_(size_t to_write, const LogString *desc); diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 77f70a3630..2f02d227d7 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -14,7 +14,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) -from esphome.components.network import IPAddress +from esphome.components.network import ip_address_literal from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface import esphome.config_validation as cv from esphome.const import ( @@ -320,11 +320,11 @@ def _final_validate_spi(config): def manual_ip(config): return cg.StructInitializer( ManualIP, - ("static_ip", IPAddress(str(config[CONF_STATIC_IP]))), - ("gateway", IPAddress(str(config[CONF_GATEWAY]))), - ("subnet", IPAddress(str(config[CONF_SUBNET]))), - ("dns1", IPAddress(str(config[CONF_DNS1]))), - ("dns2", IPAddress(str(config[CONF_DNS2]))), + ("static_ip", ip_address_literal(config[CONF_STATIC_IP])), + ("gateway", ip_address_literal(config[CONF_GATEWAY])), + ("subnet", ip_address_literal(config[CONF_SUBNET])), + ("dns1", ip_address_literal(config[CONF_DNS1])), + ("dns2", ip_address_literal(config[CONF_DNS2])), ) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 449cc48625..e2b69ba872 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -85,11 +85,6 @@ def event_schema( return _EVENT_SCHEMA.extend(schema) -# Remove before 2025.11.0 -EVENT_SCHEMA = event_schema() -EVENT_SCHEMA.add_extra(cv.deprecated_schema_constant("event")) - - async def setup_event_core_(var, config, *, event_types: list[str]): await setup_entity(var, config, "event") diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 245c9f04b4..35a351e8f1 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -189,10 +189,6 @@ def fan_schema( return _FAN_SCHEMA.extend(schema) -# Remove before 2025.11.0 -FAN_SCHEMA = fan_schema(Fan) -FAN_SCHEMA.add_extra(cv.deprecated_schema_constant("fan")) - _PRESET_MODES_SCHEMA = cv.All( cv.ensure_list(cv.string_strict), cv.Length(min=1), diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 90661c307c..48de8d66fb 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -212,18 +212,18 @@ class FanPresetSetTrigger : public Trigger { public: FanPresetSetTrigger(Fan *state) { state->add_on_state_callback([this, state]() { - auto preset_mode = state->preset_mode; + const auto *preset_mode = state->get_preset_mode(); auto should_trigger = preset_mode != this->last_preset_mode_; this->last_preset_mode_ = preset_mode; - if (should_trigger) { + if (should_trigger && preset_mode != nullptr) { this->trigger(preset_mode); } }); - this->last_preset_mode_ = state->preset_mode; + this->last_preset_mode_ = state->get_preset_mode(); } protected: - std::string last_preset_mode_; + const char *last_preset_mode_{nullptr}; }; } // namespace fan diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index cf1ec3d6ae..cfc09f4d53 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -17,6 +17,27 @@ const LogString *fan_direction_to_string(FanDirection direction) { } } +FanCall &FanCall::set_preset_mode(const std::string &preset_mode) { return this->set_preset_mode(preset_mode.c_str()); } + +FanCall &FanCall::set_preset_mode(const char *preset_mode) { + if (preset_mode == nullptr || strlen(preset_mode) == 0) { + this->preset_mode_ = nullptr; + return *this; + } + + // Find and validate pointer from traits immediately + auto traits = this->parent_.get_traits(); + const char *validated_mode = traits.find_preset_mode(preset_mode); + if (validated_mode != nullptr) { + this->preset_mode_ = validated_mode; // Store pointer from traits + } else { + // Preset mode not found in traits - log warning and don't set + ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), preset_mode); + this->preset_mode_ = nullptr; + } + return *this; +} + void FanCall::perform() { ESP_LOGD(TAG, "'%s' - Setting:", this->parent_.get_name().c_str()); this->validate_(); @@ -32,8 +53,8 @@ void FanCall::perform() { if (this->direction_.has_value()) { ESP_LOGD(TAG, " Direction: %s", LOG_STR_ARG(fan_direction_to_string(*this->direction_))); } - if (!this->preset_mode_.empty()) { - ESP_LOGD(TAG, " Preset Mode: %s", this->preset_mode_.c_str()); + if (this->has_preset_mode()) { + ESP_LOGD(TAG, " Preset Mode: %s", this->preset_mode_); } this->parent_.control(*this); } @@ -46,30 +67,15 @@ void FanCall::validate_() { // https://developers.home-assistant.io/docs/core/entity/fan/#preset-modes // "Manually setting a speed must disable any set preset mode" - this->preset_mode_.clear(); - } - - if (!this->preset_mode_.empty()) { - const auto &preset_modes = traits.supported_preset_modes(); - bool found = false; - for (const auto &mode : preset_modes) { - if (mode == this->preset_mode_) { - found = true; - break; - } - } - if (!found) { - ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), this->preset_mode_.c_str()); - this->preset_mode_.clear(); - } + this->preset_mode_ = nullptr; } // when turning on... if (!this->parent_.state && this->binary_state_.has_value() && *this->binary_state_ // ..,and no preset mode will be active... - && this->preset_mode_.empty() && - this->parent_.preset_mode.empty() + && !this->has_preset_mode() && + this->parent_.get_preset_mode() == nullptr // ...and neither current nor new speed is available... && traits.supports_speed() && this->parent_.speed == 0 && !this->speed_.has_value()) { // ...set speed to 100% @@ -99,11 +105,12 @@ FanCall FanRestoreState::to_call(Fan &fan) { call.set_speed(this->speed); call.set_direction(this->direction); - if (fan.get_traits().supports_preset_modes()) { + auto traits = fan.get_traits(); + if (traits.supports_preset_modes()) { // Use stored preset index to get preset name - const auto &preset_modes = fan.get_traits().supported_preset_modes(); + const auto &preset_modes = traits.supported_preset_modes(); if (this->preset_mode < preset_modes.size()) { - call.set_preset_mode(*std::next(preset_modes.begin(), this->preset_mode)); + call.set_preset_mode(preset_modes[this->preset_mode]); } } return call; @@ -114,13 +121,12 @@ void FanRestoreState::apply(Fan &fan) { fan.speed = this->speed; fan.direction = this->direction; - if (fan.get_traits().supports_preset_modes()) { - // Use stored preset index to get preset name - const auto &preset_modes = fan.get_traits().supported_preset_modes(); - if (this->preset_mode < preset_modes.size()) { - fan.preset_mode = *std::next(preset_modes.begin(), this->preset_mode); - } + // Use stored preset index to get preset name from traits + const auto &preset_modes = fan.get_traits().supported_preset_modes(); + if (this->preset_mode < preset_modes.size()) { + fan.set_preset_mode_(preset_modes[this->preset_mode]); } + fan.publish_state(); } @@ -129,6 +135,29 @@ FanCall Fan::turn_off() { return this->make_call().set_state(false); } FanCall Fan::toggle() { return this->make_call().set_state(!this->state); } FanCall Fan::make_call() { return FanCall(*this); } +const char *Fan::find_preset_mode_(const char *preset_mode) { return this->get_traits().find_preset_mode(preset_mode); } + +bool Fan::set_preset_mode_(const char *preset_mode) { + if (preset_mode == nullptr) { + // Treat nullptr as clearing the preset mode + if (this->preset_mode_ == nullptr) { + return false; // No change + } + this->clear_preset_mode_(); + return true; + } + const char *validated = this->find_preset_mode_(preset_mode); + if (validated == nullptr || this->preset_mode_ == validated) { + return false; // Preset mode not supported or no change + } + this->preset_mode_ = validated; + return true; +} + +bool Fan::set_preset_mode_(const std::string &preset_mode) { return this->set_preset_mode_(preset_mode.c_str()); } + +void Fan::clear_preset_mode_() { this->preset_mode_ = nullptr; } + void Fan::add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } void Fan::publish_state() { auto traits = this->get_traits(); @@ -144,8 +173,9 @@ void Fan::publish_state() { if (traits.supports_direction()) { ESP_LOGD(TAG, " Direction: %s", LOG_STR_ARG(fan_direction_to_string(this->direction))); } - if (traits.supports_preset_modes() && !this->preset_mode.empty()) { - ESP_LOGD(TAG, " Preset Mode: %s", this->preset_mode.c_str()); + const char *preset = this->get_preset_mode(); + if (traits.supports_preset_modes() && preset != nullptr) { + ESP_LOGD(TAG, " Preset Mode: %s", preset); } this->state_callback_.call(); this->save_state_(); @@ -189,22 +219,23 @@ void Fan::save_state_() { return; } + auto traits = this->get_traits(); + FanRestoreState state{}; state.state = this->state; state.oscillating = this->oscillating; state.speed = this->speed; state.direction = this->direction; - if (this->get_traits().supports_preset_modes() && !this->preset_mode.empty()) { - const auto &preset_modes = this->get_traits().supported_preset_modes(); - // Store index of current preset mode - size_t i = 0; - for (const auto &mode : preset_modes) { - if (mode == this->preset_mode) { + const char *preset = this->get_preset_mode(); + if (preset != nullptr) { + const auto &preset_modes = traits.supported_preset_modes(); + // Find index of current preset mode (pointer comparison is safe since preset is from traits) + for (size_t i = 0; i < preset_modes.size(); i++) { + if (preset_modes[i] == preset) { state.preset_mode = i; break; } - i++; } } @@ -228,8 +259,8 @@ void Fan::dump_traits_(const char *tag, const char *prefix) { } if (traits.supports_preset_modes()) { ESP_LOGCONFIG(tag, "%s Supported presets:", prefix); - for (const std::string &s : traits.supported_preset_modes()) - ESP_LOGCONFIG(tag, "%s - %s", prefix, s.c_str()); + for (const char *s : traits.supported_preset_modes()) + ESP_LOGCONFIG(tag, "%s - %s", prefix, s); } } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index b74187eb4a..33e546b2bb 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -60,8 +60,6 @@ class FanCall { this->speed_ = speed; return *this; } - ESPDEPRECATED("set_speed() with string argument is deprecated, use integer argument instead.", "2021.9") - FanCall &set_speed(const char *legacy_speed); optional get_speed() const { return this->speed_; } FanCall &set_direction(FanDirection direction) { this->direction_ = direction; @@ -72,11 +70,10 @@ class FanCall { return *this; } optional get_direction() const { return this->direction_; } - FanCall &set_preset_mode(const std::string &preset_mode) { - this->preset_mode_ = preset_mode; - return *this; - } - std::string get_preset_mode() const { return this->preset_mode_; } + FanCall &set_preset_mode(const std::string &preset_mode); + FanCall &set_preset_mode(const char *preset_mode); + const char *get_preset_mode() const { return this->preset_mode_; } + bool has_preset_mode() const { return this->preset_mode_ != nullptr; } void perform(); @@ -88,7 +85,7 @@ class FanCall { optional oscillating_; optional speed_; optional direction_{}; - std::string preset_mode_{}; + const char *preset_mode_{nullptr}; // Pointer to string in traits (after validation) }; struct FanRestoreState { @@ -114,8 +111,6 @@ class Fan : public EntityBase { int speed{0}; /// The current direction of the fan FanDirection direction{FanDirection::FORWARD}; - // The current preset mode of the fan - std::string preset_mode{}; FanCall turn_on(); FanCall turn_off(); @@ -132,8 +127,15 @@ class Fan : public EntityBase { /// Set the restore mode of this fan. void set_restore_mode(FanRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } + /// Get the current preset mode (returns pointer to string stored in traits, or nullptr if not set) + const char *get_preset_mode() const { return this->preset_mode_; } + + /// Check if a preset mode is currently active + bool has_preset_mode() const { return this->preset_mode_ != nullptr; } + protected: friend FanCall; + friend struct FanRestoreState; virtual void control(const FanCall &call) = 0; @@ -142,9 +144,19 @@ class Fan : public EntityBase { void dump_traits_(const char *tag, const char *prefix); + /// Set the preset mode (finds and stores pointer from traits). Returns true if changed. + bool set_preset_mode_(const char *preset_mode); + /// Set the preset mode (finds and stores pointer from traits). Returns true if changed. + bool set_preset_mode_(const std::string &preset_mode); + /// Clear the preset mode + void clear_preset_mode_(); + /// Find and return the matching preset mode pointer from traits, or nullptr if not found. + const char *find_preset_mode_(const char *preset_mode); + CallbackManager state_callback_{}; ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; + const char *preset_mode_{nullptr}; }; } // namespace fan diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 15c951b045..24987fe984 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -1,15 +1,11 @@ #pragma once +#include #include +#include namespace esphome { -#ifdef USE_API -namespace api { -class APIConnection; -} // namespace api -#endif - namespace fan { class FanTraits { @@ -35,27 +31,38 @@ class FanTraits { /// Set whether this fan supports changing direction void set_direction(bool direction) { this->direction_ = direction; } /// Return the preset modes supported by the fan. - const std::vector &supported_preset_modes() const { return this->preset_modes_; } - /// Set the preset modes supported by the fan. - void set_supported_preset_modes(const std::vector &preset_modes) { this->preset_modes_ = preset_modes; } + const std::vector &supported_preset_modes() const { return this->preset_modes_; } + /// Set the preset modes supported by the fan (from initializer list). + void set_supported_preset_modes(std::initializer_list preset_modes) { + this->preset_modes_ = preset_modes; + } + /// Set the preset modes supported by the fan (from vector). + void set_supported_preset_modes(const std::vector &preset_modes) { this->preset_modes_ = preset_modes; } + + // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages + void set_supported_preset_modes(const std::vector &preset_modes) = delete; + void set_supported_preset_modes(std::initializer_list preset_modes) = delete; + /// Return if preset modes are supported bool supports_preset_modes() const { return !this->preset_modes_.empty(); } + /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. + const char *find_preset_mode(const char *preset_mode) const { + if (preset_mode == nullptr) + return nullptr; + for (const char *mode : this->preset_modes_) { + if (strcmp(mode, preset_mode) == 0) { + return mode; // Return pointer from traits + } + } + return nullptr; + } protected: -#ifdef USE_API - // The API connection is a friend class to access internal methods - friend class api::APIConnection; - // This method returns a reference to the internal preset modes. - // It is used by the API to avoid copying data when encoding messages. - // Warning: Do not use this method outside of the API connection code. - // It returns a reference to internal data that can be invalidated. - const std::vector &supported_preset_modes_for_api_() const { return this->preset_modes_; } -#endif bool oscillation_{false}; bool speed_{false}; bool direction_{false}; int speed_count_{}; - std::vector preset_modes_{}; + std::vector preset_modes_{}; }; } // namespace fan diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 8372bc7e08..3c2021d40e 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -39,6 +39,7 @@ CONFIG_SCHEMA = ( # due to hardware limitations or lack of reliable interrupt support. This ensures # stable operation on these platforms. Future maintainers should verify platform # capabilities before changing this default behavior. + # nrf52 has no gpio interrupts implemented yet cv.SplitDefault( CONF_USE_INTERRUPT, bk72xx=False, @@ -46,7 +47,7 @@ CONFIG_SCHEMA = ( esp8266=True, host=True, ln882x=False, - nrf52=True, + nrf52=False, rp2040=True, rtl87xx=False, ): cv.boolean, @@ -94,6 +95,8 @@ async def to_code(config): ) use_interrupt = False - cg.add(var.set_use_interrupt(use_interrupt)) if use_interrupt: cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE])) + else: + # Only generate call when disabling interrupts (default is true) + cg.add(var.set_use_interrupt(use_interrupt)) diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 5709b8e9b5..cd2673a272 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -171,7 +171,7 @@ void HaierClimateBase::toggle_power() { PendingAction({ActionRequest::TOGGLE_POWER, esphome::optional()}); } -void HaierClimateBase::set_supported_swing_modes(const std::set &modes) { +void HaierClimateBase::set_supported_swing_modes(climate::ClimateSwingModeMask modes) { this->traits_.set_supported_swing_modes(modes); if (!modes.empty()) this->traits_.add_supported_swing_mode(climate::CLIMATE_SWING_OFF); @@ -179,13 +179,13 @@ void HaierClimateBase::set_supported_swing_modes(const std::sethaier_protocol_.set_answer_timeout(timeout); } -void HaierClimateBase::set_supported_modes(const std::set &modes) { +void HaierClimateBase::set_supported_modes(climate::ClimateModeMask modes) { this->traits_.set_supported_modes(modes); this->traits_.add_supported_mode(climate::CLIMATE_MODE_OFF); // Always available this->traits_.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); // Always available } -void HaierClimateBase::set_supported_presets(const std::set &presets) { +void HaierClimateBase::set_supported_presets(climate::ClimatePresetMask presets) { this->traits_.set_supported_presets(presets); if (!presets.empty()) this->traits_.add_supported_preset(climate::CLIMATE_PRESET_NONE); diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index f0597c49ff..e24217bfd9 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include "esphome/components/climate/climate.h" #include "esphome/components/uart/uart.h" #include "esphome/core/automation.h" @@ -60,9 +59,9 @@ class HaierClimateBase : public esphome::Component, void send_power_off_command(); void toggle_power(); void reset_protocol() { this->reset_protocol_request_ = true; }; - void set_supported_modes(const std::set &modes); - void set_supported_swing_modes(const std::set &modes); - void set_supported_presets(const std::set &presets); + void set_supported_modes(esphome::climate::ClimateModeMask modes); + void set_supported_swing_modes(esphome::climate::ClimateSwingModeMask modes); + void set_supported_presets(esphome::climate::ClimatePresetMask presets); bool valid_connection() const { return this->protocol_phase_ >= ProtocolPhases::IDLE; }; size_t available() noexcept override { return esphome::uart::UARTDevice::available(); }; size_t read_array(uint8_t *data, size_t len) noexcept override { diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 76558f2ebb..23d28bfd47 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -1033,9 +1033,9 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * { // Swing mode ClimateSwingMode old_swing_mode = this->swing_mode; - const std::set &swing_modes = traits_.get_supported_swing_modes(); - bool vertical_swing_supported = swing_modes.find(CLIMATE_SWING_VERTICAL) != swing_modes.end(); - bool horizontal_swing_supported = swing_modes.find(CLIMATE_SWING_HORIZONTAL) != swing_modes.end(); + const auto &swing_modes = traits_.get_supported_swing_modes(); + bool vertical_swing_supported = swing_modes.count(CLIMATE_SWING_VERTICAL); + bool horizontal_swing_supported = swing_modes.count(CLIMATE_SWING_HORIZONTAL); if (horizontal_swing_supported && (packet.control.horizontal_swing_mode == (uint8_t) hon_protocol::HorizontalSwingMode::AUTO)) { if (vertical_swing_supported && @@ -1218,13 +1218,13 @@ void HonClimate::fill_control_messages_queue_() { (uint8_t) hon_protocol::DataParameters::QUIET_MODE, quiet_mode_buf, 2); } - if ((fast_mode_buf[1] != 0xFF) && ((presets.find(climate::ClimatePreset::CLIMATE_PRESET_BOOST) != presets.end()))) { + if ((fast_mode_buf[1] != 0xFF) && presets.count(climate::ClimatePreset::CLIMATE_PRESET_BOOST)) { this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + (uint8_t) hon_protocol::DataParameters::FAST_MODE, fast_mode_buf, 2); } - if ((away_mode_buf[1] != 0xFF) && ((presets.find(climate::ClimatePreset::CLIMATE_PRESET_AWAY) != presets.end()))) { + if ((away_mode_buf[1] != 0xFF) && presets.count(climate::ClimatePreset::CLIMATE_PRESET_AWAY)) { this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + (uint8_t) hon_protocol::DataParameters::TEN_DEGREE, diff --git a/esphome/components/hbridge/fan/hbridge_fan.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index 605a9d4ef3..488208b725 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -57,7 +57,7 @@ void HBridgeFan::control(const fan::FanCall &call) { this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value()) this->direction = *call.get_direction(); - this->preset_mode = call.get_preset_mode(); + this->set_preset_mode_(call.get_preset_mode()); this->write_state_(); this->publish_state(); diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index b5fb7f5daa..143c7c1853 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -1,7 +1,5 @@ #pragma once -#include - #include "esphome/core/automation.h" #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" @@ -22,7 +20,7 @@ class HBridgeFan : public Component, public fan::Fan { void set_pin_a(output::FloatOutput *pin_a) { pin_a_ = pin_a; } void set_pin_b(output::FloatOutput *pin_b) { pin_b_ = pin_b; } void set_enable_pin(output::FloatOutput *enable) { enable_ = enable; } - void set_preset_modes(const std::vector &presets) { preset_modes_ = presets; } + void set_preset_modes(std::initializer_list presets) { preset_modes_ = presets; } void setup() override; void dump_config() override; @@ -38,7 +36,7 @@ class HBridgeFan : public Component, public fan::Fan { int speed_count_{}; DecayMode decay_mode_{DECAY_MODE_SLOW}; fan::FanTraits traits_; - std::vector preset_modes_{}; + std::vector preset_modes_{}; void control(const fan::FanCall &call) override; void write_state_(); diff --git a/esphome/components/hdc2010/__init__.py b/esphome/components/hdc2010/__init__.py new file mode 100644 index 0000000000..badf9dbb0c --- /dev/null +++ b/esphome/components/hdc2010/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@optimusprimespace", "@ssieb"] diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp new file mode 100644 index 0000000000..c53fdb3f5b --- /dev/null +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -0,0 +1,111 @@ +#include "esphome/core/hal.h" +#include "hdc2010.h" +// https://github.com/vigsterkr/homebridge-hdc2010/blob/main/src/hdc2010.js +// https://github.com/lime-labs/HDC2080-Arduino/blob/master/src/HDC2080.cpp +namespace esphome { +namespace hdc2010 { + +static const char *const TAG = "hdc2010"; + +static const uint8_t HDC2010_ADDRESS = 0x40; // 0b1000000 or 0b1000001 from datasheet +static const uint8_t HDC2010_CMD_CONFIGURATION_MEASUREMENT = 0x8F; +static const uint8_t HDC2010_CMD_START_MEASUREMENT = 0xF9; +static const uint8_t HDC2010_CMD_TEMPERATURE_LOW = 0x00; +static const uint8_t HDC2010_CMD_TEMPERATURE_HIGH = 0x01; +static const uint8_t HDC2010_CMD_HUMIDITY_LOW = 0x02; +static const uint8_t HDC2010_CMD_HUMIDITY_HIGH = 0x03; +static const uint8_t CONFIG = 0x0E; +static const uint8_t MEASUREMENT_CONFIG = 0x0F; + +void HDC2010Component::setup() { + ESP_LOGCONFIG(TAG, "Running setup"); + + const uint8_t data[2] = { + 0b00000000, // resolution 14bit for both humidity and temperature + 0b00000000 // reserved + }; + + if (!this->write_bytes(HDC2010_CMD_CONFIGURATION_MEASUREMENT, data, 2)) { + ESP_LOGW(TAG, "Initial config instruction error"); + this->status_set_warning(); + return; + } + + // Set measurement mode to temperature and humidity + uint8_t config_contents; + this->read_register(MEASUREMENT_CONFIG, &config_contents, 1); + config_contents = (config_contents & 0xF9); // Always set to TEMP_AND_HUMID mode + this->write_bytes(MEASUREMENT_CONFIG, &config_contents, 1); + + // Set rate to manual + this->read_register(CONFIG, &config_contents, 1); + config_contents &= 0x8F; + this->write_bytes(CONFIG, &config_contents, 1); + + // Set temperature resolution to 14bit + this->read_register(CONFIG, &config_contents, 1); + config_contents &= 0x3F; + this->write_bytes(CONFIG, &config_contents, 1); + + // Set humidity resolution to 14bit + this->read_register(CONFIG, &config_contents, 1); + config_contents &= 0xCF; + this->write_bytes(CONFIG, &config_contents, 1); +} + +void HDC2010Component::dump_config() { + ESP_LOGCONFIG(TAG, "HDC2010:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); +} + +void HDC2010Component::update() { + // Trigger measurement + uint8_t config_contents; + this->read_register(CONFIG, &config_contents, 1); + config_contents |= 0x01; + this->write_bytes(MEASUREMENT_CONFIG, &config_contents, 1); + + // 1ms delay after triggering the sample + set_timeout(1, [this]() { + if (this->temperature_sensor_ != nullptr) { + float temp = this->read_temp(); + this->temperature_sensor_->publish_state(temp); + ESP_LOGD(TAG, "Temp=%.1f°C", temp); + } + + if (this->humidity_sensor_ != nullptr) { + float humidity = this->read_humidity(); + this->humidity_sensor_->publish_state(humidity); + ESP_LOGD(TAG, "Humidity=%.1f%%", humidity); + } + }); +} + +float HDC2010Component::read_temp() { + uint8_t byte[2]; + + this->read_register(HDC2010_CMD_TEMPERATURE_LOW, &byte[0], 1); + this->read_register(HDC2010_CMD_TEMPERATURE_HIGH, &byte[1], 1); + + uint16_t temp = encode_uint16(byte[1], byte[0]); + return (float) temp * 0.0025177f - 40.0f; +} + +float HDC2010Component::read_humidity() { + uint8_t byte[2]; + + this->read_register(HDC2010_CMD_HUMIDITY_LOW, &byte[0], 1); + this->read_register(HDC2010_CMD_HUMIDITY_HIGH, &byte[1], 1); + + uint16_t humidity = encode_uint16(byte[1], byte[0]); + return (float) humidity * 0.001525879f; +} + +} // namespace hdc2010 +} // namespace esphome diff --git a/esphome/components/hdc2010/hdc2010.h b/esphome/components/hdc2010/hdc2010.h new file mode 100644 index 0000000000..52c00686e6 --- /dev/null +++ b/esphome/components/hdc2010/hdc2010.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome { +namespace hdc2010 { + +class HDC2010Component : public PollingComponent, public i2c::I2CDevice { + public: + void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } + + void set_humidity_sensor(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } + + /// Setup the sensor and check for connection. + void setup() override; + void dump_config() override; + /// Retrieve the latest sensor values. This operation takes approximately 16ms. + void update() override; + + float read_temp(); + + float read_humidity(); + + protected: + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; +}; + +} // namespace hdc2010 +} // namespace esphome diff --git a/esphome/components/hdc2010/sensor.py b/esphome/components/hdc2010/sensor.py new file mode 100644 index 0000000000..15e19f2cc8 --- /dev/null +++ b/esphome/components/hdc2010/sensor.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_HUMIDITY, + CONF_ID, + CONF_TEMPERATURE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PERCENT, +) + +DEPENDENCIES = ["i2c"] + +hdc2010_ns = cg.esphome_ns.namespace("hdc2010") +HDC2010Component = hdc2010_ns.class_( + "HDC2010Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HDC2010Component), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x40)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature_sensor(sens)) + + if humidity_config := config.get(CONF_HUMIDITY): + sens = await sensor.new_sensor(humidity_config) + cg.add(var.set_humidity_sensor(sens)) diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index 3e14c11861..ed43ffdc83 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -97,12 +97,11 @@ const float TEMP_MAX = 100; // Celsius class HeatpumpIRClimate : public climate_ir::ClimateIR { public: HeatpumpIRClimate() - : climate_ir::ClimateIR( - TEMP_MIN, TEMP_MAX, 1.0f, true, true, - std::set{climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, - climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_AUTO}, - std::set{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, - climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_FAN_AUTO}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} void setup() override; void set_protocol(Protocol protocol) { this->protocol_ = protocol; } void set_horizontal_default(HorizontalDirection horizontal_direction) { diff --git a/esphome/components/host/gpio.h b/esphome/components/host/gpio.h index a60d535912..ae677291b9 100644 --- a/esphome/components/host/gpio.h +++ b/esphome/components/host/gpio.h @@ -28,8 +28,8 @@ class HostGPIOPin : public InternalGPIOPin { void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + bool inverted_{}; + gpio::Flags flags_{}; }; } // namespace host diff --git a/esphome/components/host/gpio.py b/esphome/components/host/gpio.py index 0f22a790bd..fcfb0b6c54 100644 --- a/esphome/components/host/gpio.py +++ b/esphome/components/host/gpio.py @@ -57,6 +57,9 @@ async def host_pin_to_code(config): var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) + # Only set if true to avoid bloating setup() function + # (inverted bit in pin_flags_ bitfield is zero-initialized to false) + if config[CONF_INVERTED]: + cg.add(var.set_inverted(True)) cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) return var diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index e428838c83..f4fa448c5b 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -12,7 +12,6 @@ from esphome.const import ( CONF_ON_ERROR, CONF_ON_RESPONSE, CONF_TIMEOUT, - CONF_TRIGGER_ID, CONF_URL, CONF_WATCHDOG_TIMEOUT, PLATFORM_HOST, @@ -216,16 +215,8 @@ HTTP_REQUEST_ACTION_SCHEMA = cv.Schema( f"{CONF_VERIFY_SSL} has moved to the base component configuration." ), cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean, - cv.Optional(CONF_ON_RESPONSE): automation.validate_automation( - {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(HttpRequestResponseTrigger)} - ), - cv.Optional(CONF_ON_ERROR): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - automation.Trigger.template() - ) - } - ), + cv.Optional(CONF_ON_RESPONSE): automation.validate_automation(single=True), + cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True), cv.Optional(CONF_MAX_RESPONSE_BUFFER_SIZE, default="1kB"): cv.validate_bytes, } ) @@ -280,7 +271,12 @@ async def http_request_action_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) cg.add(var.set_url(template_)) cg.add(var.set_method(config[CONF_METHOD])) - cg.add(var.set_capture_response(config[CONF_CAPTURE_RESPONSE])) + + capture_response = config[CONF_CAPTURE_RESPONSE] + if capture_response: + cg.add(var.set_capture_response(capture_response)) + cg.add_define("USE_HTTP_REQUEST_RESPONSE") + cg.add(var.set_max_response_buffer_size(config[CONF_MAX_RESPONSE_BUFFER_SIZE])) if CONF_BODY in config: @@ -303,21 +299,26 @@ async def http_request_action_to_code(config, action_id, template_arg, args): for value in config.get(CONF_COLLECT_HEADERS, []): cg.add(var.add_collect_header(value)) - for conf in config.get(CONF_ON_RESPONSE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_response_trigger(trigger)) - await automation.build_automation( - trigger, - [ - (cg.std_shared_ptr.template(HttpContainer), "response"), - (cg.std_string_ref, "body"), - ], - conf, - ) - for conf in config.get(CONF_ON_ERROR, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_error_trigger(trigger)) - await automation.build_automation(trigger, [], conf) + if response_conf := config.get(CONF_ON_RESPONSE): + if capture_response: + await automation.build_automation( + var.get_success_trigger_with_response(), + [ + (cg.std_shared_ptr.template(HttpContainer), "response"), + (cg.std_string_ref, "body"), + *args, + ], + response_conf, + ) + else: + await automation.build_automation( + var.get_success_trigger(), + [(cg.std_shared_ptr.template(HttpContainer), "response"), *args], + response_conf, + ) + + if error_conf := config.get(CONF_ON_ERROR): + await automation.build_automation(var.get_error_trigger(), args, error_conf) return var diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index bb14cc6f51..482cd2da44 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -124,7 +124,7 @@ class HttpRequestComponent : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void set_useragent(const char *useragent) { this->useragent_ = useragent; } - void set_timeout(uint16_t timeout) { this->timeout_ = timeout; } + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; } uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; } void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; } @@ -169,11 +169,11 @@ class HttpRequestComponent : public Component { protected: virtual std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set collect_headers) = 0; + const std::set &collect_headers) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; uint16_t redirect_limit_{}; - uint16_t timeout_{4500}; + uint32_t timeout_{4500}; uint32_t watchdog_timeout_{0}; }; @@ -183,7 +183,9 @@ template class HttpRequestSendAction : public Action { TEMPLATABLE_VALUE(std::string, url) TEMPLATABLE_VALUE(const char *, method) TEMPLATABLE_VALUE(std::string, body) +#ifdef USE_HTTP_REQUEST_RESPONSE TEMPLATABLE_VALUE(bool, capture_response) +#endif void add_request_header(const char *key, TemplatableValue value) { this->request_headers_.insert({key, value}); @@ -195,9 +197,14 @@ template class HttpRequestSendAction : public Action { void set_json(std::function json_func) { this->json_func_ = json_func; } - void register_response_trigger(HttpRequestResponseTrigger *trigger) { this->response_triggers_.push_back(trigger); } +#ifdef USE_HTTP_REQUEST_RESPONSE + Trigger, std::string &, Ts...> *get_success_trigger_with_response() const { + return this->success_trigger_with_response_; + } +#endif + Trigger, Ts...> *get_success_trigger() const { return this->success_trigger_; } - void register_error_trigger(Trigger<> *trigger) { this->error_triggers_.push_back(trigger); } + Trigger *get_error_trigger() const { return this->error_trigger_; } void set_max_response_buffer_size(size_t max_response_buffer_size) { this->max_response_buffer_size_ = max_response_buffer_size; @@ -228,17 +235,20 @@ template class HttpRequestSendAction : public Action { auto container = this->parent_->start(this->url_.value(x...), this->method_.value(x...), body, request_headers, this->collect_headers_); + auto captured_args = std::make_tuple(x...); + if (container == nullptr) { - for (auto *trigger : this->error_triggers_) - trigger->trigger(); + std::apply([this](Ts... captured_args_inner) { this->error_trigger_->trigger(captured_args_inner...); }, + captured_args); return; } size_t content_length = container->content_length; size_t max_length = std::min(content_length, this->max_response_buffer_size_); - std::string response_body; +#ifdef USE_HTTP_REQUEST_RESPONSE if (this->capture_response_.value(x...)) { + std::string response_body; RAMAllocator allocator; uint8_t *buf = allocator.allocate(max_length); if (buf != nullptr) { @@ -253,19 +263,17 @@ template class HttpRequestSendAction : public Action { response_body.assign((char *) buf, read_index); allocator.deallocate(buf, max_length); } - } - - if (this->response_triggers_.size() == 1) { - // if there is only one trigger, no need to copy the response body - this->response_triggers_[0]->process(container, response_body); - } else { - for (auto *trigger : this->response_triggers_) { - // with multiple triggers, pass a copy of the response body to each - // one so that modifications made in one trigger are not visible to - // the others - auto response_body_copy = std::string(response_body); - trigger->process(container, response_body_copy); - } + std::apply( + [this, &container, &response_body](Ts... captured_args_inner) { + this->success_trigger_with_response_->trigger(container, response_body, captured_args_inner...); + }, + captured_args); + } else +#endif + { + std::apply([this, &container]( + Ts... captured_args_inner) { this->success_trigger_->trigger(container, captured_args_inner...); }, + captured_args); } container->end(); } @@ -283,8 +291,13 @@ template class HttpRequestSendAction : public Action { std::set collect_headers_{"content-type", "content-length"}; std::map> json_{}; std::function json_func_{nullptr}; - std::vector response_triggers_{}; - std::vector *> error_triggers_{}; +#ifdef USE_HTTP_REQUEST_RESPONSE + Trigger, std::string &, Ts...> *success_trigger_with_response_ = + new Trigger, std::string &, Ts...>(); +#endif + Trigger, Ts...> *success_trigger_ = + new Trigger, Ts...>(); + Trigger *error_trigger_ = new Trigger(); size_t max_response_buffer_size_{SIZE_MAX}; }; diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index dfdbbd3fab..c64a7be554 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -17,7 +17,7 @@ static const char *const TAG = "http_request.arduino"; std::shared_ptr HttpRequestArduino::perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set collect_headers) { + const std::set &collect_headers) { if (!network::is_connected()) { this->status_momentary_error("failed", 1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index c8208c74d8..b736bb56d1 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -33,7 +33,7 @@ class HttpRequestArduino : public HttpRequestComponent { protected: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set collect_headers) override; + const std::set &collect_headers) override; }; } // namespace http_request diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index c20ea552b7..402affc1d1 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -20,7 +20,7 @@ static const char *const TAG = "http_request.host"; std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set response_headers) { + const std::set &response_headers) { if (!network::is_connected()) { this->status_momentary_error("failed", 1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index fdd72e7ea5..886ba94938 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -20,7 +20,7 @@ class HttpRequestHost : public HttpRequestComponent { public: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set response_headers) override; + const std::set &response_headers) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } protected: diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index a91c0bfc25..34a3fb87eb 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -55,7 +55,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { std::shared_ptr HttpRequestIDF::perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set collect_headers) { + const std::set &collect_headers) { if (!network::is_connected()) { this->status_momentary_error("failed", 1000); ESP_LOGE(TAG, "HTTP Request failed; Not connected to network"); diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 90dee0be68..e51b3aaebc 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -39,7 +39,7 @@ class HttpRequestIDF : public HttpRequestComponent { protected: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set collect_headers) override; + const std::set &collect_headers) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE uint16_t buffer_size_rx_{}; uint16_t buffer_size_tx_{}; diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index f880b5f736..bf25a7cd92 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -671,18 +671,33 @@ async def write_image(config, all_frames=False): resize = config.get(CONF_RESIZE) if is_svg_file(path): # Local import so use of non-SVG files needn't require cairosvg installed + from pyexpat import ExpatError + from xml.etree.ElementTree import ParseError + from cairosvg import svg2png + from cairosvg.helpers import PointError if not resize: resize = (None, None) - with open(path, "rb") as file: - image = svg2png( - file_obj=file, - output_width=resize[0], - output_height=resize[1], - ) - image = Image.open(io.BytesIO(image)) - width, height = image.size + try: + with open(path, "rb") as file: + image = svg2png( + file_obj=file, + output_width=resize[0], + output_height=resize[1], + ) + image = Image.open(io.BytesIO(image)) + width, height = image.size + except ( + ValueError, + ParseError, + IndexError, + ExpatError, + AttributeError, + TypeError, + PointError, + ) as e: + raise core.EsphomeError(f"Could not load SVG image {path}: {e}") from e else: image = Image.open(path) width, height = image.size diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 7b65c4d0cb..90e021467f 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -125,7 +125,7 @@ lv_img_dsc_t *Image::get_lv_img_dsc() { case IMAGE_TYPE_RGB: #if LV_COLOR_DEPTH == 32 - switch (this->transparent_) { + switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_ALPHA; break; @@ -156,7 +156,8 @@ lv_img_dsc_t *Image::get_lv_img_dsc() { break; } #else - this->dsc_.header.cf = this->transparent_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_IMG_CF_RGB565A8 : LV_IMG_CF_RGB565; + this->dsc_.header.cf = + this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_IMG_CF_RGB565A8 : LV_IMG_CF_RGB565; #endif break; } diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ce82504d3c..9d080ea98e 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -28,6 +28,38 @@ void ImprovSerialComponent::setup() { } } +void ImprovSerialComponent::loop() { + if (this->last_read_byte_ && (millis() - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) { + this->last_read_byte_ = 0; + this->rx_buffer_.clear(); + ESP_LOGV(TAG, "Timeout"); + } + + auto byte = this->read_byte_(); + while (byte.has_value()) { + if (this->parse_improv_serial_byte_(byte.value())) { + this->last_read_byte_ = millis(); + } else { + this->last_read_byte_ = 0; + this->rx_buffer_.clear(); + } + byte = this->read_byte_(); + } + + if (this->state_ == improv::STATE_PROVISIONING) { + if (wifi::global_wifi_component->is_connected()) { + wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), + this->connecting_sta_.get_password()); + this->connecting_sta_ = {}; + this->cancel_timeout("wifi-connect-timeout"); + this->set_state_(improv::STATE_PROVISIONED); + + std::vector url = this->build_rpc_settings_response_(improv::WIFI_SETTINGS); + this->send_response_(url); + } + } +} + void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:"); } optional ImprovSerialComponent::read_byte_() { @@ -78,8 +110,28 @@ optional ImprovSerialComponent::read_byte_() { return byte; } -void ImprovSerialComponent::write_data_(std::vector &data) { - data.push_back('\n'); +void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) { + // First, set length field + this->tx_header_[TX_LENGTH_IDX] = this->tx_header_[TX_TYPE_IDX] == TYPE_RPC_RESPONSE ? size : 1; + + const bool there_is_data = data != nullptr && size > 0; + // If there_is_data, checksum must not include our optional data byte + const uint8_t header_checksum_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE - 2; + // Only transmit the full buffer length if there is no data (only state/error byte is provided in this case) + const uint8_t header_tx_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE; + // Calculate checksum for message + uint8_t checksum = 0; + for (uint8_t i = 0; i < header_checksum_len; i++) { + checksum += this->tx_header_[i]; + } + if (there_is_data) { + // Include data in checksum + for (size_t i = 0; i < size; i++) { + checksum += data[i]; + } + } + this->tx_header_[TX_CHECKSUM_IDX] = checksum; + #ifdef USE_ESP32 switch (logger::global_logger->get_uart()) { case logger::UART_SELECTION_UART0: @@ -87,63 +139,45 @@ void ImprovSerialComponent::write_data_(std::vector &data) { #if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) case logger::UART_SELECTION_UART2: -#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 - uart_write_bytes(this->uart_num_, data.data(), data.size()); +#endif + uart_write_bytes(this->uart_num_, this->tx_header_, header_tx_len); + if (there_is_data) { + uart_write_bytes(this->uart_num_, data, size); + uart_write_bytes(this->uart_num_, &this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline + } break; #if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC) - case logger::UART_SELECTION_USB_CDC: { - const char *msg = (char *) data.data(); - esp_usb_console_write_buf(msg, data.size()); + case logger::UART_SELECTION_USB_CDC: + esp_usb_console_write_buf((const char *) this->tx_header_, header_tx_len); + if (there_is_data) { + esp_usb_console_write_buf((const char *) data, size); + esp_usb_console_write_buf((const char *) &this->tx_header_[TX_CHECKSUM_IDX], + 2); // Footer: checksum and newline + } break; - } -#endif // USE_LOGGER_USB_CDC +#endif #ifdef USE_LOGGER_USB_SERIAL_JTAG case logger::UART_SELECTION_USB_SERIAL_JTAG: - usb_serial_jtag_write_bytes((char *) data.data(), data.size(), 20 / portTICK_PERIOD_MS); - delay(10); - usb_serial_jtag_ll_txfifo_flush(); // fixes for issue in IDF 4.4.7 + usb_serial_jtag_write_bytes((const char *) this->tx_header_, header_tx_len, 20 / portTICK_PERIOD_MS); + if (there_is_data) { + usb_serial_jtag_write_bytes((const char *) data, size, 20 / portTICK_PERIOD_MS); + usb_serial_jtag_write_bytes((const char *) &this->tx_header_[TX_CHECKSUM_IDX], 2, + 20 / portTICK_PERIOD_MS); // Footer: checksum and newline + } break; -#endif // USE_LOGGER_USB_SERIAL_JTAG +#endif default: break; } #elif defined(USE_ARDUINO) - this->hw_serial_->write(data.data(), data.size()); + this->hw_serial_->write(this->tx_header_, header_tx_len); + if (there_is_data) { + this->hw_serial_->write(data, size); + this->hw_serial_->write(&this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline + } #endif } -void ImprovSerialComponent::loop() { - if (this->last_read_byte_ && (millis() - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) { - this->last_read_byte_ = 0; - this->rx_buffer_.clear(); - ESP_LOGV(TAG, "Improv Serial timeout"); - } - - auto byte = this->read_byte_(); - while (byte.has_value()) { - if (this->parse_improv_serial_byte_(byte.value())) { - this->last_read_byte_ = millis(); - } else { - this->last_read_byte_ = 0; - this->rx_buffer_.clear(); - } - byte = this->read_byte_(); - } - - if (this->state_ == improv::STATE_PROVISIONING) { - if (wifi::global_wifi_component->is_connected()) { - wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), - this->connecting_sta_.get_password()); - this->connecting_sta_ = {}; - this->cancel_timeout("wifi-connect-timeout"); - this->set_state_(improv::STATE_PROVISIONED); - - std::vector url = this->build_rpc_settings_response_(improv::WIFI_SETTINGS); - this->send_response_(url); - } - } -} - std::vector ImprovSerialComponent::build_rpc_settings_response_(improv::Command command) { std::vector urls; #ifdef USE_IMPROV_SERIAL_NEXT_URL @@ -177,13 +211,13 @@ std::vector ImprovSerialComponent::build_version_info_() { bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) { size_t at = this->rx_buffer_.size(); this->rx_buffer_.push_back(byte); - ESP_LOGV(TAG, "Improv Serial byte: 0x%02X", byte); + ESP_LOGV(TAG, "Byte: 0x%02X", byte); const uint8_t *raw = &this->rx_buffer_[0]; return improv::parse_improv_serial_byte( at, byte, raw, [this](improv::ImprovCommand command) -> bool { return this->parse_improv_payload_(command); }, [this](improv::Error error) -> void { - ESP_LOGW(TAG, "Error decoding Improv payload"); + ESP_LOGW(TAG, "Error decoding payload"); this->set_error_(error); }); } @@ -199,7 +233,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command wifi::global_wifi_component->set_sta(sta); wifi::global_wifi_component->start_connecting(sta, false); this->set_state_(improv::STATE_PROVISIONING); - ESP_LOGD(TAG, "Received Improv wifi settings ssid=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), + ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); auto f = std::bind(&ImprovSerialComponent::on_wifi_connect_timeout_, this); @@ -240,7 +274,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command return true; } default: { - ESP_LOGW(TAG, "Unknown Improv payload"); + ESP_LOGW(TAG, "Unknown payload"); this->set_error_(improv::ERROR_UNKNOWN_RPC); return false; } @@ -249,57 +283,26 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command void ImprovSerialComponent::set_state_(improv::State state) { this->state_ = state; - - std::vector data = {'I', 'M', 'P', 'R', 'O', 'V'}; - data.resize(11); - data[6] = IMPROV_SERIAL_VERSION; - data[7] = TYPE_CURRENT_STATE; - data[8] = 1; - data[9] = state; - - uint8_t checksum = 0x00; - for (uint8_t d : data) - checksum += d; - data[10] = checksum; - - this->write_data_(data); + this->tx_header_[TX_TYPE_IDX] = TYPE_CURRENT_STATE; + this->tx_header_[TX_DATA_IDX] = state; + this->write_data_(); } void ImprovSerialComponent::set_error_(improv::Error error) { - std::vector data = {'I', 'M', 'P', 'R', 'O', 'V'}; - data.resize(11); - data[6] = IMPROV_SERIAL_VERSION; - data[7] = TYPE_ERROR_STATE; - data[8] = 1; - data[9] = error; - - uint8_t checksum = 0x00; - for (uint8_t d : data) - checksum += d; - data[10] = checksum; - this->write_data_(data); + this->tx_header_[TX_TYPE_IDX] = TYPE_ERROR_STATE; + this->tx_header_[TX_DATA_IDX] = error; + this->write_data_(); } void ImprovSerialComponent::send_response_(std::vector &response) { - std::vector data = {'I', 'M', 'P', 'R', 'O', 'V'}; - data.resize(9); - data[6] = IMPROV_SERIAL_VERSION; - data[7] = TYPE_RPC_RESPONSE; - data[8] = response.size(); - data.insert(data.end(), response.begin(), response.end()); - - uint8_t checksum = 0x00; - for (uint8_t d : data) - checksum += d; - data.push_back(checksum); - - this->write_data_(data); + this->tx_header_[TX_TYPE_IDX] = TYPE_RPC_RESPONSE; + this->write_data_(response.data(), response.size()); } void ImprovSerialComponent::on_wifi_connect_timeout_() { this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); this->set_state_(improv::STATE_AUTHORIZED); - ESP_LOGW(TAG, "Timed out trying to connect to given WiFi network"); + ESP_LOGW(TAG, "Timed out while connecting to Wi-Fi network"); wifi::global_wifi_component->clear_sta(); } diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index c3c9aee24e..057247f376 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -26,6 +26,16 @@ namespace esphome { namespace improv_serial { +// TX buffer layout constants +static constexpr uint8_t TX_HEADER_SIZE = 6; // Bytes 0-5 = "IMPROV" +static constexpr uint8_t TX_VERSION_IDX = 6; +static constexpr uint8_t TX_TYPE_IDX = 7; +static constexpr uint8_t TX_LENGTH_IDX = 8; +static constexpr uint8_t TX_DATA_IDX = 9; // For state/error messages only +static constexpr uint8_t TX_CHECKSUM_IDX = 10; +static constexpr uint8_t TX_NEWLINE_IDX = 11; +static constexpr uint8_t TX_BUFFER_SIZE = 12; + enum ImprovSerialType : uint8_t { TYPE_CURRENT_STATE = 0x01, TYPE_ERROR_STATE = 0x02, @@ -57,7 +67,22 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { std::vector build_version_info_(); optional read_byte_(); - void write_data_(std::vector &data); + void write_data_(const uint8_t *data = nullptr, size_t size = 0); + + uint8_t tx_header_[TX_BUFFER_SIZE] = { + 'I', // 0: Header + 'M', // 1: Header + 'P', // 2: Header + 'R', // 3: Header + 'O', // 4: Header + 'V', // 5: Header + IMPROV_SERIAL_VERSION, // 6: Version + 0, // 7: ImprovSerialType + 0, // 8: Length + 0, // 9...X: Data (here, one byte reserved for state/error) + 0, // X + 10: Checksum + '\n', + }; #ifdef USE_ESP32 uart_port_t uart_num_; diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 5c3af54ad8..608882565f 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -121,9 +121,9 @@ constexpr Uint8ToString OUT_PIN_LEVELS_BY_UINT[] = { }; // Helper functions for lookups -template uint8_t find_uint8(const StringToUint8 (&arr)[N], const std::string &str) { +template uint8_t find_uint8(const StringToUint8 (&arr)[N], const char *str) { for (const auto &entry : arr) { - if (str == entry.str) + if (strcmp(str, entry.str) == 0) return entry.value; } return 0xFF; // Not found @@ -441,7 +441,7 @@ bool LD2410Component::handle_ack_data_() { ESP_LOGV(TAG, "Baud rate change"); #ifdef USE_SELECT if (this->baud_rate_select_ != nullptr) { - ESP_LOGE(TAG, "Change baud rate to %s and reinstall", this->baud_rate_select_->state.c_str()); + ESP_LOGE(TAG, "Change baud rate to %s and reinstall", this->baud_rate_select_->current_option()); } #endif break; @@ -626,14 +626,14 @@ void LD2410Component::set_bluetooth(bool enable) { this->set_timeout(200, [this]() { this->restart_and_read_all_info(); }); } -void LD2410Component::set_distance_resolution(const std::string &state) { +void LD2410Component::set_distance_resolution(const char *state) { this->set_config_mode_(true); const uint8_t cmd_value[2] = {find_uint8(DISTANCE_RESOLUTIONS_BY_STR, state), 0x00}; this->send_command_(CMD_SET_DISTANCE_RESOLUTION, cmd_value, sizeof(cmd_value)); this->set_timeout(200, [this]() { this->restart_and_read_all_info(); }); } -void LD2410Component::set_baud_rate(const std::string &state) { +void LD2410Component::set_baud_rate(const char *state) { this->set_config_mode_(true); const uint8_t cmd_value[2] = {find_uint8(BAUD_RATES_BY_STR, state), 0x00}; this->send_command_(CMD_SET_BAUD_RATE, cmd_value, sizeof(cmd_value)); @@ -759,10 +759,10 @@ void LD2410Component::set_light_out_control() { #endif #ifdef USE_SELECT if (this->light_function_select_ != nullptr && this->light_function_select_->has_state()) { - this->light_function_ = find_uint8(LIGHT_FUNCTIONS_BY_STR, this->light_function_select_->state); + this->light_function_ = find_uint8(LIGHT_FUNCTIONS_BY_STR, this->light_function_select_->current_option()); } if (this->out_pin_level_select_ != nullptr && this->out_pin_level_select_->has_state()) { - this->out_pin_level_ = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->state); + this->out_pin_level_ = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option()); } #endif this->set_config_mode_(true); diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 54fe1ce14d..52cf76b5b6 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -98,8 +98,8 @@ class LD2410Component : public Component, public uart::UARTDevice { void read_all_info(); void restart_and_read_all_info(); void set_bluetooth(bool enable); - void set_distance_resolution(const std::string &state); - void set_baud_rate(const std::string &state); + void set_distance_resolution(const char *state); + void set_baud_rate(const char *state); void factory_reset(); protected: diff --git a/esphome/components/ld2410/select/baud_rate_select.cpp b/esphome/components/ld2410/select/baud_rate_select.cpp index f4e0b90e2e..6da7c1d5f5 100644 --- a/esphome/components/ld2410/select/baud_rate_select.cpp +++ b/esphome/components/ld2410/select/baud_rate_select.cpp @@ -3,9 +3,9 @@ namespace esphome { namespace ld2410 { -void BaudRateSelect::control(const std::string &value) { - this->publish_state(value); - this->parent_->set_baud_rate(state); +void BaudRateSelect::control(size_t index) { + this->publish_state(index); + this->parent_->set_baud_rate(this->option_at(index)); } } // namespace ld2410 diff --git a/esphome/components/ld2410/select/baud_rate_select.h b/esphome/components/ld2410/select/baud_rate_select.h index 3827b6a48a..9385c8cf7e 100644 --- a/esphome/components/ld2410/select/baud_rate_select.h +++ b/esphome/components/ld2410/select/baud_rate_select.h @@ -11,7 +11,7 @@ class BaudRateSelect : public select::Select, public Parented { BaudRateSelect() = default; protected: - void control(const std::string &value) override; + void control(size_t index) override; }; } // namespace ld2410 diff --git a/esphome/components/ld2410/select/distance_resolution_select.cpp b/esphome/components/ld2410/select/distance_resolution_select.cpp index eef34bda63..4fc4c5af02 100644 --- a/esphome/components/ld2410/select/distance_resolution_select.cpp +++ b/esphome/components/ld2410/select/distance_resolution_select.cpp @@ -3,9 +3,9 @@ namespace esphome { namespace ld2410 { -void DistanceResolutionSelect::control(const std::string &value) { - this->publish_state(value); - this->parent_->set_distance_resolution(state); +void DistanceResolutionSelect::control(size_t index) { + this->publish_state(index); + this->parent_->set_distance_resolution(this->option_at(index)); } } // namespace ld2410 diff --git a/esphome/components/ld2410/select/distance_resolution_select.h b/esphome/components/ld2410/select/distance_resolution_select.h index d6affb1020..1a04f843a6 100644 --- a/esphome/components/ld2410/select/distance_resolution_select.h +++ b/esphome/components/ld2410/select/distance_resolution_select.h @@ -11,7 +11,7 @@ class DistanceResolutionSelect : public select::Select, public Parentedpublish_state(value); +void LightOutControlSelect::control(size_t index) { + this->publish_state(index); this->parent_->set_light_out_control(); } diff --git a/esphome/components/ld2410/select/light_out_control_select.h b/esphome/components/ld2410/select/light_out_control_select.h index 5d72e1774e..e8cd8f1d6a 100644 --- a/esphome/components/ld2410/select/light_out_control_select.h +++ b/esphome/components/ld2410/select/light_out_control_select.h @@ -11,7 +11,7 @@ class LightOutControlSelect : public select::Select, public Parented uint8_t find_uint8(const StringToUint8 (&arr)[N], const std::string &str) { +template uint8_t find_uint8(const StringToUint8 (&arr)[N], const char *str) { for (const auto &entry : arr) { - if (str == entry.str) { + if (strcmp(str, entry.str) == 0) { return entry.value; } } @@ -485,7 +485,7 @@ bool LD2412Component::handle_ack_data_() { ESP_LOGV(TAG, "Baud rate change"); #ifdef USE_SELECT if (this->baud_rate_select_ != nullptr) { - ESP_LOGW(TAG, "Change baud rate to %s and reinstall", this->baud_rate_select_->state.c_str()); + ESP_LOGW(TAG, "Change baud rate to %s and reinstall", this->baud_rate_select_->current_option()); } #endif break; @@ -699,14 +699,14 @@ void LD2412Component::set_bluetooth(bool enable) { this->set_timeout(200, [this]() { this->restart_and_read_all_info(); }); } -void LD2412Component::set_distance_resolution(const std::string &state) { +void LD2412Component::set_distance_resolution(const char *state) { this->set_config_mode_(true); const uint8_t cmd_value[6] = {find_uint8(DISTANCE_RESOLUTIONS_BY_STR, state), 0x00, 0x00, 0x00, 0x00, 0x00}; this->send_command_(CMD_SET_DISTANCE_RESOLUTION, cmd_value, sizeof(cmd_value)); this->set_timeout(200, [this]() { this->restart_and_read_all_info(); }); } -void LD2412Component::set_baud_rate(const std::string &state) { +void LD2412Component::set_baud_rate(const char *state) { this->set_config_mode_(true); const uint8_t cmd_value[2] = {find_uint8(BAUD_RATES_BY_STR, state), 0x00}; this->send_command_(CMD_SET_BAUD_RATE, cmd_value, sizeof(cmd_value)); @@ -783,7 +783,7 @@ void LD2412Component::set_basic_config() { 1, TOTAL_GATES, DEFAULT_PRESENCE_TIMEOUT, 0, #endif #ifdef USE_SELECT - find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->state), + find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option()), #else 0x01, // Default value if not using select #endif @@ -837,7 +837,7 @@ void LD2412Component::set_light_out_control() { #endif #ifdef USE_SELECT if (this->light_function_select_ != nullptr && this->light_function_select_->has_state()) { - this->light_function_ = find_uint8(LIGHT_FUNCTIONS_BY_STR, this->light_function_select_->state); + this->light_function_ = find_uint8(LIGHT_FUNCTIONS_BY_STR, this->light_function_select_->current_option()); } #endif uint8_t value[2] = {this->light_function_, this->light_threshold_}; diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 41f96ab301..2bed34bdd8 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -99,8 +99,8 @@ class LD2412Component : public Component, public uart::UARTDevice { void read_all_info(); void restart_and_read_all_info(); void set_bluetooth(bool enable); - void set_distance_resolution(const std::string &state); - void set_baud_rate(const std::string &state); + void set_distance_resolution(const char *state); + void set_baud_rate(const char *state); void factory_reset(); void start_dynamic_background_correction(); diff --git a/esphome/components/ld2412/select/baud_rate_select.cpp b/esphome/components/ld2412/select/baud_rate_select.cpp index 2291a81896..7bc4683853 100644 --- a/esphome/components/ld2412/select/baud_rate_select.cpp +++ b/esphome/components/ld2412/select/baud_rate_select.cpp @@ -3,9 +3,9 @@ namespace esphome { namespace ld2412 { -void BaudRateSelect::control(const std::string &value) { - this->publish_state(value); - this->parent_->set_baud_rate(state); +void BaudRateSelect::control(size_t index) { + this->publish_state(index); + this->parent_->set_baud_rate(this->option_at(index)); } } // namespace ld2412 diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index 2ae33551fb..ffe0329341 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -11,7 +11,7 @@ class BaudRateSelect : public select::Select, public Parented { BaudRateSelect() = default; protected: - void control(const std::string &value) override; + void control(size_t index) override; }; } // namespace ld2412 diff --git a/esphome/components/ld2412/select/distance_resolution_select.cpp b/esphome/components/ld2412/select/distance_resolution_select.cpp index a282215fbd..5a6f46a071 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.cpp +++ b/esphome/components/ld2412/select/distance_resolution_select.cpp @@ -3,9 +3,9 @@ namespace esphome { namespace ld2412 { -void DistanceResolutionSelect::control(const std::string &value) { - this->publish_state(value); - this->parent_->set_distance_resolution(state); +void DistanceResolutionSelect::control(size_t index) { + this->publish_state(index); + this->parent_->set_distance_resolution(this->option_at(index)); } } // namespace ld2412 diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index 0658f5d1a7..842f63b7b1 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -11,7 +11,7 @@ class DistanceResolutionSelect : public select::Select, public Parentedpublish_state(value); +void LightOutControlSelect::control(size_t index) { + this->publish_state(index); this->parent_->set_light_out_control(); } diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index a71bab1e14..7a50970d0d 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -11,7 +11,7 @@ class LightOutControlSelect : public select::Select, public Parentedtotal_sample_number_counter); } -void LD2420Component::set_operating_mode(const std::string &state) { +void LD2420Component::set_operating_mode(const char *state) { // If unsupported firmware ignore mode select if (ld2420::get_firmware_int(firmware_ver_) >= CALIBRATE_VERSION_MIN) { this->current_operating_mode = find_uint8(OP_MODE_BY_STR, state); diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 812c408cfd..128baab604 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -107,7 +107,7 @@ class LD2420Component : public Component, public uart::UARTDevice { int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); void handle_cmd_error(uint8_t error); - void set_operating_mode(const std::string &state); + void set_operating_mode(const char *state); void auto_calibrate_sensitivity(); void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number); uint8_t set_config_mode(bool enable); diff --git a/esphome/components/ld2420/select/operating_mode_select.cpp b/esphome/components/ld2420/select/operating_mode_select.cpp index 2d576e7cc6..5bf80b33c9 100644 --- a/esphome/components/ld2420/select/operating_mode_select.cpp +++ b/esphome/components/ld2420/select/operating_mode_select.cpp @@ -7,9 +7,9 @@ namespace ld2420 { static const char *const TAG = "ld2420.select"; -void LD2420Select::control(const std::string &value) { - this->publish_state(value); - this->parent_->set_operating_mode(value); +void LD2420Select::control(size_t index) { + this->publish_state(index); + this->parent_->set_operating_mode(this->option_at(index)); } } // namespace ld2420 diff --git a/esphome/components/ld2420/select/operating_mode_select.h b/esphome/components/ld2420/select/operating_mode_select.h index 317b2af8c0..f59eb33432 100644 --- a/esphome/components/ld2420/select/operating_mode_select.h +++ b/esphome/components/ld2420/select/operating_mode_select.h @@ -11,7 +11,7 @@ class LD2420Select : public Component, public select::Select, public Parentedset_config_mode_(false); #ifdef USE_SELECT const auto baud_rate = std::to_string(this->parent_->get_baud_rate()); - if (this->baud_rate_select_ != nullptr && this->baud_rate_select_->state != baud_rate) { + if (this->baud_rate_select_ != nullptr && strcmp(this->baud_rate_select_->current_option(), baud_rate.c_str()) != 0) { this->baud_rate_select_->publish_state(baud_rate); } this->publish_zone_type(); @@ -635,7 +635,7 @@ bool LD2450Component::handle_ack_data_() { ESP_LOGV(TAG, "Baud rate change"); #ifdef USE_SELECT if (this->baud_rate_select_ != nullptr) { - ESP_LOGE(TAG, "Change baud rate to %s and reinstall", this->baud_rate_select_->state.c_str()); + ESP_LOGE(TAG, "Change baud rate to %s and reinstall", this->baud_rate_select_->current_option()); } #endif break; @@ -716,7 +716,7 @@ bool LD2450Component::handle_ack_data_() { this->publish_zone_type(); #ifdef USE_SELECT if (this->zone_type_select_ != nullptr) { - ESP_LOGV(TAG, "Change zone type to: %s", this->zone_type_select_->state.c_str()); + ESP_LOGV(TAG, "Change zone type to: %s", this->zone_type_select_->current_option()); } #endif if (this->buffer_data_[10] == 0x00) { @@ -790,7 +790,7 @@ void LD2450Component::set_bluetooth(bool enable) { } // Set Baud rate -void LD2450Component::set_baud_rate(const std::string &state) { +void LD2450Component::set_baud_rate(const char *state) { this->set_config_mode_(true); const uint8_t cmd_value[2] = {find_uint8(BAUD_RATES_BY_STR, state), 0x00}; this->send_command_(CMD_SET_BAUD_RATE, cmd_value, sizeof(cmd_value)); @@ -798,8 +798,8 @@ void LD2450Component::set_baud_rate(const std::string &state) { } // Set Zone Type - one of: Disabled, Detection, Filter -void LD2450Component::set_zone_type(const std::string &state) { - ESP_LOGV(TAG, "Set zone type: %s", state.c_str()); +void LD2450Component::set_zone_type(const char *state) { + ESP_LOGV(TAG, "Set zone type: %s", state); uint8_t zone_type = find_uint8(ZONE_TYPE_BY_STR, state); this->zone_type_ = zone_type; this->send_set_zone_command_(); diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 9faa189019..44b63be444 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -115,8 +115,8 @@ class LD2450Component : public Component, public uart::UARTDevice { void restart_and_read_all_info(); void set_bluetooth(bool enable); void set_multi_target(bool enable); - void set_baud_rate(const std::string &state); - void set_zone_type(const std::string &state); + void set_baud_rate(const char *state); + void set_zone_type(const char *state); void publish_zone_type(); void factory_reset(); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/select/baud_rate_select.cpp b/esphome/components/ld2450/select/baud_rate_select.cpp index 06439aaa75..754972214e 100644 --- a/esphome/components/ld2450/select/baud_rate_select.cpp +++ b/esphome/components/ld2450/select/baud_rate_select.cpp @@ -3,9 +3,9 @@ namespace esphome { namespace ld2450 { -void BaudRateSelect::control(const std::string &value) { - this->publish_state(value); - this->parent_->set_baud_rate(state); +void BaudRateSelect::control(size_t index) { + this->publish_state(index); + this->parent_->set_baud_rate(this->option_at(index)); } } // namespace ld2450 diff --git a/esphome/components/ld2450/select/baud_rate_select.h b/esphome/components/ld2450/select/baud_rate_select.h index 04fe65b4fd..22810d5f13 100644 --- a/esphome/components/ld2450/select/baud_rate_select.h +++ b/esphome/components/ld2450/select/baud_rate_select.h @@ -11,7 +11,7 @@ class BaudRateSelect : public select::Select, public Parented { BaudRateSelect() = default; protected: - void control(const std::string &value) override; + void control(size_t index) override; }; } // namespace ld2450 diff --git a/esphome/components/ld2450/select/zone_type_select.cpp b/esphome/components/ld2450/select/zone_type_select.cpp index a9f6155142..1111428c7c 100644 --- a/esphome/components/ld2450/select/zone_type_select.cpp +++ b/esphome/components/ld2450/select/zone_type_select.cpp @@ -3,9 +3,9 @@ namespace esphome { namespace ld2450 { -void ZoneTypeSelect::control(const std::string &value) { - this->publish_state(value); - this->parent_->set_zone_type(state); +void ZoneTypeSelect::control(size_t index) { + this->publish_state(index); + this->parent_->set_zone_type(this->option_at(index)); } } // namespace ld2450 diff --git a/esphome/components/ld2450/select/zone_type_select.h b/esphome/components/ld2450/select/zone_type_select.h index 8aafeb6beb..fc95ec1021 100644 --- a/esphome/components/ld2450/select/zone_type_select.h +++ b/esphome/components/ld2450/select/zone_type_select.h @@ -11,7 +11,7 @@ class ZoneTypeSelect : public select::Select, public Parented { ZoneTypeSelect() = default; protected: - void control(const std::string &value) override; + void control(size_t index) override; }; } // namespace ld2450 diff --git a/esphome/components/libretiny/gpio.py b/esphome/components/libretiny/gpio.py index 07eb0ce133..9bad400eb7 100644 --- a/esphome/components/libretiny/gpio.py +++ b/esphome/components/libretiny/gpio.py @@ -199,6 +199,9 @@ async def component_pin_to_code(config): var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) + # Only set if true to avoid bloating setup() function + # (inverted bit in pin_flags_ bitfield is zero-initialized to false) + if config[CONF_INVERTED]: + cg.add(var.set_inverted(True)) cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) return var diff --git a/esphome/components/libretiny/gpio_arduino.h b/esphome/components/libretiny/gpio_arduino.h index 9adc425a41..3674748c18 100644 --- a/esphome/components/libretiny/gpio_arduino.h +++ b/esphome/components/libretiny/gpio_arduino.h @@ -27,8 +27,8 @@ class ArduinoInternalGPIOPin : public InternalGPIOPin { void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + bool inverted_{}; + gpio::Flags flags_{}; }; } // namespace libretiny diff --git a/esphome/components/light/addressable_light_effect.h b/esphome/components/light/addressable_light_effect.h index 9840112040..0847db3770 100644 --- a/esphome/components/light/addressable_light_effect.h +++ b/esphome/components/light/addressable_light_effect.h @@ -57,9 +57,9 @@ class AddressableLightEffect : public LightEffect { class AddressableLambdaLightEffect : public AddressableLightEffect { public: - AddressableLambdaLightEffect(const char *name, std::function f, + AddressableLambdaLightEffect(const char *name, void (*f)(AddressableLight &, Color, bool initial_run), uint32_t update_interval) - : AddressableLightEffect(name), f_(std::move(f)), update_interval_(update_interval) {} + : AddressableLightEffect(name), f_(f), update_interval_(update_interval) {} void start() override { this->initial_run_ = true; } void apply(AddressableLight &it, const Color ¤t_color) override { const uint32_t now = millis(); @@ -72,7 +72,7 @@ class AddressableLambdaLightEffect : public AddressableLightEffect { } protected: - std::function f_; + void (*f_)(AddressableLight &, Color, bool initial_run); uint32_t update_interval_; uint32_t last_run_{0}; bool initial_run_; diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index 327c243525..515afc5c59 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -112,8 +112,8 @@ class RandomLightEffect : public LightEffect { class LambdaLightEffect : public LightEffect { public: - LambdaLightEffect(const char *name, std::function f, uint32_t update_interval) - : LightEffect(name), f_(std::move(f)), update_interval_(update_interval) {} + LambdaLightEffect(const char *name, void (*f)(bool initial_run), uint32_t update_interval) + : LightEffect(name), f_(f), update_interval_(update_interval) {} void start() override { this->initial_run_ = true; } void apply() override { @@ -130,7 +130,7 @@ class LambdaLightEffect : public LightEffect { uint32_t get_current_index() const { return this->get_index(); } protected: - std::function f_; + void (*f_)(bool initial_run); uint32_t update_interval_; uint32_t last_run_{0}; bool initial_run_; diff --git a/esphome/components/light/light_effect.h b/esphome/components/light/light_effect.h index 7b734c2001..d4c2dc3582 100644 --- a/esphome/components/light/light_effect.h +++ b/esphome/components/light/light_effect.h @@ -22,6 +22,10 @@ class LightEffect { /// Apply this effect. Use the provided state for starting transitions, ... virtual void apply() = 0; + /** + * Returns the name of this effect. + * The returned pointer is valid for the lifetime of the program and must not be freed. + */ const char *get_name() const { return this->name_; } /// Internal method called by the LightState when this light effect is registered in it. diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 04c1586ddd..9d893d3ad9 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -91,11 +91,6 @@ def lock_schema( return _LOCK_SCHEMA.extend(schema) -# Remove before 2025.11.0 -LOCK_SCHEMA = lock_schema() -LOCK_SCHEMA.add_extra(cv.deprecated_schema_constant("lock")) - - async def _setup_lock_core(var, config): await setup_entity(var, config, "lock") diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 1d02073d27..cf78e6ae63 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -1,7 +1,7 @@ import re from esphome import automation -from esphome.automation import LambdaAction +from esphome.automation import LambdaAction, StatelessLambdaAction import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option, get_esp32_variant from esphome.components.esp32.const import ( @@ -173,14 +173,34 @@ def uart_selection(value): raise NotImplementedError -def validate_local_no_higher_than_global(value): - global_level = LOG_LEVEL_SEVERITY.index(value[CONF_LEVEL]) - for tag, level in value.get(CONF_LOGS, {}).items(): - if LOG_LEVEL_SEVERITY.index(level) > global_level: - raise cv.Invalid( - f"The configured log level for {tag} ({level}) must be no more severe than the global log level {value[CONF_LEVEL]}." +def validate_local_no_higher_than_global(config): + global_level = config[CONF_LEVEL] + global_level_index = LOG_LEVEL_SEVERITY.index(global_level) + errs = [] + for tag, level in config.get(CONF_LOGS, {}).items(): + if LOG_LEVEL_SEVERITY.index(level) > global_level_index: + errs.append( + cv.Invalid( + f"The configured log level for {tag} ({level}) must not be less severe than the global log level ({global_level})", + [CONF_LOGS, tag], + ) ) - return value + if errs: + raise cv.MultipleInvalid(errs) + return config + + +def validate_initial_no_higher_than_global(config): + if initial_level := config.get(CONF_INITIAL_LEVEL): + global_level = config[CONF_LEVEL] + if LOG_LEVEL_SEVERITY.index(initial_level) > LOG_LEVEL_SEVERITY.index( + global_level + ): + raise cv.Invalid( + f"The initial log level ({initial_level}) must not be less severe than the global log level ({global_level})", + [CONF_INITIAL_LEVEL], + ) + return config Logger = logger_ns.class_("Logger", cg.Component) @@ -263,6 +283,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_local_no_higher_than_global, + validate_initial_no_higher_than_global, ) @@ -430,7 +451,9 @@ async def logger_log_action_to_code(config, action_id, template_arg, args): text = str(cg.statement(esp_log(config[CONF_TAG], config[CONF_FORMAT], *args_))) lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) - return cg.new_Pvariable(action_id, template_arg, lambda_) + return automation.new_lambda_pvariable( + action_id, lambda_, StatelessLambdaAction, template_arg + ) @automation.register_action( @@ -455,7 +478,9 @@ async def logger_set_level_to_code(config, action_id, template_arg, args): text = str(cg.statement(logger.set_log_level(level))) lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) - return cg.new_Pvariable(action_id, template_arg, lambda_) + return automation.new_lambda_pvariable( + action_id, lambda_, StatelessLambdaAction, template_arg + ) FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/logger/select/logger_level_select.cpp b/esphome/components/logger/select/logger_level_select.cpp index 6d60a3ae47..e2ec28a390 100644 --- a/esphome/components/logger/select/logger_level_select.cpp +++ b/esphome/components/logger/select/logger_level_select.cpp @@ -3,10 +3,10 @@ namespace esphome::logger { void LoggerLevelSelect::publish_state(int level) { - const auto &option = this->at(level_to_index(level)); - if (!option) + auto index = level_to_index(level); + if (!this->has_index(index)) return; - Select::publish_state(option.value()); + Select::publish_state(index); } void LoggerLevelSelect::setup() { @@ -14,11 +14,6 @@ void LoggerLevelSelect::setup() { this->publish_state(this->parent_->get_log_level()); } -void LoggerLevelSelect::control(const std::string &value) { - const auto index = this->index_of(value); - if (!index) - return; - this->parent_->set_log_level(index_to_level(index.value())); -} +void LoggerLevelSelect::control(size_t index) { this->parent_->set_log_level(index_to_level(index)); } } // namespace esphome::logger diff --git a/esphome/components/logger/select/logger_level_select.h b/esphome/components/logger/select/logger_level_select.h index 0631eca45d..950edd29ac 100644 --- a/esphome/components/logger/select/logger_level_select.h +++ b/esphome/components/logger/select/logger_level_select.h @@ -9,7 +9,7 @@ class LoggerLevelSelect : public Component, public select::Select, public Parent public: void publish_state(int level); void setup() override; - void control(const std::string &value) override; + void control(size_t index) override; protected: // Convert log level to option index (skip CONFIG at level 4) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 5af61300da..aa6935c5fc 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -58,7 +58,7 @@ from .types import ( FontEngine, IdleTrigger, ObjUpdateAction, - PauseTrigger, + PlainTrigger, lv_font_t, lv_group_t, lv_style_t, @@ -151,6 +151,13 @@ for w_type in WIDGET_TYPES.values(): create_modify_schema(w_type), )(update_to_code) +SIMPLE_TRIGGERS = ( + df.CONF_ON_PAUSE, + df.CONF_ON_RESUME, + df.CONF_ON_DRAW_START, + df.CONF_ON_DRAW_END, +) + def as_macro(macro, value): if value is None: @@ -244,9 +251,9 @@ def final_validation(configs): for w in refreshed_widgets: path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) - if not any(isinstance(v, Lambda) for v in widget_conf.values()): + if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): raise cv.Invalid( - f"Widget '{w}' does not have any templated properties to refresh", + f"Widget '{w}' does not have any dynamic properties to refresh", ) @@ -366,16 +373,16 @@ async def to_code(configs): conf[CONF_TRIGGER_ID], lv_component, templ ) await build_automation(idle_trigger, [], conf) - for conf in config.get(df.CONF_ON_PAUSE, ()): - pause_trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], lv_component, True - ) - await build_automation(pause_trigger, [], conf) - for conf in config.get(df.CONF_ON_RESUME, ()): - resume_trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], lv_component, False - ) - await build_automation(resume_trigger, [], conf) + for trigger_name in SIMPLE_TRIGGERS: + if conf := config.get(trigger_name): + trigger_var = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await build_automation(trigger_var, [], conf) + cg.add( + getattr( + lv_component, + f"set_{trigger_name.removeprefix('on_')}_trigger", + )(trigger_var) + ) await add_on_boot_triggers(config.get(CONF_ON_BOOT, ())) # This must be done after all widgets are created @@ -443,16 +450,15 @@ LVGL_SCHEMA = cv.All( ), } ), - cv.Optional(df.CONF_ON_PAUSE): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PauseTrigger), - } - ), - cv.Optional(df.CONF_ON_RESUME): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PauseTrigger), - } - ), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, cv.Exclusive(df.CONF_WIDGETS, CONF_PAGES): cv.ensure_list( WIDGET_SCHEMA ), diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index fc70b0f682..593c8c67bb 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -400,7 +400,8 @@ async def obj_refresh_to_code(config, action_id, template_arg, args): # must pass all widget-specific options here, even if not templated, but only do so if at least one is # templated. First filter out common style properties. config = {k: v for k, v in widget.config.items() if k not in ALL_STYLES} - if any(isinstance(v, Lambda) for v in config.values()): + # Check if v is a Lambda or a dict, implying it is dynamic + if any(isinstance(v, (Lambda, dict)) for v in config.values()): await widget.type.to_code(widget, config) if ( widget.type.w_type.value_property is not None diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index ffbdc977b2..f9df7d23fa 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -31,7 +31,7 @@ async def to_code(config): lvgl_static.add_event_cb( widget.obj, await pressed_ctx.get_lambda(), - LV_EVENT.PRESSING, + LV_EVENT.PRESSED, LV_EVENT.RELEASED, ) ) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 6464824c64..3241ba9c3f 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -5,6 +5,7 @@ Constants already defined in esphome.const are not duplicated here and must be i """ import logging +from typing import TYPE_CHECKING, Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS @@ -12,6 +13,7 @@ from esphome.core import ID, Lambda from esphome.cpp_generator import LambdaExpression, MockObj from esphome.cpp_types import uint32 from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import Expression, SafeExpType from .helpers import requires_component @@ -42,7 +44,13 @@ def static_cast(type, value): def call_lambda(lamb: LambdaExpression): expr = lamb.content.strip() if expr.startswith("return") and expr.endswith(";"): - return expr[6:][:-1].strip() + return expr[6:-1].strip() + # If lambda has parameters, call it with those parameter names + # Parameter names come from hardcoded component code (like "x", "it", "event") + # not from user input, so they're safe to use directly + if lamb.parameters and lamb.parameters.parameters: + param_names = ", ".join(str(param.id) for param in lamb.parameters.parameters) + return f"{lamb}({param_names})" return f"{lamb}()" @@ -65,10 +73,20 @@ class LValidator: return cv.returning_lambda(value) return self.validator(value) - async def process(self, value, args=()): + async def process( + self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + ) -> Expression: if value is None: return None if isinstance(value, Lambda): + # Local import to avoid circular import + from .lvcode import CodeContext, LambdaContext + + if TYPE_CHECKING: + # CodeContext does not have get_automation_parameters + # so we need to assert the type here + assert isinstance(CodeContext.code_context, LambdaContext) + args = args or CodeContext.code_context.get_automation_parameters() return cg.RawExpression( call_lambda( await cg.process_lambda(value, args, return_type=self.rtype) @@ -465,6 +483,8 @@ CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" CONF_ONE_LINE = "one_line" +CONF_ON_DRAW_START = "on_draw_start" +CONF_ON_DRAW_END = "on_draw_end" CONF_ON_PAUSE = "on_pause" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index d345ac70f3..6f95a32a18 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING, Any + import esphome.codegen as cg from esphome.components import image from esphome.components.color import CONF_HEX, ColorStruct, from_rgbw @@ -17,6 +19,7 @@ from esphome.cpp_generator import MockObj from esphome.cpp_types import ESPTime, int32, uint32 from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import Expression, SafeExpType from . import types as ty from .defines import ( @@ -388,11 +391,23 @@ class TextValidator(LValidator): return value return super().__call__(value) - async def process(self, value, args=()): + async def process( + self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + ) -> Expression: + # Local import to avoid circular import at module level + + from .lvcode import CodeContext, LambdaContext + + if TYPE_CHECKING: + # CodeContext does not have get_automation_parameters + # so we need to assert the type here + assert isinstance(CodeContext.code_context, LambdaContext) + args = args or CodeContext.code_context.get_automation_parameters() + if isinstance(value, dict): if format_str := value.get(CONF_FORMAT): - args = [str(x) for x in value[CONF_ARGS]] - arg_expr = cg.RawExpression(",".join(args)) + str_args = [str(x) for x in value[CONF_ARGS]] + arg_expr = cg.RawExpression(",".join(str_args)) format_str = cpp_string_escape(format_str) return literal(f"str_sprintf({format_str}, {arg_expr}).c_str()") if time_format := value.get(CONF_TIME_FORMAT): diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 7a5c35f896..ea38845c07 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -164,6 +164,9 @@ class LambdaContext(CodeContext): code_text.append(text) return code_text + def get_automation_parameters(self) -> list[tuple[SafeExpType, str]]: + return self.parameters + async def __aenter__(self): await super().__aenter__() add_line_marks(self.where) @@ -178,9 +181,8 @@ class LvContext(LambdaContext): added_lambda_count = 0 - def __init__(self, args=None): - self.args = args or LVGL_COMP_ARG - super().__init__(parameters=self.args) + def __init__(self): + super().__init__(parameters=LVGL_COMP_ARG) async def __aexit__(self, exc_type, exc_val, exc_tb): await super().__aexit__(exc_type, exc_val, exc_tb) @@ -189,6 +191,11 @@ class LvContext(LambdaContext): cg.add(expression) return expression + def get_automation_parameters(self) -> list[tuple[SafeExpType, str]]: + # When generating automations, we don't want the `lv_component` parameter to be passed + # to the lambda. + return [] + def __call__(self, *args): return self.add(*args) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 7a32691b53..947342089c 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -82,6 +82,18 @@ static void rounder_cb(lv_disp_drv_t *disp_drv, lv_area_t *area) { area->y2 = (area->y2 + draw_rounding) / draw_rounding * draw_rounding - 1; } +void LvglComponent::monitor_cb(lv_disp_drv_t *disp_drv, uint32_t time, uint32_t px) { + ESP_LOGVV(TAG, "Draw end: %" PRIu32 " pixels in %" PRIu32 " ms", px, time); + auto *comp = static_cast(disp_drv->user_data); + comp->draw_end_(); +} + +void LvglComponent::render_start_cb(lv_disp_drv_t *disp_drv) { + ESP_LOGVV(TAG, "Draw start"); + auto *comp = static_cast(disp_drv->user_data); + comp->draw_start_(); +} + lv_event_code_t lv_api_event; // NOLINT lv_event_code_t lv_update_event; // NOLINT void LvglComponent::dump_config() { @@ -101,7 +113,10 @@ void LvglComponent::set_paused(bool paused, bool show_snow) { lv_disp_trig_activity(this->disp_); // resets the inactivity time lv_obj_invalidate(lv_scr_act()); } - this->pause_callbacks_.call(paused); + if (paused && this->pause_callback_ != nullptr) + this->pause_callback_->trigger(); + if (!paused && this->resume_callback_ != nullptr) + this->resume_callback_->trigger(); } void LvglComponent::esphome_lvgl_init() { @@ -225,13 +240,6 @@ IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue timeo }); } -PauseTrigger::PauseTrigger(LvglComponent *parent, TemplatableValue paused) : paused_(std::move(paused)) { - parent->add_on_pause_callback([this](bool pausing) { - if (this->paused_.value() == pausing) - this->trigger(); - }); -} - #ifdef USE_LVGL_TOUCHSCREEN LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent) { this->set_parent(parent); @@ -474,6 +482,12 @@ void LvglComponent::setup() { return; } } + if (this->draw_start_callback_ != nullptr) { + this->disp_drv_.render_start_cb = render_start_cb; + } + if (this->draw_end_callback_ != nullptr) { + this->disp_drv_.monitor_cb = monitor_cb; + } #if LV_USE_LOG lv_log_register_print_cb([](const char *buf) { auto next = strchr(buf, ')'); @@ -502,8 +516,9 @@ void LvglComponent::loop() { if (this->paused_) { if (this->show_snow_) this->write_random_(); + } else { + lv_timer_handler_run_in_period(5); } - lv_timer_handler_run_in_period(5); } #ifdef USE_LVGL_ANIMIMG diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3ae67e8a0b..ea58fdb85b 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -171,7 +171,9 @@ class LvglComponent : public PollingComponent { void add_on_idle_callback(std::function &&callback) { this->idle_callbacks_.add(std::move(callback)); } - void add_on_pause_callback(std::function &&callback) { this->pause_callbacks_.add(std::move(callback)); } + + static void monitor_cb(lv_disp_drv_t *disp_drv, uint32_t time, uint32_t px); + static void render_start_cb(lv_disp_drv_t *disp_drv); void dump_config() override; bool is_idle(uint32_t idle_ms) { return lv_disp_get_inactive_time(this->disp_) > idle_ms; } lv_disp_t *get_disp() { return this->disp_; } @@ -213,12 +215,20 @@ class LvglComponent : public PollingComponent { size_t draw_rounding{2}; display::DisplayRotation rotation{display::DISPLAY_ROTATION_0_DEGREES}; + void set_pause_trigger(Trigger<> *trigger) { this->pause_callback_ = trigger; } + void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; } + void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; } + void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; } protected: + // these functions are never called unless the callbacks are non-null since the + // LVGL callbacks that call them are not set unless the start/end callbacks are non-null + void draw_start_() const { this->draw_start_callback_->trigger(); } + void draw_end_() const { this->draw_end_callback_->trigger(); } + void write_random_(); void draw_buffer_(const lv_area_t *area, lv_color_t *ptr); void flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p); - std::vector displays_{}; size_t buffer_frac_{1}; bool full_refresh_{}; @@ -235,7 +245,10 @@ class LvglComponent : public PollingComponent { std::map focus_marks_{}; CallbackManager idle_callbacks_{}; - CallbackManager pause_callbacks_{}; + Trigger<> *pause_callback_{}; + Trigger<> *resume_callback_{}; + Trigger<> *draw_start_callback_{}; + Trigger<> *draw_end_callback_{}; lv_color_t *rotate_buf_{}; }; @@ -248,14 +261,6 @@ class IdleTrigger : public Trigger<> { bool is_idle_{}; }; -class PauseTrigger : public Trigger<> { - public: - explicit PauseTrigger(LvglComponent *parent, TemplatableValue paused); - - protected: - TemplatableValue paused_; -}; - template class LvglAction : public Action, public Parented { public: explicit LvglAction(std::function &&lamb) : action_(std::move(lamb)) {} @@ -358,7 +363,7 @@ class LvSelectable : public LvCompound { virtual void set_selected_index(size_t index, lv_anim_enable_t anim) = 0; void set_selected_text(const std::string &text, lv_anim_enable_t anim); std::string get_selected_text(); - std::vector get_options() { return this->options_; } + const std::vector &get_options() { return this->options_; } void set_options(std::vector options); protected: diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index a0e60295a6..3b1fd67d68 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -53,7 +53,17 @@ class LVGLSelect : public select::Select, public Component { this->widget_->set_selected_text(value, this->anim_); this->publish(); } - void set_options_() { this->traits.set_options(this->widget_->get_options()); } + void set_options_() { + // Widget uses std::vector, SelectTraits uses FixedVector + // Convert by extracting c_str() pointers + const auto &opts = this->widget_->get_options(); + FixedVector opt_ptrs; + opt_ptrs.init(opts.size()); + for (size_t i = 0; i < opts.size(); i++) { + opt_ptrs[i] = opts[i].c_str(); + } + this->traits.set_options(opt_ptrs); + } LvSelectable *widget_; lv_anim_enable_t anim_; diff --git a/esphome/components/lvgl/sensor/__init__.py b/esphome/components/lvgl/sensor/__init__.py index 03b2638ed0..167af9c6e1 100644 --- a/esphome/components/lvgl/sensor/__init__.py +++ b/esphome/components/lvgl/sensor/__init__.py @@ -5,7 +5,6 @@ from ..defines import CONF_WIDGET from ..lvcode import ( API_EVENT, EVENT_ARG, - LVGL_COMP_ARG, UPDATE_EVENT, LambdaContext, LvContext, @@ -30,7 +29,7 @@ async def to_code(config): await wait_for_widgets() async with LambdaContext(EVENT_ARG) as lamb: lv_add(sensor.publish_state(widget.get_value())) - async with LvContext(LVGL_COMP_ARG): + async with LvContext(): lv_add( lvgl_static.add_event_cb( widget.obj, diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index c19c89401a..9955b530aa 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -3,6 +3,7 @@ import sys from esphome import automation, codegen as cg from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_TEXT, CONF_VALUE from esphome.cpp_generator import MockObj, MockObjClass +from esphome.cpp_types import esphome_ns from .defines import lvgl_ns from .lvcode import lv_expr @@ -42,8 +43,11 @@ lv_event_code_t = cg.global_ns.enum("lv_event_code_t") lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t") lv_key_t = cg.global_ns.enum("lv_key_t") FontEngine = lvgl_ns.class_("FontEngine") +PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template()) +DrawEndTrigger = esphome_ns.class_( + "Trigger", automation.Trigger.template(cg.uint32, cg.uint32) +) IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template()) -PauseTrigger = lvgl_ns.class_("PauseTrigger", automation.Trigger.template()) ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action) LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition) LvglAction = lvgl_ns.class_("LvglAction", automation.Action) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 217e8935f1..f0a9cd35ba 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -33,7 +33,7 @@ from ..lv_validation import ( pixels, size, ) -from ..lvcode import LocalVariable, lv, lv_assign +from ..lvcode import LocalVariable, lv, lv_assign, lv_expr from ..schemas import STYLE_PROPS, STYLE_REMAP, TEXT_SCHEMA, point_schema from ..types import LvType, ObjUpdateAction, WidgetType from . import Widget, get_widgets @@ -70,15 +70,18 @@ class CanvasType(WidgetType): width = config[CONF_WIDTH] height = config[CONF_HEIGHT] use_alpha = "_ALPHA" if config[CONF_TRANSPARENT] else "" - lv.canvas_set_buffer( - w.obj, - lv.custom_mem_alloc( - literal(f"LV_CANVAS_BUF_SIZE_TRUE_COLOR{use_alpha}({width}, {height})") - ), - width, - height, - literal(f"LV_IMG_CF_TRUE_COLOR{use_alpha}"), + buf_size = literal( + f"LV_CANVAS_BUF_SIZE_TRUE_COLOR{use_alpha}({width}, {height})" ) + with LocalVariable("buf", cg.void, lv_expr.custom_mem_alloc(buf_size)) as buf: + cg.add(cg.RawExpression(f"memset({buf}, 0, {buf_size});")) + lv.canvas_set_buffer( + w.obj, + buf, + width, + height, + literal(f"LV_IMG_CF_TRUE_COLOR{use_alpha}"), + ) canvas_spec = CanvasType() diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 70c7cf7a56..c6ffe50d79 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -192,10 +192,6 @@ def media_player_schema( return _MEDIA_PLAYER_SCHEMA.extend(schema) -# Remove before 2025.11.0 -MEDIA_PLAYER_SCHEMA = media_player_schema(MediaPlayer) -MEDIA_PLAYER_SCHEMA.add_extra(cv.deprecated_schema_constant("media_player")) - MEDIA_PLAYER_ACTION_SCHEMA = automation.maybe_simple_id( cv.Schema( { diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 2837713c35..dca4038f04 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -8,9 +8,9 @@ namespace midea { namespace ac { const char *const Constants::TAG = "midea"; -const std::string Constants::FREEZE_PROTECTION = "freeze protection"; -const std::string Constants::SILENT = "silent"; -const std::string Constants::TURBO = "turbo"; +const char *const Constants::FREEZE_PROTECTION = "freeze protection"; +const char *const Constants::SILENT = "silent"; +const char *const Constants::TURBO = "turbo"; ClimateMode Converters::to_climate_mode(MideaMode mode) { switch (mode) { @@ -108,7 +108,7 @@ bool Converters::is_custom_midea_fan_mode(MideaFanMode mode) { } } -const std::string &Converters::to_custom_climate_fan_mode(MideaFanMode mode) { +const char *Converters::to_custom_climate_fan_mode(MideaFanMode mode) { switch (mode) { case MideaFanMode::FAN_SILENT: return Constants::SILENT; @@ -151,7 +151,7 @@ ClimatePreset Converters::to_climate_preset(MideaPreset preset) { bool Converters::is_custom_midea_preset(MideaPreset preset) { return preset == MideaPreset::PRESET_FREEZE_PROTECTION; } -const std::string &Converters::to_custom_climate_preset(MideaPreset preset) { return Constants::FREEZE_PROTECTION; } +const char *Converters::to_custom_climate_preset(MideaPreset preset) { return Constants::FREEZE_PROTECTION; } MideaPreset Converters::to_midea_preset(const std::string &preset) { return MideaPreset::PRESET_FREEZE_PROTECTION; } @@ -169,7 +169,7 @@ void Converters::to_climate_traits(ClimateTraits &traits, const dudanov::midea:: if (capabilities.supportEcoPreset()) traits.add_supported_preset(ClimatePreset::CLIMATE_PRESET_ECO); if (capabilities.supportFrostProtectionPreset()) - traits.add_supported_custom_preset(Constants::FREEZE_PROTECTION); + traits.set_supported_custom_presets({Constants::FREEZE_PROTECTION}); } } // namespace ac diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index c17894ae31..d52f421331 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -20,9 +20,9 @@ using MideaPreset = dudanov::midea::ac::Preset; class Constants { public: static const char *const TAG; - static const std::string FREEZE_PROTECTION; - static const std::string SILENT; - static const std::string TURBO; + static const char *const FREEZE_PROTECTION; + static const char *const SILENT; + static const char *const TURBO; }; class Converters { @@ -35,12 +35,12 @@ class Converters { static MideaPreset to_midea_preset(const std::string &preset); static bool is_custom_midea_preset(MideaPreset preset); static ClimatePreset to_climate_preset(MideaPreset preset); - static const std::string &to_custom_climate_preset(MideaPreset preset); + static const char *to_custom_climate_preset(MideaPreset preset); static MideaFanMode to_midea_fan_mode(ClimateFanMode fan_mode); static MideaFanMode to_midea_fan_mode(const std::string &fan_mode); static bool is_custom_midea_fan_mode(MideaFanMode fan_mode); static ClimateFanMode to_climate_fan_mode(MideaFanMode fan_mode); - static const std::string &to_custom_climate_fan_mode(MideaFanMode fan_mode); + static const char *to_custom_climate_fan_mode(MideaFanMode fan_mode); static void to_climate_traits(ClimateTraits &traits, const dudanov::midea::ac::Capabilities &capabilities); }; diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 0ad26ebd51..97eacb936c 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -84,8 +84,10 @@ ClimateTraits AirConditioner::traits() { traits.set_supported_modes(this->supported_modes_); traits.set_supported_swing_modes(this->supported_swing_modes_); traits.set_supported_presets(this->supported_presets_); - traits.set_supported_custom_presets(this->supported_custom_presets_); - traits.set_supported_custom_fan_modes(this->supported_custom_fan_modes_); + if (!this->supported_custom_presets_.empty()) + traits.set_supported_custom_presets(this->supported_custom_presets_); + if (!this->supported_custom_fan_modes_.empty()) + traits.set_supported_custom_fan_modes(this->supported_custom_fan_modes_); /* + MINIMAL SET OF CAPABILITIES */ traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_AUTO); traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_LOW); diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index e70bd34e71..70833b8bcc 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -19,6 +19,9 @@ using climate::ClimateTraits; using climate::ClimateMode; using climate::ClimateSwingMode; using climate::ClimateFanMode; +using climate::ClimateModeMask; +using climate::ClimateSwingModeMask; +using climate::ClimatePresetMask; class AirConditioner : public ApplianceBase, public climate::Climate { public: @@ -40,20 +43,20 @@ class AirConditioner : public ApplianceBase, void do_power_on() { this->base_.setPowerState(true); } void do_power_off() { this->base_.setPowerState(false); } void do_power_toggle() { this->base_.setPowerState(this->mode == ClimateMode::CLIMATE_MODE_OFF); } - void set_supported_modes(const std::set &modes) { this->supported_modes_ = modes; } - void set_supported_swing_modes(const std::set &modes) { this->supported_swing_modes_ = modes; } - void set_supported_presets(const std::set &presets) { this->supported_presets_ = presets; } - void set_custom_presets(const std::set &presets) { this->supported_custom_presets_ = presets; } - void set_custom_fan_modes(const std::set &modes) { this->supported_custom_fan_modes_ = modes; } + void set_supported_modes(ClimateModeMask modes) { this->supported_modes_ = modes; } + void set_supported_swing_modes(ClimateSwingModeMask modes) { this->supported_swing_modes_ = modes; } + void set_supported_presets(ClimatePresetMask presets) { this->supported_presets_ = presets; } + void set_custom_presets(std::initializer_list presets) { this->supported_custom_presets_ = presets; } + void set_custom_fan_modes(std::initializer_list modes) { this->supported_custom_fan_modes_ = modes; } protected: void control(const ClimateCall &call) override; ClimateTraits traits() override; - std::set supported_modes_{}; - std::set supported_swing_modes_{}; - std::set supported_presets_{}; - std::set supported_custom_presets_{}; - std::set supported_custom_fan_modes_{}; + ClimateModeMask supported_modes_{}; + ClimateSwingModeMask supported_swing_modes_{}; + ClimatePresetMask supported_presets_{}; + std::vector supported_custom_presets_{}; + std::vector supported_custom_fan_modes_{}; Sensor *outdoor_sensor_{nullptr}; Sensor *humidity_sensor_{nullptr}; Sensor *power_sensor_{nullptr}; diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 4dff1af62a..93d1750cd6 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -384,6 +384,18 @@ class DriverChip: transform[CONF_TRANSFORM] = True return transform + def swap_xy_schema(self): + uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED + + def validator(value): + if value: + raise cv.Invalid("Axis swapping not supported by this model") + return cv.boolean(value) + + if uses_swap: + return {cv.Required(CONF_SWAP_XY): cv.boolean} + return {cv.Optional(CONF_SWAP_XY, default=False): validator} + def add_madctl(self, sequence: list, config: dict): # Add the MADCTL command to the sequence based on the configuration. use_flip = config.get(CONF_USE_AXIS_FLIPS) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 3001d33980..9d6b1fa729 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -46,6 +46,7 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, + CONF_DISABLED, CONF_ENABLE_PIN, CONF_GREEN, CONF_HSYNC_PIN, @@ -117,16 +118,16 @@ def data_pin_set(length): def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - if transforms := model.transforms: - transform = cv.Schema({cv.Required(x): cv.boolean for x in transforms}) - for x in (CONF_SWAP_XY, CONF_MIRROR_X, CONF_MIRROR_Y): - if x not in transforms: - transform = transform.extend( - {cv.Optional(x): cv.invalid(f"{x} not supported by this model")} - ) - else: - transform = cv.invalid("This model does not support transforms") - + transform = cv.Any( + cv.Schema( + { + cv.Required(CONF_MIRROR_X): cv.boolean, + cv.Required(CONF_MIRROR_Y): cv.boolean, + **model.swap_xy_schema(), + } + ), + cv.one_of(CONF_DISABLED, lower=True), + ) # RPI model does not use an init sequence, indicates with empty list if model.initsequence is None: # Custom model requires an init sequence @@ -135,12 +136,16 @@ def model_schema(config): else: iseqconf = cv.Optional(CONF_INIT_SEQUENCE) uses_spi = CONF_INIT_SEQUENCE in config or len(model.initsequence) != 0 - swap_xy = config.get(CONF_TRANSFORM, {}).get(CONF_SWAP_XY, False) - - # Dimensions are optional if the model has a default width and the swap_xy transform is not overridden - cv_dimensions = ( - cv.Optional if model.get_default(CONF_WIDTH) and not swap_xy else cv.Required + # Dimensions are optional if the model has a default width and the x-y transform is not overridden + transform_config = config.get(CONF_TRANSFORM, {}) + is_swapped = ( + isinstance(transform_config, dict) + and transform_config.get(CONF_SWAP_XY, False) is True ) + cv_dimensions = ( + cv.Optional if model.get_default(CONF_WIDTH) and not is_swapped else cv.Required + ) + pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_18BIT, "16", "18") schema = display.FULL_DISPLAY_SCHEMA.extend( { @@ -157,7 +162,7 @@ def model_schema(config): model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of( *pixel_modes, lower=True ), - model.option(CONF_TRANSFORM, cv.UNDEFINED): transform, + cv.Optional(CONF_TRANSFORM): transform, cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True), model.option(CONF_INVERT_COLORS, False): cv.boolean, model.option(CONF_USE_AXIS_FLIPS, True): cv.boolean, @@ -270,7 +275,6 @@ async def to_code(config): cg.add(var.set_vsync_front_porch(config[CONF_VSYNC_FRONT_PORCH])) cg.add(var.set_pclk_inverted(config[CONF_PCLK_INVERTED])) cg.add(var.set_pclk_frequency(config[CONF_PCLK_FREQUENCY])) - index = 0 dpins = [] if CONF_RED in config[CONF_DATA_PINS]: red_pins = config[CONF_DATA_PINS][CONF_RED] diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 891c8b42ff..50ea826eab 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -131,19 +131,6 @@ def denominator(config): ) from StopIteration -def swap_xy_schema(model): - uses_swap = model.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED - - def validator(value): - if value: - raise cv.Invalid("Axis swapping not supported by this model") - return cv.boolean(value) - - if uses_swap: - return {cv.Required(CONF_SWAP_XY): cv.boolean} - return {cv.Optional(CONF_SWAP_XY, default=False): validator} - - def model_schema(config): model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] @@ -152,7 +139,7 @@ def model_schema(config): { cv.Required(CONF_MIRROR_X): cv.boolean, cv.Required(CONF_MIRROR_Y): cv.boolean, - **swap_xy_schema(model), + **model.swap_xy_schema(), } ), cv.one_of(CONF_DISABLED, lower=True), diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 3a017c6f88..119f4fdd5a 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -33,8 +33,8 @@ class ModbusBinarySensor : public Component, public binary_sensor::BinarySensor, void dump_config() override; - using transform_func_t = std::function(ModbusBinarySensor *, bool, const std::vector &)>; - void set_template(transform_func_t &&f) { this->transform_func_ = f; } + using transform_func_t = optional (*)(ModbusBinarySensor *, bool, const std::vector &); + void set_template(transform_func_t f) { this->transform_func_ = f; } protected: optional transform_func_{nullopt}; diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index 8f77b2e014..169f85ff36 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -31,10 +31,10 @@ class ModbusNumber : public number::Number, public Component, public SensorItem void set_parent(ModbusController *parent) { this->parent_ = parent; } void set_write_multiply(float factor) { this->multiply_by_ = factor; } - using transform_func_t = std::function(ModbusNumber *, float, const std::vector &)>; - using write_transform_func_t = std::function(ModbusNumber *, float, std::vector &)>; - void set_template(transform_func_t &&f) { this->transform_func_ = f; } - void set_write_template(write_transform_func_t &&f) { this->write_transform_func_ = f; } + using transform_func_t = optional (*)(ModbusNumber *, float, const std::vector &); + using write_transform_func_t = optional (*)(ModbusNumber *, float, std::vector &); + void set_template(transform_func_t f) { this->transform_func_ = f; } + void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } protected: diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index bceb97affb..0fb4bb89ea 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -29,8 +29,8 @@ class ModbusFloatOutput : public output::FloatOutput, public Component, public S // Do nothing void parse_and_publish(const std::vector &data) override{}; - using write_transform_func_t = std::function(ModbusFloatOutput *, float, std::vector &)>; - void set_write_template(write_transform_func_t &&f) { this->write_transform_func_ = f; } + using write_transform_func_t = optional (*)(ModbusFloatOutput *, float, std::vector &); + void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } protected: @@ -60,8 +60,8 @@ class ModbusBinaryOutput : public output::BinaryOutput, public Component, public // Do nothing void parse_and_publish(const std::vector &data) override{}; - using write_transform_func_t = std::function(ModbusBinaryOutput *, bool, std::vector &)>; - void set_write_template(write_transform_func_t &&f) { this->write_transform_func_ = f; } + using write_transform_func_t = optional (*)(ModbusBinaryOutput *, bool, std::vector &); + void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } protected: diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index 56b8c783ed..48bf2835f2 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -28,7 +28,7 @@ void ModbusSelect::parse_and_publish(const std::vector &data) { if (map_it != this->mapping_.cend()) { size_t idx = std::distance(this->mapping_.cbegin(), map_it); - new_state = this->traits.get_options()[idx]; + new_state = std::string(this->option_at(idx)); ESP_LOGV(TAG, "Found option %s for value %lld", new_state->c_str(), value); } else { ESP_LOGE(TAG, "No option found for mapping %lld", value); @@ -41,10 +41,12 @@ void ModbusSelect::parse_and_publish(const std::vector &data) { } void ModbusSelect::control(const std::string &value) { - auto options = this->traits.get_options(); - auto opt_it = std::find(options.cbegin(), options.cend(), value); - size_t idx = std::distance(options.cbegin(), opt_it); - optional mapval = this->mapping_[idx]; + auto idx = this->index_of(value); + if (!idx.has_value()) { + ESP_LOGW(TAG, "Invalid option '%s'", value.c_str()); + return; + } + optional mapval = this->mapping_[idx.value()]; ESP_LOGD(TAG, "Found value %lld for option '%s'", *mapval, value.c_str()); std::vector data; diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index 55fb2107dd..e6b98aead2 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -26,16 +26,15 @@ class ModbusSelect : public Component, public select::Select, public SensorItem this->mapping_ = std::move(mapping); } - using transform_func_t = - std::function(ModbusSelect *const, int64_t, const std::vector &)>; - using write_transform_func_t = - std::function(ModbusSelect *const, const std::string &, int64_t, std::vector &)>; + using transform_func_t = optional (*)(ModbusSelect *const, int64_t, const std::vector &); + using write_transform_func_t = optional (*)(ModbusSelect *const, const std::string &, int64_t, + std::vector &); void set_parent(ModbusController *const parent) { this->parent_ = parent; } void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_template(transform_func_t &&f) { this->transform_func_ = f; } - void set_write_template(write_transform_func_t &&f) { this->write_transform_func_ = f; } + void set_template(transform_func_t f) { this->transform_func_ = f; } + void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void dump_config() override; void parse_and_publish(const std::vector &data) override; diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index 65eb487c1c..ba943c873c 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -25,9 +25,9 @@ class ModbusSensor : public Component, public sensor::Sensor, public SensorItem void parse_and_publish(const std::vector &data) override; void dump_config() override; - using transform_func_t = std::function(ModbusSensor *, float, const std::vector &)>; + using transform_func_t = optional (*)(ModbusSensor *, float, const std::vector &); - void set_template(transform_func_t &&f) { this->transform_func_ = f; } + void set_template(transform_func_t f) { this->transform_func_ = f; } protected: optional transform_func_{nullopt}; diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 0098076ef4..301c2bf548 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -34,10 +34,10 @@ class ModbusSwitch : public Component, public switch_::Switch, public SensorItem void parse_and_publish(const std::vector &data) override; void set_parent(ModbusController *parent) { this->parent_ = parent; } - using transform_func_t = std::function(ModbusSwitch *, bool, const std::vector &)>; - using write_transform_func_t = std::function(ModbusSwitch *, bool, std::vector &)>; - void set_template(transform_func_t &&f) { this->publish_transform_func_ = f; } - void set_write_template(write_transform_func_t &&f) { this->write_transform_func_ = f; } + using transform_func_t = optional (*)(ModbusSwitch *, bool, const std::vector &); + using write_transform_func_t = optional (*)(ModbusSwitch *, bool, std::vector &); + void set_template(transform_func_t f) { this->publish_transform_func_ = f; } + void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } protected: diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index d6eb5fd230..6666aea976 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -30,9 +30,8 @@ class ModbusTextSensor : public Component, public text_sensor::TextSensor, publi void dump_config() override; void parse_and_publish(const std::vector &data) override; - using transform_func_t = - std::function(ModbusTextSensor *, std::string, const std::vector &)>; - void set_template(transform_func_t &&f) { this->transform_func_ = f; } + using transform_func_t = optional (*)(ModbusTextSensor *, std::string, const std::vector &); + void set_template(transform_func_t f) { this->transform_func_ = f; } protected: optional transform_func_{nullopt}; diff --git a/esphome/components/mqtt/mqtt_select.cpp b/esphome/components/mqtt/mqtt_select.cpp index b851348306..e1660b07ea 100644 --- a/esphome/components/mqtt/mqtt_select.cpp +++ b/esphome/components/mqtt/mqtt_select.cpp @@ -21,7 +21,8 @@ void MQTTSelectComponent::setup() { call.set_option(state); call.perform(); }); - this->select_->add_on_state_callback([this](const std::string &state, size_t index) { this->publish_state(state); }); + this->select_->add_on_state_callback( + [this](const std::string &state, size_t index) { this->publish_state(this->select_->option_at(index)); }); } void MQTTSelectComponent::dump_config() { @@ -44,7 +45,7 @@ void MQTTSelectComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon } bool MQTTSelectComponent::send_initial_state() { if (this->select_->has_state()) { - return this->publish_state(this->select_->state); + return this->publish_state(this->select_->current_option()); } else { return true; } diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1a74350c4c..1d62b661ca 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -1,3 +1,5 @@ +import ipaddress + import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option import esphome.config_validation as cv @@ -10,6 +12,41 @@ AUTO_LOAD = ["mdns"] network_ns = cg.esphome_ns.namespace("network") IPAddress = network_ns.class_("IPAddress") + +def ip_address_literal(ip: str | int | None) -> cg.MockObj: + """Generate an IPAddress with compile-time initialization instead of runtime parsing. + + This function parses the IP address in Python during code generation and generates + a call to the 4-octet constructor (IPAddress(192, 168, 1, 1)) instead of the + string constructor (IPAddress("192.168.1.1")). This eliminates runtime string + parsing overhead and reduces flash usage on embedded systems. + + Args: + ip: IP address as string (e.g., "192.168.1.1"), ipaddress.IPv4Address, or None + + Returns: + IPAddress expression that uses 4-octet constructor for efficiency + """ + if ip is None: + return IPAddress(0, 0, 0, 0) + + try: + # Parse using Python's ipaddress module + ip_obj = ipaddress.ip_address(ip) + except (ValueError, TypeError): + pass + else: + # Only support IPv4 for now + if isinstance(ip_obj, ipaddress.IPv4Address): + # Extract octets from the packed bytes representation + octets = ip_obj.packed + # Generate call to 4-octet constructor: IPAddress(192, 168, 1, 1) + return IPAddress(octets[0], octets[1], octets[2], octets[3]) + + # Fallback to string constructor if parsing fails + return IPAddress(str(ip)) + + CONFIG_SCHEMA = cv.Schema( { cv.SplitDefault( @@ -18,6 +55,7 @@ CONFIG_SCHEMA = cv.Schema( esp32=False, rp2040=False, bk72xx=False, + host=False, ): cv.All( cv.boolean, cv.Any( @@ -27,6 +65,7 @@ CONFIG_SCHEMA = cv.Schema( esp8266_arduino=cv.Version(0, 0, 0), rp2040_arduino=cv.Version(0, 0, 0), bk72xx_arduino=cv.Version(1, 7, 0), + host=cv.Version(0, 0, 0), ), cv.boolean_false, ), diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index fc152ece1e..d77af510d7 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -323,6 +323,8 @@ void Nextion::loop() { this->set_touch_sleep_timeout(this->touch_sleep_timeout_); } + this->set_auto_wake_on_touch(this->connection_state_.auto_wake_on_touch_); + this->connection_state_.ignore_is_setup_ = false; } diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index e2c4faa1d0..c078ab9d56 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -540,6 +540,23 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe */ void goto_page(uint8_t page); + /** + * Set the visibility of a component. + * + * @param component The component name. + * @param show True to show the component, false to hide it. + * + * @see show_component() + * @see hide_component() + * + * Example: + * ```cpp + * it.set_component_visibility("textview", true); // Equivalent to show_component("textview") + * it.set_component_visibility("textview", false); // Equivalent to hide_component("textview") + * ``` + */ + void set_component_visibility(const char *component, bool show) override; + /** * Hide a component. * @param component The component name. diff --git a/esphome/components/nextion/nextion_base.h b/esphome/components/nextion/nextion_base.h index b88dd399f8..d46cd9a185 100644 --- a/esphome/components/nextion/nextion_base.h +++ b/esphome/components/nextion/nextion_base.h @@ -45,6 +45,7 @@ class NextionBase { virtual void set_component_pressed_font_color(const char *component, Color color) = 0; virtual void set_component_font(const char *component, uint8_t font_id) = 0; + virtual void set_component_visibility(const char *component, bool show) = 0; virtual void show_component(const char *component) = 0; virtual void hide_component(const char *component) = 0; diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index f3a282717b..cfaae7e3e0 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -201,13 +201,13 @@ void Nextion::set_component_font(const char *component, uint8_t font_id) { this->add_no_result_to_queue_with_printf_("set_component_font", "%s.font=%" PRIu8, component, font_id); } -void Nextion::hide_component(const char *component) { - this->add_no_result_to_queue_with_printf_("hide_component", "vis %s,0", component); +void Nextion::set_component_visibility(const char *component, bool show) { + this->add_no_result_to_queue_with_printf_("set_component_visibility", "vis %s,%d", component, show ? 1 : 0); } -void Nextion::show_component(const char *component) { - this->add_no_result_to_queue_with_printf_("show_component", "vis %s,1", component); -} +void Nextion::hide_component(const char *component) { this->set_component_visibility(component, false); } + +void Nextion::show_component(const char *component) { this->set_component_visibility(component, true); } void Nextion::enable_component_touch(const char *component) { this->add_no_result_to_queue_with_printf_("enable_component_touch", "tsw %s,1", component); diff --git a/esphome/components/nextion/nextion_component.cpp b/esphome/components/nextion/nextion_component.cpp index 32929d6845..324ad87372 100644 --- a/esphome/components/nextion/nextion_component.cpp +++ b/esphome/components/nextion/nextion_component.cpp @@ -81,13 +81,11 @@ void NextionComponent::update_component_settings(bool force_update) { this->component_flags_.visible_needs_update = false; - if (this->component_flags_.visible) { - this->nextion_->show_component(name_to_send.c_str()); - this->send_state_to_nextion(); - } else { - this->nextion_->hide_component(name_to_send.c_str()); + this->nextion_->set_component_visibility(name_to_send.c_str(), this->component_flags_.visible); + if (!this->component_flags_.visible) { return; } + this->send_state_to_nextion(); } if (this->component_flags_.bco_needs_update || (force_update && this->component_flags_.bco2_is_set)) { diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index b0e5d121dd..b4d217d7aa 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -174,11 +174,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Check if baud rate is supported this->original_baud_rate_ = this->parent_->get_baud_rate(); - static const std::vector SUPPORTED_BAUD_RATES = {2400, 4800, 9600, 19200, 31250, 38400, 57600, - 115200, 230400, 250000, 256000, 512000, 921600}; - if (std::find(SUPPORTED_BAUD_RATES.begin(), SUPPORTED_BAUD_RATES.end(), baud_rate) == SUPPORTED_BAUD_RATES.end()) { - baud_rate = this->original_baud_rate_; - } ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client diff --git a/esphome/components/nextion/nextion_upload_idf.cpp b/esphome/components/nextion/nextion_upload_idf.cpp index 78a47f9e2c..3b0d65643d 100644 --- a/esphome/components/nextion/nextion_upload_idf.cpp +++ b/esphome/components/nextion/nextion_upload_idf.cpp @@ -177,11 +177,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Check if baud rate is supported this->original_baud_rate_ = this->parent_->get_baud_rate(); - static const std::vector SUPPORTED_BAUD_RATES = {2400, 4800, 9600, 19200, 31250, 38400, 57600, - 115200, 230400, 250000, 256000, 512000, 921600}; - if (std::find(SUPPORTED_BAUD_RATES.begin(), SUPPORTED_BAUD_RATES.end(), baud_rate) == SUPPORTED_BAUD_RATES.end()) { - baud_rate = this->original_baud_rate_; - } ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 727607933d..ace324c1f5 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging from pathlib import Path @@ -277,3 +278,20 @@ def upload_program(config: ConfigType, args, host: str) -> bool: raise EsphomeError(f"Upload failed with result: {result}") return handled + + +def show_logs(config: ConfigType, args, devices: list[str]) -> bool: + address = devices[0] + from .ble_logger import is_mac_address, logger_connect, logger_scan + + if devices[0] == "BLE": + ble_device = asyncio.run(logger_scan(CORE.config["esphome"]["name"])) + if ble_device: + address = ble_device.address + else: + return True + + if is_mac_address(address): + asyncio.run(logger_connect(address)) + return True + return False diff --git a/esphome/components/nrf52/ble_logger.py b/esphome/components/nrf52/ble_logger.py new file mode 100644 index 0000000000..f74a49ea89 --- /dev/null +++ b/esphome/components/nrf52/ble_logger.py @@ -0,0 +1,60 @@ +import asyncio +import logging +import re +from typing import Final + +from bleak import BleakClient, BleakScanner, BLEDevice +from bleak.exc import ( + BleakCharacteristicNotFoundError, + BleakDBusError, + BleakDeviceNotFoundError, +) + +_LOGGER = logging.getLogger(__name__) + + +NUS_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" +NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" + +MAC_ADDRESS_PATTERN: Final = re.compile( + r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE +) + + +def is_mac_address(value: str) -> bool: + return MAC_ADDRESS_PATTERN.match(value) + + +async def logger_scan(name: str) -> BLEDevice | None: + _LOGGER.info("Scanning bluetooth for %s...", name) + device = await BleakScanner.find_device_by_name(name) + if not device: + _LOGGER.error("%s Bluetooth LE device was not found!", name) + return device + + +async def logger_connect(host: str) -> int | None: + disconnected_event = asyncio.Event() + + def handle_disconnect(client): + disconnected_event.set() + + def handle_rx(_, data: bytearray): + print(data.decode("utf-8"), end="") + + _LOGGER.info("Connecting %s...", host) + try: + async with BleakClient(host, disconnected_callback=handle_disconnect) as client: + _LOGGER.info("Connected %s...", host) + try: + await client.start_notify(NUS_TX_CHAR_UUID, handle_rx) + except BleakDBusError as e: + _LOGGER.error("Bluetooth LE logger: %s", e) + disconnected_event.set() + await disconnected_event.wait() + except BleakDeviceNotFoundError: + _LOGGER.error("Device %s not found", host) + return 1 + except BleakCharacteristicNotFoundError: + _LOGGER.error("Device %s has no NUS characteristic", host) + return 1 diff --git a/esphome/components/nrf52/gpio.py b/esphome/components/nrf52/gpio.py index 260114f90e..17329042b2 100644 --- a/esphome/components/nrf52/gpio.py +++ b/esphome/components/nrf52/gpio.py @@ -74,6 +74,9 @@ async def nrf52_pin_to_code(config): var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) + # Only set if true to avoid bloating setup() function + # (inverted bit in pin_flags_ bitfield is zero-initialized to false) + if config[CONF_INVERTED]: + cg.add(var.set_inverted(True)) cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) return var diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 230c3aa0c1..368b431d7b 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -238,11 +238,6 @@ def number_schema( return _NUMBER_SCHEMA.extend(schema) -# Remove before 2025.11.0 -NUMBER_SCHEMA = number_schema(Number) -NUMBER_SCHEMA.add_extra(cv.deprecated_schema_constant("number")) - - async def setup_number_core_( var, config, *, min_value: float, max_value: float, step: float ): @@ -252,7 +247,10 @@ async def setup_number_core_( cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) - cg.add(var.traits.set_mode(config[CONF_MODE])) + # Only set if non-default to avoid bloating setup() function + # (mode_ is initialized to NUMBER_MODE_AUTO in the header) + if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO: + cg.add(var.traits.set_mode(config[CONF_MODE])) for conf in config.get(CONF_ON_VALUE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index fdc75d995a..04057c07f2 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -102,7 +102,7 @@ CONFIG_SCHEMA = cv.Any( str: PACKAGE_SCHEMA, } ), - cv.ensure_list(PACKAGE_SCHEMA), + [PACKAGE_SCHEMA], ) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index b92cc3be9f..bafd5273da 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -38,7 +38,6 @@ void Pipsolar::loop() { } if (this->state_ == STATE_COMMAND_COMPLETE) { if (this->check_incoming_length_(4)) { - ESP_LOGD(TAG, "response length for command OK"); if (this->check_incoming_crc_()) { // crc ok if (this->read_buffer_[1] == 'A' && this->read_buffer_[2] == 'C' && this->read_buffer_[3] == 'K') { @@ -49,15 +48,15 @@ void Pipsolar::loop() { this->command_queue_[this->command_queue_position_] = std::string(""); this->command_queue_position_ = (command_queue_position_ + 1) % COMMAND_QUEUE_LENGTH; this->state_ = STATE_IDLE; - } else { // crc failed + // no log message necessary, check_incoming_crc_() logs this->command_queue_[this->command_queue_position_] = std::string(""); this->command_queue_position_ = (command_queue_position_ + 1) % COMMAND_QUEUE_LENGTH; this->state_ = STATE_IDLE; } } else { - ESP_LOGD(TAG, "response length for command %s not OK: with length %zu", + ESP_LOGD(TAG, "command %s response length not OK: with length %zu", this->command_queue_[this->command_queue_position_].c_str(), this->read_pos_); this->command_queue_[this->command_queue_position_] = std::string(""); this->command_queue_position_ = (command_queue_position_ + 1) % COMMAND_QUEUE_LENGTH; @@ -66,46 +65,10 @@ void Pipsolar::loop() { } if (this->state_ == STATE_POLL_CHECKED) { - switch (this->enabled_polling_commands_[this->last_polling_command_].identifier) { - case POLLING_QPIRI: - ESP_LOGD(TAG, "Decode QPIRI"); - handle_qpiri_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QPIGS: - ESP_LOGD(TAG, "Decode QPIGS"); - handle_qpigs_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QMOD: - ESP_LOGD(TAG, "Decode QMOD"); - handle_qmod_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QFLAG: - ESP_LOGD(TAG, "Decode QFLAG"); - handle_qflag_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QPIWS: - ESP_LOGD(TAG, "Decode QPIWS"); - handle_qpiws_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QT: - ESP_LOGD(TAG, "Decode QT"); - handle_qt_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QMN: - ESP_LOGD(TAG, "Decode QMN"); - handle_qmn_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - default: - this->state_ = STATE_IDLE; - break; - } + ESP_LOGD(TAG, "poll %s decode", this->enabled_polling_commands_[this->last_polling_command_].command); + this->handle_poll_response_(this->enabled_polling_commands_[this->last_polling_command_].identifier, + (const char *) this->read_buffer_); + this->state_ = STATE_IDLE; return; } @@ -113,6 +76,8 @@ void Pipsolar::loop() { if (this->check_incoming_crc_()) { if (this->read_buffer_[0] == '(' && this->read_buffer_[1] == 'N' && this->read_buffer_[2] == 'A' && this->read_buffer_[3] == 'K') { + ESP_LOGD(TAG, "poll %s NACK", this->enabled_polling_commands_[this->last_polling_command_].command); + this->handle_poll_error_(this->enabled_polling_commands_[this->last_polling_command_].identifier); this->state_ = STATE_IDLE; return; } @@ -121,6 +86,9 @@ void Pipsolar::loop() { this->state_ = STATE_POLL_CHECKED; return; } else { + // crc failed + // no log message necessary, check_incoming_crc_() logs + this->handle_poll_error_(this->enabled_polling_commands_[this->last_polling_command_].identifier); this->state_ = STATE_IDLE; } } @@ -158,21 +126,19 @@ void Pipsolar::loop() { // command timeout const char *command = this->command_queue_[this->command_queue_position_].c_str(); this->command_start_millis_ = millis(); - ESP_LOGD(TAG, "timeout command from queue: %s", command); + ESP_LOGD(TAG, "command %s timeout", command); this->command_queue_[this->command_queue_position_] = std::string(""); this->command_queue_position_ = (command_queue_position_ + 1) % COMMAND_QUEUE_LENGTH; this->state_ = STATE_IDLE; return; - } else { } } if (this->state_ == STATE_POLL) { if (millis() - this->command_start_millis_ > esphome::pipsolar::Pipsolar::COMMAND_TIMEOUT) { // command timeout - ESP_LOGD(TAG, "timeout command to poll: %s", - this->enabled_polling_commands_[this->last_polling_command_].command); + ESP_LOGD(TAG, "poll %s timeout", this->enabled_polling_commands_[this->last_polling_command_].command); + this->handle_poll_error_(this->enabled_polling_commands_[this->last_polling_command_].identifier); this->state_ = STATE_IDLE; - } else { } } } @@ -187,7 +153,6 @@ uint8_t Pipsolar::check_incoming_length_(uint8_t length) { uint8_t Pipsolar::check_incoming_crc_() { uint16_t crc16; crc16 = this->pipsolar_crc_(read_buffer_, read_pos_ - 3); - ESP_LOGD(TAG, "checking crc on incoming message"); if (((uint8_t) ((crc16) >> 8)) == read_buffer_[read_pos_ - 3] && ((uint8_t) ((crc16) &0xff)) == read_buffer_[read_pos_ - 2]) { ESP_LOGD(TAG, "CRC OK"); @@ -253,7 +218,7 @@ bool Pipsolar::send_next_poll_() { this->write(((uint8_t) ((crc16) &0xff))); // lowbyte // end Byte this->write(0x0D); - ESP_LOGD(TAG, "Sending polling command : %s with length %d", + ESP_LOGD(TAG, "Sending polling command: %s with length %d", this->enabled_polling_commands_[this->last_polling_command_].command, this->enabled_polling_commands_[this->last_polling_command_].length); return true; @@ -274,6 +239,38 @@ void Pipsolar::queue_command(const std::string &command) { ESP_LOGD(TAG, "Command queue full dropping command: %s", command.c_str()); } +void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const char *message) { + switch (polling_command) { + case POLLING_QPIRI: + handle_qpiri_(message); + break; + case POLLING_QPIGS: + handle_qpigs_(message); + break; + case POLLING_QMOD: + handle_qmod_(message); + break; + case POLLING_QFLAG: + handle_qflag_(message); + break; + case POLLING_QPIWS: + handle_qpiws_(message); + break; + case POLLING_QT: + handle_qt_(message); + break; + case POLLING_QMN: + handle_qmn_(message); + break; + default: + break; + } +} +void Pipsolar::handle_poll_error_(ENUMPollingCommand polling_command) { + // handlers are designed in a way that an empty message sets all sensors to unknown + this->handle_poll_response_(polling_command, ""); +} + void Pipsolar::handle_qpiri_(const char *message) { if (this->last_qpiri_) { this->last_qpiri_->publish_state(message); diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 40056bac9d..beae67a4e0 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -204,6 +204,9 @@ class Pipsolar : public uart::UARTDevice, public PollingComponent { bool send_next_command_(); bool send_next_poll_(); + void handle_poll_response_(ENUMPollingCommand polling_command, const char *message); + void handle_poll_error_(ENUMPollingCommand polling_command); + // these handlers are designed in a way that an empty message sets all sensors to unknown void handle_qpiri_(const char *message); void handle_qpigs_(const char *message); void handle_qmod_(const char *message); diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 929865b480..d08a877b55 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -4,11 +4,18 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_VOLTAGE, CONF_BUS_VOLTAGE, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + ICON_BATTERY, ICON_CURRENT_AC, + ICON_FLASH, + ICON_GAUGE, + STATE_CLASS_MEASUREMENT, UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, @@ -22,6 +29,10 @@ from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA DEPENDENCIES = ["uart"] +ICON_SOLAR_POWER = "mdi:solar-power" +ICON_SOLAR_PANEL = "mdi:solar-panel" +ICON_CURRENT_DC = "mdi:current-dc" + # QPIRI sensors CONF_GRID_RATING_VOLTAGE = "grid_rating_voltage" CONF_GRID_RATING_CURRENT = "grid_rating_current" @@ -75,16 +86,19 @@ TYPES = { unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_GRID_RATING_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=1, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_RATING_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_RATING_FREQUENCY: sensor.sensor_schema( unit_of_measurement=UNIT_HERTZ, @@ -98,11 +112,12 @@ TYPES = { ), CONF_AC_OUTPUT_RATING_APPARENT_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS, - accuracy_decimals=1, + accuracy_decimals=0, + device_class=DEVICE_CLASS_APPARENT_POWER, ), CONF_AC_OUTPUT_RATING_ACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, - accuracy_decimals=1, + accuracy_decimals=0, device_class=DEVICE_CLASS_POWER, ), CONF_BATTERY_RATING_VOLTAGE: sensor.sensor_schema( @@ -131,124 +146,151 @@ TYPES = { device_class=DEVICE_CLASS_VOLTAGE, ), CONF_BATTERY_TYPE: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_CURRENT_MAX_AC_CHARGING_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=1, + accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, ), CONF_CURRENT_MAX_CHARGING_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=1, + accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, ), CONF_INPUT_VOLTAGE_RANGE: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_OUTPUT_SOURCE_PRIORITY: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_CHARGER_SOURCE_PRIORITY: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_PARALLEL_MAX_NUM: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_MACHINE_TYPE: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_TOPOLOGY: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_OUTPUT_MODE: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_BATTERY_REDISCHARGE_VOLTAGE: sensor.sensor_schema( accuracy_decimals=1, ), CONF_PV_OK_CONDITION_FOR_PARALLEL: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_PV_POWER_BALANCE: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_GRID_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_GRID_FREQUENCY: sensor.sensor_schema( unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=1, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_FREQUENCY: sensor.sensor_schema( unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=1, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_APPARENT_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS, - accuracy_decimals=1, + accuracy_decimals=0, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_ACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, - accuracy_decimals=1, + accuracy_decimals=0, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_OUTPUT_LOAD_PERCENT: sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, - accuracy_decimals=1, + icon=ICON_GAUGE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BUS_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, + icon=ICON_FLASH, + accuracy_decimals=0, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, + icon=ICON_BATTERY, + accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_CHARGING_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=1, + icon=ICON_CURRENT_DC, + accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_CAPACITY_PERCENT: sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, - accuracy_decimals=1, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_INVERTER_HEAT_SINK_TEMPERATURE: sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, - accuracy_decimals=1, + accuracy_decimals=0, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_PV_INPUT_CURRENT_FOR_BATTERY: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, + icon=ICON_SOLAR_PANEL, accuracy_decimals=1, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_PV_INPUT_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, + icon=ICON_SOLAR_PANEL, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_VOLTAGE_SCC: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, + accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_DISCHARGE_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=1, + icon=ICON_CURRENT_DC, + accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_VOLTAGE_OFFSET_FOR_FANS_ON: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, @@ -256,12 +298,14 @@ TYPES = { device_class=DEVICE_CLASS_VOLTAGE, ), CONF_EEPROM_VERSION: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_PV_CHARGING_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, - accuracy_decimals=1, + icon=ICON_SOLAR_POWER, + accuracy_decimals=0, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), } diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index ccf16a8beb..8d735ea563 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -17,6 +17,7 @@ from esphome.const import ( CONF_FAMILY, CONF_GROUP, CONF_ID, + CONF_INDEX, CONF_INVERTED, CONF_LEVEL, CONF_MAGNITUDE, @@ -616,6 +617,49 @@ async def dooya_action(var, config, args): cg.add(var.set_check(template_)) +# Dyson +DysonData, DysonBinarySensor, DysonTrigger, DysonAction, DysonDumper = declare_protocol( + "Dyson" +) +DYSON_SCHEMA = cv.Schema( + { + cv.Required(CONF_CODE): cv.hex_uint16_t, + cv.Optional(CONF_INDEX, default=0xFF): cv.hex_uint8_t, + } +) + + +@register_binary_sensor("dyson", DysonBinarySensor, DYSON_SCHEMA) +def dyson_binary_sensor(var, config): + cg.add( + var.set_data( + cg.StructInitializer( + DysonData, + ("code", config[CONF_CODE]), + ("index", config[CONF_INDEX]), + ) + ) + ) + + +@register_trigger("dyson", DysonTrigger, DysonData) +def dyson_trigger(var, config): + pass + + +@register_dumper("dyson", DysonDumper) +def dyson_dumper(var, config): + pass + + +@register_action("dyson", DysonAction, DYSON_SCHEMA) +async def dyson_action(var, config, args): + template_ = await cg.templatable(config[CONF_CODE], args, cg.uint16) + cg.add(var.set_code(template_)) + template_ = await cg.templatable(config[CONF_INDEX], args, cg.uint8) + cg.add(var.set_index(template_)) + + # JVC JVCData, JVCBinarySensor, JVCTrigger, JVCAction, JVCDumper = declare_protocol("JVC") JVC_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_uint32_t}) diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index f2d0f5b547..b258bd920b 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -33,19 +33,13 @@ Message Format: class ABBWelcomeData { public: // Make default - ABBWelcomeData() { - std::fill(std::begin(this->data_), std::end(this->data_), 0); - this->data_[0] = 0x55; - this->data_[1] = 0xff; - } + ABBWelcomeData() : data_{0x55, 0xff} {} // Make from initializer_list - ABBWelcomeData(std::initializer_list data) { - std::fill(std::begin(this->data_), std::end(this->data_), 0); + ABBWelcomeData(std::initializer_list data) : data_{} { std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); } // Make from vector - ABBWelcomeData(const std::vector &data) { - std::fill(std::begin(this->data_), std::end(this->data_), 0); + ABBWelcomeData(const std::vector &data) : data_{} { std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); } // Default copy constructor diff --git a/esphome/components/remote_base/dyson_protocol.cpp b/esphome/components/remote_base/dyson_protocol.cpp new file mode 100644 index 0000000000..db4e1135f4 --- /dev/null +++ b/esphome/components/remote_base/dyson_protocol.cpp @@ -0,0 +1,71 @@ +#include "dyson_protocol.h" +#include "esphome/core/log.h" + +#include + +namespace esphome { +namespace remote_base { + +static const char *const TAG = "remote.dyson"; + +// pulsewidth [µs] +constexpr uint32_t PW_MARK_US = 780; +constexpr uint32_t PW_SHORT_US = 720; +constexpr uint32_t PW_LONG_US = 1500; +constexpr uint32_t PW_START_US = 2280; + +// MSB of 15 bit dyson code +constexpr uint16_t MSB_DYSON = (1 << 14); + +// required symbols in transmit buffer = (start_symbol + 15 data_symbols) +constexpr uint32_t N_SYMBOLS_REQ = 2u * (1 + 15); + +void DysonProtocol::encode(RemoteTransmitData *dst, const DysonData &data) { + uint32_t raw_code = (data.code << 2) + (data.index & 3); + dst->set_carrier_frequency(36000); + dst->reserve(N_SYMBOLS_REQ + 1); + dst->item(PW_START_US, PW_SHORT_US); + for (uint16_t mask = MSB_DYSON; mask != 0; mask >>= 1) { + if (mask == (mask & raw_code)) { + dst->item(PW_MARK_US, PW_LONG_US); + } else { + dst->item(PW_MARK_US, PW_SHORT_US); + } + } + dst->mark(PW_MARK_US); // final carrier pulse +} + +optional DysonProtocol::decode(RemoteReceiveData src) { + uint32_t n_received = static_cast(src.size()); + uint16_t raw_code = 0; + DysonData data{ + .code = 0, + .index = 0, + }; + if (n_received < N_SYMBOLS_REQ) + return {}; // invalid frame length + if (!src.expect_item(PW_START_US, PW_SHORT_US)) + return {}; // start not found + for (uint16_t mask = MSB_DYSON; mask != 0; mask >>= 1) { + if (src.expect_item(PW_MARK_US, PW_SHORT_US)) { + raw_code &= ~mask; // zero detected + } else if (src.expect_item(PW_MARK_US, PW_LONG_US)) { + raw_code |= mask; // one detected + } else { + return {}; // invalid data item + } + } + data.code = raw_code >> 2; // extract button code + data.index = raw_code & 3; // extract rolling index + if (src.expect_mark(PW_MARK_US)) { // check total length + return data; + } + return {}; // frame not complete +} + +void DysonProtocol::dump(const DysonData &data) { + ESP_LOGI(TAG, "Dyson: code=0x%x rolling index=%d", data.code, data.index); +} + +} // namespace remote_base +} // namespace esphome diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h new file mode 100644 index 0000000000..d1c08fefba --- /dev/null +++ b/esphome/components/remote_base/dyson_protocol.h @@ -0,0 +1,46 @@ +#pragma once + +#include "remote_base.h" + +#include + +namespace esphome { +namespace remote_base { + +static constexpr uint8_t IGNORE_INDEX = 0xFF; + +struct DysonData { + uint16_t code; // the button, e.g. power, swing, fan++, ... + uint8_t index; // the rolling index counter + bool operator==(const DysonData &rhs) const { + if (IGNORE_INDEX == index || IGNORE_INDEX == rhs.index) { + return code == rhs.code; + } + return code == rhs.code && index == rhs.index; + } +}; + +class DysonProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const DysonData &data) override; + optional decode(RemoteReceiveData src) override; + void dump(const DysonData &data) override; +}; + +DECLARE_REMOTE_PROTOCOL(Dyson) + +template class DysonAction : public RemoteTransmitterActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, code) + TEMPLATABLE_VALUE(uint8_t, index) + + void encode(RemoteTransmitData *dst, Ts... x) override { + DysonData data{}; + data.code = this->code_.value(x...); + data.index = this->index_.value(x...); + DysonProtocol().encode(dst, data); + } +}; + +} // namespace remote_base +} // namespace esphome diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index cb98c017f1..faa6c827f7 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -1,3 +1,5 @@ +import logging + from esphome import automation, pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, remote_base @@ -18,9 +20,12 @@ from esphome.const import ( ) from esphome.core import CORE +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["remote_base"] CONF_EOT_LEVEL = "eot_level" +CONF_NON_BLOCKING = "non_blocking" CONF_ON_TRANSMIT = "on_transmit" CONF_ON_COMPLETE = "on_complete" CONF_TRANSMITTER_ID = remote_base.CONF_TRANSMITTER_ID @@ -65,11 +70,25 @@ CONFIG_SCHEMA = cv.Schema( esp32_c6=48, esp32_h2=48, ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), + cv.Optional(CONF_NON_BLOCKING): cv.All(cv.only_on_esp32, cv.boolean), cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True), cv.Optional(CONF_ON_COMPLETE): automation.validate_automation(single=True), } ).extend(cv.COMPONENT_SCHEMA) + +def _validate_non_blocking(config): + if CORE.is_esp32 and CONF_NON_BLOCKING not in config: + _LOGGER.warning( + "'non_blocking' is not set for 'remote_transmitter' and will default to 'true'.\n" + "The default behavior changed in 2025.11.0; previously blocking mode was used.\n" + "To silence this warning, explicitly set 'non_blocking: true' (or 'false')." + ) + config[CONF_NON_BLOCKING] = True + + +FINAL_VALIDATE_SCHEMA = _validate_non_blocking + DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(CONF_TRANSMITTER_ID): cv.use_id(RemoteTransmitterComponent), @@ -95,6 +114,7 @@ async def to_code(config): if CORE.is_esp32: var = cg.new_Pvariable(config[CONF_ID], pin) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) + cg.add(var.set_non_blocking(config[CONF_NON_BLOCKING])) if CONF_CLOCK_RESOLUTION in config: cg.add(var.set_clock_resolution(config[CONF_CLOCK_RESOLUTION])) if CONF_USE_DMA in config: diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index aa1f54911d..cc3b82ad61 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -12,6 +12,25 @@ namespace esphome { namespace remote_transmitter { +#ifdef USE_ESP32 +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) +// IDF version 5.5.1 and above is required because of a bug in +// the RMT encoder: https://github.com/espressif/esp-idf/issues/17244 +typedef union { // NOLINT(modernize-use-using) + struct { + uint16_t duration : 15; + uint16_t level : 1; + }; + uint16_t val; +} rmt_symbol_half_t; + +struct RemoteTransmitterComponentStore { + uint32_t times{0}; + uint32_t index{0}; +}; +#endif +#endif + class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, public Component #ifdef USE_ESP32 @@ -35,6 +54,7 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, #if defined(USE_ESP32) void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; } void set_eot_level(bool eot_level) { this->eot_level_ = eot_level; } + void set_non_blocking(bool non_blocking) { this->non_blocking_ = non_blocking; } #endif Trigger<> *get_transmit_trigger() const { return this->transmit_trigger_; }; @@ -55,10 +75,16 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, #ifdef USE_ESP32 void configure_rmt_(); + void wait_for_rmt_(); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) + RemoteTransmitterComponentStore store_{}; + std::vector rmt_temp_; +#else + std::vector rmt_temp_; +#endif uint32_t current_carrier_frequency_{38000}; bool initialized_{false}; - std::vector rmt_temp_; bool with_dma_{false}; bool eot_level_{false}; rmt_channel_handle_t channel_{NULL}; @@ -66,6 +92,7 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, esp_err_t error_code_{ESP_OK}; std::string error_string_{""}; bool inverted_{false}; + bool non_blocking_{false}; #endif uint8_t carrier_duty_percent_; diff --git a/esphome/components/remote_transmitter/remote_transmitter_esp32.cpp b/esphome/components/remote_transmitter/remote_transmitter_esp32.cpp index 119aa81e7e..59c85c99a8 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_esp32.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_esp32.cpp @@ -10,6 +10,46 @@ namespace remote_transmitter { static const char *const TAG = "remote_transmitter"; +// Maximum RMT symbol duration (15-bit field) +static constexpr uint32_t RMT_SYMBOL_DURATION_MAX = 0x7FFF; + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) +static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size_t written, size_t free, + rmt_symbol_word_t *symbols, bool *done, void *arg) { + auto *store = static_cast(arg); + const auto *encoded = static_cast(data); + size_t length = size / sizeof(rmt_symbol_half_t); + size_t count = 0; + + // copy symbols + for (size_t i = 0; i < free; i++) { + uint16_t sym_0 = encoded[store->index++].val; + if (store->index >= length) { + store->index = 0; + store->times--; + if (store->times == 0) { + *done = true; + symbols[count++].val = sym_0; + return count; + } + } + uint16_t sym_1 = encoded[store->index++].val; + if (store->index >= length) { + store->index = 0; + store->times--; + if (store->times == 0) { + *done = true; + symbols[count++].val = sym_0 | (sym_1 << 16); + return count; + } + } + symbols[count++].val = sym_0 | (sym_1 << 16); + } + *done = false; + return count; +} +#endif + void RemoteTransmitterComponent::setup() { this->inverted_ = this->pin_->is_inverted(); this->configure_rmt_(); @@ -34,6 +74,17 @@ void RemoteTransmitterComponent::dump_config() { } void RemoteTransmitterComponent::digital_write(bool value) { +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) + rmt_symbol_half_t symbol = { + .duration = 1, + .level = value, + }; + rmt_transmit_config_t config; + memset(&config, 0, sizeof(config)); + config.flags.eot_level = value; + this->store_.times = 1; + this->store_.index = 0; +#else rmt_symbol_word_t symbol = { .duration0 = 1, .level0 = value, @@ -42,8 +93,8 @@ void RemoteTransmitterComponent::digital_write(bool value) { }; rmt_transmit_config_t config; memset(&config, 0, sizeof(config)); - config.loop_count = 0; config.flags.eot_level = value; +#endif esp_err_t error = rmt_transmit(this->channel_, this->encoder_, &symbol, sizeof(symbol), &config); if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); @@ -90,6 +141,20 @@ void RemoteTransmitterComponent::configure_rmt_() { gpio_pullup_dis(gpio_num_t(this->pin_->get_pin())); } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) + rmt_simple_encoder_config_t encoder; + memset(&encoder, 0, sizeof(encoder)); + encoder.callback = encoder_callback; + encoder.arg = &this->store_; + encoder.min_chunk_size = 1; + error = rmt_new_simple_encoder(&encoder, &this->encoder_); + if (error != ESP_OK) { + this->error_code_ = error; + this->error_string_ = "in rmt_new_simple_encoder"; + this->mark_failed(); + return; + } +#else rmt_copy_encoder_config_t encoder; memset(&encoder, 0, sizeof(encoder)); error = rmt_new_copy_encoder(&encoder, &this->encoder_); @@ -99,6 +164,7 @@ void RemoteTransmitterComponent::configure_rmt_() { this->mark_failed(); return; } +#endif error = rmt_enable(this->channel_); if (error != ESP_OK) { @@ -130,6 +196,97 @@ void RemoteTransmitterComponent::configure_rmt_() { } } +void RemoteTransmitterComponent::wait_for_rmt_() { + esp_err_t error = rmt_tx_wait_all_done(this->channel_, -1); + if (error != ESP_OK) { + ESP_LOGW(TAG, "rmt_tx_wait_all_done failed: %s", esp_err_to_name(error)); + this->status_set_warning(); + } + + this->complete_trigger_->trigger(); +} + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) +void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { + uint64_t total_duration = 0; + + if (this->is_failed()) { + return; + } + + // if the timeout was cancelled, block until the tx is complete + if (this->non_blocking_ && this->cancel_timeout("complete")) { + this->wait_for_rmt_(); + } + + if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { + this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); + this->configure_rmt_(); + } + + this->rmt_temp_.clear(); + this->rmt_temp_.reserve(this->temp_.get_data().size() + 1); + + // encode any delay at the start of the buffer to simplify the encoder callback + // this will be skipped the first time around + total_duration += send_wait * (send_times - 1); + send_wait = this->from_microseconds_(static_cast(send_wait)); + while (send_wait > 0) { + int32_t duration = std::min(send_wait, uint32_t(RMT_SYMBOL_DURATION_MAX)); + this->rmt_temp_.push_back({ + .duration = static_cast(duration), + .level = static_cast(this->eot_level_), + }); + send_wait -= duration; + } + + // encode data + size_t offset = this->rmt_temp_.size(); + for (int32_t value : this->temp_.get_data()) { + bool level = value >= 0; + if (!level) { + value = -value; + } + total_duration += value * send_times; + value = this->from_microseconds_(static_cast(value)); + while (value > 0) { + int32_t duration = std::min(value, int32_t(RMT_SYMBOL_DURATION_MAX)); + this->rmt_temp_.push_back({ + .duration = static_cast(duration), + .level = static_cast(level ^ this->inverted_), + }); + value -= duration; + } + } + + if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.size() <= offset) { + ESP_LOGE(TAG, "Empty data"); + return; + } + + this->transmit_trigger_->trigger(); + + rmt_transmit_config_t config; + memset(&config, 0, sizeof(config)); + config.flags.eot_level = this->eot_level_; + this->store_.times = send_times; + this->store_.index = offset; + esp_err_t error = rmt_transmit(this->channel_, this->encoder_, this->rmt_temp_.data(), + this->rmt_temp_.size() * sizeof(rmt_symbol_half_t), &config); + if (error != ESP_OK) { + ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); + this->status_set_warning(); + } else { + this->status_clear_warning(); + } + + if (this->non_blocking_) { + this->set_timeout("complete", total_duration / 1000, [this]() { this->wait_for_rmt_(); }); + } else { + this->wait_for_rmt_(); + } +} +#else void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { if (this->is_failed()) return; @@ -151,7 +308,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen val = this->from_microseconds_(static_cast(val)); do { - int32_t item = std::min(val, int32_t(32767)); + int32_t item = std::min(val, int32_t(RMT_SYMBOL_DURATION_MAX)); val -= item; if (rmt_i % 2 == 0) { @@ -180,7 +337,6 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen for (uint32_t i = 0; i < send_times; i++) { rmt_transmit_config_t config; memset(&config, 0, sizeof(config)); - config.loop_count = 0; config.flags.eot_level = this->eot_level_; esp_err_t error = rmt_transmit(this->channel_, this->encoder_, this->rmt_temp_.data(), this->rmt_temp_.size() * sizeof(rmt_symbol_word_t), &config); @@ -200,6 +356,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } this->complete_trigger_->trigger(); } +#endif } // namespace remote_transmitter } // namespace esphome diff --git a/esphome/components/rp2040/gpio.h b/esphome/components/rp2040/gpio.h index 9bc66d9e4b..47a6fe17f2 100644 --- a/esphome/components/rp2040/gpio.h +++ b/esphome/components/rp2040/gpio.h @@ -29,8 +29,8 @@ class RP2040GPIOPin : public InternalGPIOPin { void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + bool inverted_{}; + gpio::Flags flags_{}; }; } // namespace rp2040 diff --git a/esphome/components/rp2040/gpio.py b/esphome/components/rp2040/gpio.py index 58514f7db5..193e567d17 100644 --- a/esphome/components/rp2040/gpio.py +++ b/esphome/components/rp2040/gpio.py @@ -94,6 +94,9 @@ async def rp2040_pin_to_code(config): var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) + # Only set if true to avoid bloating setup() function + # (inverted bit in pin_flags_ bitfield is zero-initialized to false) + if config[CONF_INVERTED]: + cg.add(var.set_inverted(True)) cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) return var diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 58fb67a3ea..870a623f32 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -264,10 +265,22 @@ template class IsRunningCondition : public Condition class ScriptWaitAction : public Action, public Component { public: ScriptWaitAction(C *script) : script_(script) {} + void setup() override { + // Start with loop disabled - only enable when there's work to do + this->disable_loop(); + } + void play_complex(Ts... x) override { this->num_running_++; // Check if we can continue immediately. @@ -275,7 +288,11 @@ template class ScriptWaitAction : public Action, this->play_next_(x...); return; } - this->var_ = std::make_tuple(x...); + + // Store parameters for later execution + this->param_queue_.emplace_front(x...); + // Enable loop now that we have work to do + this->enable_loop(); this->loop(); } @@ -286,15 +303,30 @@ template class ScriptWaitAction : public Action, if (this->script_->is_running()) return; - this->play_next_tuple_(this->var_); + while (!this->param_queue_.empty()) { + auto ¶ms = this->param_queue_.front(); + this->play_next_tuple_(params, typename gens::type()); + this->param_queue_.pop_front(); + } + // Queue is now empty - disable loop until next play_complex + this->disable_loop(); } void play(Ts... x) override { /* ignore - see play_complex */ } + void stop() override { + this->param_queue_.clear(); + this->disable_loop(); + } + protected: + template void play_next_tuple_(const std::tuple &tuple, seq /*unused*/) { + this->play_next_(std::get(tuple)...); + } + C *script_; - std::tuple var_{}; + std::forward_list> param_queue_; }; } // namespace script diff --git a/esphome/components/sdl/binary_sensor.py b/esphome/components/sdl/binary_sensor.py index 3ea6c2d218..e19a488800 100644 --- a/esphome/components/sdl/binary_sensor.py +++ b/esphome/components/sdl/binary_sensor.py @@ -12,241 +12,256 @@ CODEOWNERS = ["@bdm310"] STATE_ARG = "state" -SDL_KEYMAP = { - "SDLK_UNKNOWN": 0, - "SDLK_FIRST": 0, - "SDLK_BACKSPACE": 8, - "SDLK_TAB": 9, - "SDLK_CLEAR": 12, - "SDLK_RETURN": 13, - "SDLK_PAUSE": 19, - "SDLK_ESCAPE": 27, - "SDLK_SPACE": 32, - "SDLK_EXCLAIM": 33, - "SDLK_QUOTEDBL": 34, - "SDLK_HASH": 35, - "SDLK_DOLLAR": 36, - "SDLK_AMPERSAND": 38, - "SDLK_QUOTE": 39, - "SDLK_LEFTPAREN": 40, - "SDLK_RIGHTPAREN": 41, - "SDLK_ASTERISK": 42, - "SDLK_PLUS": 43, - "SDLK_COMMA": 44, - "SDLK_MINUS": 45, - "SDLK_PERIOD": 46, - "SDLK_SLASH": 47, - "SDLK_0": 48, - "SDLK_1": 49, - "SDLK_2": 50, - "SDLK_3": 51, - "SDLK_4": 52, - "SDLK_5": 53, - "SDLK_6": 54, - "SDLK_7": 55, - "SDLK_8": 56, - "SDLK_9": 57, - "SDLK_COLON": 58, - "SDLK_SEMICOLON": 59, - "SDLK_LESS": 60, - "SDLK_EQUALS": 61, - "SDLK_GREATER": 62, - "SDLK_QUESTION": 63, - "SDLK_AT": 64, - "SDLK_LEFTBRACKET": 91, - "SDLK_BACKSLASH": 92, - "SDLK_RIGHTBRACKET": 93, - "SDLK_CARET": 94, - "SDLK_UNDERSCORE": 95, - "SDLK_BACKQUOTE": 96, - "SDLK_a": 97, - "SDLK_b": 98, - "SDLK_c": 99, - "SDLK_d": 100, - "SDLK_e": 101, - "SDLK_f": 102, - "SDLK_g": 103, - "SDLK_h": 104, - "SDLK_i": 105, - "SDLK_j": 106, - "SDLK_k": 107, - "SDLK_l": 108, - "SDLK_m": 109, - "SDLK_n": 110, - "SDLK_o": 111, - "SDLK_p": 112, - "SDLK_q": 113, - "SDLK_r": 114, - "SDLK_s": 115, - "SDLK_t": 116, - "SDLK_u": 117, - "SDLK_v": 118, - "SDLK_w": 119, - "SDLK_x": 120, - "SDLK_y": 121, - "SDLK_z": 122, - "SDLK_DELETE": 127, - "SDLK_WORLD_0": 160, - "SDLK_WORLD_1": 161, - "SDLK_WORLD_2": 162, - "SDLK_WORLD_3": 163, - "SDLK_WORLD_4": 164, - "SDLK_WORLD_5": 165, - "SDLK_WORLD_6": 166, - "SDLK_WORLD_7": 167, - "SDLK_WORLD_8": 168, - "SDLK_WORLD_9": 169, - "SDLK_WORLD_10": 170, - "SDLK_WORLD_11": 171, - "SDLK_WORLD_12": 172, - "SDLK_WORLD_13": 173, - "SDLK_WORLD_14": 174, - "SDLK_WORLD_15": 175, - "SDLK_WORLD_16": 176, - "SDLK_WORLD_17": 177, - "SDLK_WORLD_18": 178, - "SDLK_WORLD_19": 179, - "SDLK_WORLD_20": 180, - "SDLK_WORLD_21": 181, - "SDLK_WORLD_22": 182, - "SDLK_WORLD_23": 183, - "SDLK_WORLD_24": 184, - "SDLK_WORLD_25": 185, - "SDLK_WORLD_26": 186, - "SDLK_WORLD_27": 187, - "SDLK_WORLD_28": 188, - "SDLK_WORLD_29": 189, - "SDLK_WORLD_30": 190, - "SDLK_WORLD_31": 191, - "SDLK_WORLD_32": 192, - "SDLK_WORLD_33": 193, - "SDLK_WORLD_34": 194, - "SDLK_WORLD_35": 195, - "SDLK_WORLD_36": 196, - "SDLK_WORLD_37": 197, - "SDLK_WORLD_38": 198, - "SDLK_WORLD_39": 199, - "SDLK_WORLD_40": 200, - "SDLK_WORLD_41": 201, - "SDLK_WORLD_42": 202, - "SDLK_WORLD_43": 203, - "SDLK_WORLD_44": 204, - "SDLK_WORLD_45": 205, - "SDLK_WORLD_46": 206, - "SDLK_WORLD_47": 207, - "SDLK_WORLD_48": 208, - "SDLK_WORLD_49": 209, - "SDLK_WORLD_50": 210, - "SDLK_WORLD_51": 211, - "SDLK_WORLD_52": 212, - "SDLK_WORLD_53": 213, - "SDLK_WORLD_54": 214, - "SDLK_WORLD_55": 215, - "SDLK_WORLD_56": 216, - "SDLK_WORLD_57": 217, - "SDLK_WORLD_58": 218, - "SDLK_WORLD_59": 219, - "SDLK_WORLD_60": 220, - "SDLK_WORLD_61": 221, - "SDLK_WORLD_62": 222, - "SDLK_WORLD_63": 223, - "SDLK_WORLD_64": 224, - "SDLK_WORLD_65": 225, - "SDLK_WORLD_66": 226, - "SDLK_WORLD_67": 227, - "SDLK_WORLD_68": 228, - "SDLK_WORLD_69": 229, - "SDLK_WORLD_70": 230, - "SDLK_WORLD_71": 231, - "SDLK_WORLD_72": 232, - "SDLK_WORLD_73": 233, - "SDLK_WORLD_74": 234, - "SDLK_WORLD_75": 235, - "SDLK_WORLD_76": 236, - "SDLK_WORLD_77": 237, - "SDLK_WORLD_78": 238, - "SDLK_WORLD_79": 239, - "SDLK_WORLD_80": 240, - "SDLK_WORLD_81": 241, - "SDLK_WORLD_82": 242, - "SDLK_WORLD_83": 243, - "SDLK_WORLD_84": 244, - "SDLK_WORLD_85": 245, - "SDLK_WORLD_86": 246, - "SDLK_WORLD_87": 247, - "SDLK_WORLD_88": 248, - "SDLK_WORLD_89": 249, - "SDLK_WORLD_90": 250, - "SDLK_WORLD_91": 251, - "SDLK_WORLD_92": 252, - "SDLK_WORLD_93": 253, - "SDLK_WORLD_94": 254, - "SDLK_WORLD_95": 255, - "SDLK_KP0": 256, - "SDLK_KP1": 257, - "SDLK_KP2": 258, - "SDLK_KP3": 259, - "SDLK_KP4": 260, - "SDLK_KP5": 261, - "SDLK_KP6": 262, - "SDLK_KP7": 263, - "SDLK_KP8": 264, - "SDLK_KP9": 265, - "SDLK_KP_PERIOD": 266, - "SDLK_KP_DIVIDE": 267, - "SDLK_KP_MULTIPLY": 268, - "SDLK_KP_MINUS": 269, - "SDLK_KP_PLUS": 270, - "SDLK_KP_ENTER": 271, - "SDLK_KP_EQUALS": 272, - "SDLK_UP": 273, - "SDLK_DOWN": 274, - "SDLK_RIGHT": 275, - "SDLK_LEFT": 276, - "SDLK_INSERT": 277, - "SDLK_HOME": 278, - "SDLK_END": 279, - "SDLK_PAGEUP": 280, - "SDLK_PAGEDOWN": 281, - "SDLK_F1": 282, - "SDLK_F2": 283, - "SDLK_F3": 284, - "SDLK_F4": 285, - "SDLK_F5": 286, - "SDLK_F6": 287, - "SDLK_F7": 288, - "SDLK_F8": 289, - "SDLK_F9": 290, - "SDLK_F10": 291, - "SDLK_F11": 292, - "SDLK_F12": 293, - "SDLK_F13": 294, - "SDLK_F14": 295, - "SDLK_F15": 296, - "SDLK_NUMLOCK": 300, - "SDLK_CAPSLOCK": 301, - "SDLK_SCROLLOCK": 302, - "SDLK_RSHIFT": 303, - "SDLK_LSHIFT": 304, - "SDLK_RCTRL": 305, - "SDLK_LCTRL": 306, - "SDLK_RALT": 307, - "SDLK_LALT": 308, - "SDLK_RMETA": 309, - "SDLK_LMETA": 310, - "SDLK_LSUPER": 311, - "SDLK_RSUPER": 312, - "SDLK_MODE": 313, - "SDLK_COMPOSE": 314, - "SDLK_HELP": 315, - "SDLK_PRINT": 316, - "SDLK_SYSREQ": 317, - "SDLK_BREAK": 318, - "SDLK_MENU": 319, - "SDLK_POWER": 320, - "SDLK_EURO": 321, - "SDLK_UNDO": 322, -} +SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode") + +SDL_KEYS = ( + "SDLK_UNKNOWN", + "SDLK_RETURN", + "SDLK_ESCAPE", + "SDLK_BACKSPACE", + "SDLK_TAB", + "SDLK_SPACE", + "SDLK_EXCLAIM", + "SDLK_QUOTEDBL", + "SDLK_HASH", + "SDLK_PERCENT", + "SDLK_DOLLAR", + "SDLK_AMPERSAND", + "SDLK_QUOTE", + "SDLK_LEFTPAREN", + "SDLK_RIGHTPAREN", + "SDLK_ASTERISK", + "SDLK_PLUS", + "SDLK_COMMA", + "SDLK_MINUS", + "SDLK_PERIOD", + "SDLK_SLASH", + "SDLK_0", + "SDLK_1", + "SDLK_2", + "SDLK_3", + "SDLK_4", + "SDLK_5", + "SDLK_6", + "SDLK_7", + "SDLK_8", + "SDLK_9", + "SDLK_COLON", + "SDLK_SEMICOLON", + "SDLK_LESS", + "SDLK_EQUALS", + "SDLK_GREATER", + "SDLK_QUESTION", + "SDLK_AT", + "SDLK_LEFTBRACKET", + "SDLK_BACKSLASH", + "SDLK_RIGHTBRACKET", + "SDLK_CARET", + "SDLK_UNDERSCORE", + "SDLK_BACKQUOTE", + "SDLK_a", + "SDLK_b", + "SDLK_c", + "SDLK_d", + "SDLK_e", + "SDLK_f", + "SDLK_g", + "SDLK_h", + "SDLK_i", + "SDLK_j", + "SDLK_k", + "SDLK_l", + "SDLK_m", + "SDLK_n", + "SDLK_o", + "SDLK_p", + "SDLK_q", + "SDLK_r", + "SDLK_s", + "SDLK_t", + "SDLK_u", + "SDLK_v", + "SDLK_w", + "SDLK_x", + "SDLK_y", + "SDLK_z", + "SDLK_CAPSLOCK", + "SDLK_F1", + "SDLK_F2", + "SDLK_F3", + "SDLK_F4", + "SDLK_F5", + "SDLK_F6", + "SDLK_F7", + "SDLK_F8", + "SDLK_F9", + "SDLK_F10", + "SDLK_F11", + "SDLK_F12", + "SDLK_PRINTSCREEN", + "SDLK_SCROLLLOCK", + "SDLK_PAUSE", + "SDLK_INSERT", + "SDLK_HOME", + "SDLK_PAGEUP", + "SDLK_DELETE", + "SDLK_END", + "SDLK_PAGEDOWN", + "SDLK_RIGHT", + "SDLK_LEFT", + "SDLK_DOWN", + "SDLK_UP", + "SDLK_NUMLOCKCLEAR", + "SDLK_KP_DIVIDE", + "SDLK_KP_MULTIPLY", + "SDLK_KP_MINUS", + "SDLK_KP_PLUS", + "SDLK_KP_ENTER", + "SDLK_KP_1", + "SDLK_KP_2", + "SDLK_KP_3", + "SDLK_KP_4", + "SDLK_KP_5", + "SDLK_KP_6", + "SDLK_KP_7", + "SDLK_KP_8", + "SDLK_KP_9", + "SDLK_KP_0", + "SDLK_KP_PERIOD", + "SDLK_APPLICATION", + "SDLK_POWER", + "SDLK_KP_EQUALS", + "SDLK_F13", + "SDLK_F14", + "SDLK_F15", + "SDLK_F16", + "SDLK_F17", + "SDLK_F18", + "SDLK_F19", + "SDLK_F20", + "SDLK_F21", + "SDLK_F22", + "SDLK_F23", + "SDLK_F24", + "SDLK_EXECUTE", + "SDLK_HELP", + "SDLK_MENU", + "SDLK_SELECT", + "SDLK_STOP", + "SDLK_AGAIN", + "SDLK_UNDO", + "SDLK_CUT", + "SDLK_COPY", + "SDLK_PASTE", + "SDLK_FIND", + "SDLK_MUTE", + "SDLK_VOLUMEUP", + "SDLK_VOLUMEDOWN", + "SDLK_KP_COMMA", + "SDLK_KP_EQUALSAS400", + "SDLK_ALTERASE", + "SDLK_SYSREQ", + "SDLK_CANCEL", + "SDLK_CLEAR", + "SDLK_PRIOR", + "SDLK_RETURN2", + "SDLK_SEPARATOR", + "SDLK_OUT", + "SDLK_OPER", + "SDLK_CLEARAGAIN", + "SDLK_CRSEL", + "SDLK_EXSEL", + "SDLK_KP_00", + "SDLK_KP_000", + "SDLK_THOUSANDSSEPARATOR", + "SDLK_DECIMALSEPARATOR", + "SDLK_CURRENCYUNIT", + "SDLK_CURRENCYSUBUNIT", + "SDLK_KP_LEFTPAREN", + "SDLK_KP_RIGHTPAREN", + "SDLK_KP_LEFTBRACE", + "SDLK_KP_RIGHTBRACE", + "SDLK_KP_TAB", + "SDLK_KP_BACKSPACE", + "SDLK_KP_A", + "SDLK_KP_B", + "SDLK_KP_C", + "SDLK_KP_D", + "SDLK_KP_E", + "SDLK_KP_F", + "SDLK_KP_XOR", + "SDLK_KP_POWER", + "SDLK_KP_PERCENT", + "SDLK_KP_LESS", + "SDLK_KP_GREATER", + "SDLK_KP_AMPERSAND", + "SDLK_KP_DBLAMPERSAND", + "SDLK_KP_VERTICALBAR", + "SDLK_KP_DBLVERTICALBAR", + "SDLK_KP_COLON", + "SDLK_KP_HASH", + "SDLK_KP_SPACE", + "SDLK_KP_AT", + "SDLK_KP_EXCLAM", + "SDLK_KP_MEMSTORE", + "SDLK_KP_MEMRECALL", + "SDLK_KP_MEMCLEAR", + "SDLK_KP_MEMADD", + "SDLK_KP_MEMSUBTRACT", + "SDLK_KP_MEMMULTIPLY", + "SDLK_KP_MEMDIVIDE", + "SDLK_KP_PLUSMINUS", + "SDLK_KP_CLEAR", + "SDLK_KP_CLEARENTRY", + "SDLK_KP_BINARY", + "SDLK_KP_OCTAL", + "SDLK_KP_DECIMAL", + "SDLK_KP_HEXADECIMAL", + "SDLK_LCTRL", + "SDLK_LSHIFT", + "SDLK_LALT", + "SDLK_LGUI", + "SDLK_RCTRL", + "SDLK_RSHIFT", + "SDLK_RALT", + "SDLK_RGUI", + "SDLK_MODE", + "SDLK_AUDIONEXT", + "SDLK_AUDIOPREV", + "SDLK_AUDIOSTOP", + "SDLK_AUDIOPLAY", + "SDLK_AUDIOMUTE", + "SDLK_MEDIASELECT", + "SDLK_WWW", + "SDLK_MAIL", + "SDLK_CALCULATOR", + "SDLK_COMPUTER", + "SDLK_AC_SEARCH", + "SDLK_AC_HOME", + "SDLK_AC_BACK", + "SDLK_AC_FORWARD", + "SDLK_AC_STOP", + "SDLK_AC_REFRESH", + "SDLK_AC_BOOKMARKS", + "SDLK_BRIGHTNESSDOWN", + "SDLK_BRIGHTNESSUP", + "SDLK_DISPLAYSWITCH", + "SDLK_KBDILLUMTOGGLE", + "SDLK_KBDILLUMDOWN", + "SDLK_KBDILLUMUP", + "SDLK_EJECT", + "SDLK_SLEEP", + "SDLK_APP1", + "SDLK_APP2", + "SDLK_AUDIOREWIND", + "SDLK_AUDIOFASTFORWARD", + "SDLK_SOFTLEFT", + "SDLK_SOFTRIGHT", + "SDLK_CALL", + "SDLK_ENDCALL", +) + +SDL_KEYMAP = {key: getattr(SDL_KeyCode, key) for key in SDL_KEYS} CONFIG_SCHEMA = ( binary_sensor.binary_sensor_schema(BinarySensor) diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 76523ce5c0..4c0416d727 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -435,12 +435,12 @@ void MR24HPC1Component::r24_frame_parse_open_underlying_information_(uint8_t *da } else if ((this->existence_boundary_select_ != nullptr) && ((data[FRAME_COMMAND_WORD_INDEX] == 0x0a) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8a))) { if (this->existence_boundary_select_->has_index(data[FRAME_DATA_INDEX] - 1)) { - this->existence_boundary_select_->publish_state(S_BOUNDARY_STR[data[FRAME_DATA_INDEX] - 1]); + this->existence_boundary_select_->publish_state(data[FRAME_DATA_INDEX] - 1); } } else if ((this->motion_boundary_select_ != nullptr) && ((data[FRAME_COMMAND_WORD_INDEX] == 0x0b) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8b))) { if (this->motion_boundary_select_->has_index(data[FRAME_DATA_INDEX] - 1)) { - this->motion_boundary_select_->publish_state(S_BOUNDARY_STR[data[FRAME_DATA_INDEX] - 1]); + this->motion_boundary_select_->publish_state(data[FRAME_DATA_INDEX] - 1); } } else if ((this->motion_trigger_number_ != nullptr) && ((data[FRAME_COMMAND_WORD_INDEX] == 0x0c) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8c))) { @@ -515,7 +515,7 @@ void MR24HPC1Component::r24_frame_parse_work_status_(uint8_t *data) { ESP_LOGD(TAG, "Reply: get radar init status 0x%02X", data[FRAME_DATA_INDEX]); } else if (data[FRAME_COMMAND_WORD_INDEX] == 0x07) { if ((this->scene_mode_select_ != nullptr) && (this->scene_mode_select_->has_index(data[FRAME_DATA_INDEX]))) { - this->scene_mode_select_->publish_state(S_SCENE_STR[data[FRAME_DATA_INDEX]]); + this->scene_mode_select_->publish_state(data[FRAME_DATA_INDEX]); } else { ESP_LOGD(TAG, "Select has index offset %d Error", data[FRAME_DATA_INDEX]); } @@ -538,7 +538,7 @@ void MR24HPC1Component::r24_frame_parse_work_status_(uint8_t *data) { ESP_LOGD(TAG, "Reply: get radar init status 0x%02X", data[FRAME_DATA_INDEX]); } else if (data[FRAME_COMMAND_WORD_INDEX] == 0x87) { if ((this->scene_mode_select_ != nullptr) && (this->scene_mode_select_->has_index(data[FRAME_DATA_INDEX]))) { - this->scene_mode_select_->publish_state(S_SCENE_STR[data[FRAME_DATA_INDEX]]); + this->scene_mode_select_->publish_state(data[FRAME_DATA_INDEX]); } else { ESP_LOGD(TAG, "Select has index offset %d Error", data[FRAME_DATA_INDEX]); } @@ -581,7 +581,7 @@ void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) { ((data[FRAME_COMMAND_WORD_INDEX] == 0x0A) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8A))) { // none:0x00 1s:0x01 30s:0x02 1min:0x03 2min:0x04 5min:0x05 10min:0x06 30min:0x07 1hour:0x08 if (data[FRAME_DATA_INDEX] < 9) { - this->unman_time_select_->publish_state(S_UNMANNED_TIME_STR[data[FRAME_DATA_INDEX]]); + this->unman_time_select_->publish_state(data[FRAME_DATA_INDEX]); } } else if ((this->keep_away_text_sensor_ != nullptr) && ((data[FRAME_COMMAND_WORD_INDEX] == 0x0B) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8B))) { diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index dea7976578..7f8bd6a43c 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -292,7 +292,7 @@ void MR60FDA2Component::process_frame_() { install_height_float = bit_cast(current_install_height_int); uint32_t select_index = find_nearest_index(install_height_float, INSTALL_HEIGHT, 7); - this->install_height_select_->publish_state(this->install_height_select_->at(select_index).value()); + this->install_height_select_->publish_state(select_index); } if (this->height_threshold_select_ != nullptr) { @@ -301,7 +301,7 @@ void MR60FDA2Component::process_frame_() { height_threshold_float = bit_cast(current_height_threshold_int); size_t select_index = find_nearest_index(height_threshold_float, HEIGHT_THRESHOLD, 7); - this->height_threshold_select_->publish_state(this->height_threshold_select_->at(select_index).value()); + this->height_threshold_select_->publish_state(select_index); } if (this->sensitivity_select_ != nullptr) { @@ -309,7 +309,7 @@ void MR60FDA2Component::process_frame_() { encode_uint32(current_data_buf_[11], current_data_buf_[10], current_data_buf_[9], current_data_buf_[8]); uint32_t select_index = find_nearest_index(current_sensitivity, SENSITIVITY, 3); - this->sensitivity_select_->publish_state(this->sensitivity_select_->at(select_index).value()); + this->sensitivity_select_->publish_state(select_index); } ESP_LOGD(TAG, "Mounting height: %.2f, Height threshold: %.2f, Sensitivity: %" PRIu32, install_height_float, diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index c7146df9fb..7c50fe02c0 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -86,11 +86,6 @@ def select_schema( return _SELECT_SCHEMA.extend(schema) -# Remove before 2025.11.0 -SELECT_SCHEMA = select_schema(Select) -SELECT_SCHEMA.add_extra(cv.deprecated_schema_constant("select")) - - async def setup_select_core_(var, config, *, options: list[str]): await setup_entity(var, config, "select") diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 16e8288ca1..fae485709e 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -1,29 +1,49 @@ #include "select.h" #include "esphome/core/log.h" +#include namespace esphome { namespace select { static const char *const TAG = "select"; -void Select::publish_state(const std::string &state) { +void Select::publish_state(const std::string &state) { this->publish_state(state.c_str()); } + +void Select::publish_state(const char *state) { auto index = this->index_of(state); - const auto *name = this->get_name().c_str(); if (index.has_value()) { - this->set_has_state(true); - this->state = state; - ESP_LOGD(TAG, "'%s': Sending state %s (index %zu)", name, state.c_str(), index.value()); - this->state_callback_.call(state, index.value()); + this->publish_state(index.value()); } else { - ESP_LOGE(TAG, "'%s': invalid state for publish_state(): %s", name, state.c_str()); + ESP_LOGE(TAG, "'%s': Invalid option %s", this->get_name().c_str(), state); } } +void Select::publish_state(size_t index) { + if (!this->has_index(index)) { + ESP_LOGE(TAG, "'%s': Invalid index %zu", this->get_name().c_str(), index); + return; + } + const char *option = this->option_at(index); + this->set_has_state(true); + this->active_index_ = index; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->state = option; // Update deprecated member for backward compatibility +#pragma GCC diagnostic pop + ESP_LOGD(TAG, "'%s': Sending state %s (index %zu)", this->get_name().c_str(), option, index); + // Callback signature requires std::string, create temporary for compatibility + this->state_callback_.call(std::string(option), index); +} + +const char *Select::current_option() const { return this->option_at(this->active_index_); } + void Select::add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } -bool Select::has_option(const std::string &option) const { return this->index_of(option).has_value(); } +bool Select::has_option(const std::string &option) const { return this->index_of(option.c_str()).has_value(); } + +bool Select::has_option(const char *option) const { return this->index_of(option).has_value(); } bool Select::has_index(size_t index) const { return index < this->size(); } @@ -32,10 +52,12 @@ size_t Select::size() const { return options.size(); } -optional Select::index_of(const std::string &option) const { +optional Select::index_of(const std::string &option) const { return this->index_of(option.c_str()); } + +optional Select::index_of(const char *option) const { const auto &options = traits.get_options(); for (size_t i = 0; i < options.size(); i++) { - if (options[i] == option) { + if (strcmp(options[i], option) == 0) { return i; } } @@ -44,20 +66,20 @@ optional Select::index_of(const std::string &option) const { optional Select::active_index() const { if (this->has_state()) { - return this->index_of(this->state); - } else { - return {}; + return this->active_index_; } + return {}; } optional Select::at(size_t index) const { if (this->has_index(index)) { const auto &options = traits.get_options(); - return options.at(index); - } else { - return {}; + return std::string(options.at(index)); } + return {}; } +const char *Select::option_at(size_t index) const { return traits.get_options().at(index); } + } // namespace select } // namespace esphome diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 902b8a78ce..030646c1ad 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -30,16 +30,31 @@ namespace select { */ class Select : public EntityBase { public: - std::string state; SelectTraits traits; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + /// @deprecated Use current_option() instead. This member will be removed in ESPHome 2026.5.0. + __attribute__((deprecated("Use current_option() instead of .state. Will be removed in 2026.5.0"))) + std::string state{}; + + Select() = default; + ~Select() = default; +#pragma GCC diagnostic pop + void publish_state(const std::string &state); + void publish_state(const char *state); + void publish_state(size_t index); + + /// Return the currently selected option (as const char* from flash). + const char *current_option() const; /// Instantiate a SelectCall object to modify this select component's state. SelectCall make_call() { return SelectCall(this); } /// Return whether this select component contains the provided option. bool has_option(const std::string &option) const; + bool has_option(const char *option) const; /// Return whether this select component contains the provided index offset. bool has_index(size_t index) const; @@ -49,6 +64,7 @@ class Select : public EntityBase { /// Find the (optional) index offset of the provided option value. optional index_of(const std::string &option) const; + optional index_of(const char *option) const; /// Return the (optional) index offset of the currently active option. optional active_index() const; @@ -56,18 +72,47 @@ class Select : public EntityBase { /// Return the (optional) option value at the provided index offset. optional at(size_t index) const; + /// Return the option value at the provided index offset (as const char* from flash). + const char *option_at(size_t index) const; + void add_on_state_callback(std::function &&callback); + /** Set the value of the select by index, this is an optional virtual method. + * + * This method is called by the SelectCall when the index is already known. + * Default implementation converts to string and calls control(). + * Override this to work directly with indices and avoid string conversions. + * + * @param index The index as validated by the SelectCall. + */ + virtual void control(size_t index) { this->control(this->option_at(index)); } + protected: friend class SelectCall; - /** Set the value of the select, this is a virtual method that each select integration must implement. + size_t active_index_{0}; + + /** Set the value of the select, this is a virtual method that each select integration can implement. * - * This method is called by the SelectCall. + * This method is called by control(size_t) when not overridden, or directly by external code. + * Integrations can either: + * 1. Override this method to handle string-based control (traditional approach) + * 2. Override control(size_t) instead to work with indices directly (recommended) + * + * Default implementation converts to index and calls control(size_t). + * + * Delegation chain: + * - SelectCall::perform() → control(size_t) → [if not overridden] → control(string) + * - External code → control(string) → publish_state(string) → publish_state(size_t) * * @param value The value as validated by the SelectCall. */ - virtual void control(const std::string &value) = 0; + virtual void control(const std::string &value) { + auto index = this->index_of(value); + if (index.has_value()) { + this->control(index.value()); + } + } CallbackManager state_callback_; }; diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index dd398b4052..aa7559e24e 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -7,19 +7,21 @@ namespace select { static const char *const TAG = "select"; -SelectCall &SelectCall::set_option(const std::string &option) { - return with_operation(SELECT_OP_SET).with_option(option); +SelectCall &SelectCall::set_option(const std::string &option) { return this->with_option(option); } + +SelectCall &SelectCall::set_option(const char *option) { return this->with_option(option); } + +SelectCall &SelectCall::set_index(size_t index) { return this->with_index(index); } + +SelectCall &SelectCall::select_next(bool cycle) { return this->with_operation(SELECT_OP_NEXT).with_cycle(cycle); } + +SelectCall &SelectCall::select_previous(bool cycle) { + return this->with_operation(SELECT_OP_PREVIOUS).with_cycle(cycle); } -SelectCall &SelectCall::set_index(size_t index) { return with_operation(SELECT_OP_SET_INDEX).with_index(index); } +SelectCall &SelectCall::select_first() { return this->with_operation(SELECT_OP_FIRST); } -SelectCall &SelectCall::select_next(bool cycle) { return with_operation(SELECT_OP_NEXT).with_cycle(cycle); } - -SelectCall &SelectCall::select_previous(bool cycle) { return with_operation(SELECT_OP_PREVIOUS).with_cycle(cycle); } - -SelectCall &SelectCall::select_first() { return with_operation(SELECT_OP_FIRST); } - -SelectCall &SelectCall::select_last() { return with_operation(SELECT_OP_LAST); } +SelectCall &SelectCall::select_last() { return this->with_operation(SELECT_OP_LAST); } SelectCall &SelectCall::with_operation(SelectOperation operation) { this->operation_ = operation; @@ -31,89 +33,96 @@ SelectCall &SelectCall::with_cycle(bool cycle) { return *this; } -SelectCall &SelectCall::with_option(const std::string &option) { - this->option_ = option; +SelectCall &SelectCall::with_option(const std::string &option) { return this->with_option(option.c_str()); } + +SelectCall &SelectCall::with_option(const char *option) { + this->operation_ = SELECT_OP_SET; + // Find the option index - this validates the option exists + this->index_ = this->parent_->index_of(option); return *this; } SelectCall &SelectCall::with_index(size_t index) { - this->index_ = index; + this->operation_ = SELECT_OP_SET; + if (index >= this->parent_->size()) { + ESP_LOGW(TAG, "'%s' - Index value %zu out of bounds", this->parent_->get_name().c_str(), index); + this->index_ = {}; // Store nullopt for invalid index + } else { + this->index_ = index; + } return *this; } +optional SelectCall::calculate_target_index_(const char *name) { + const auto &options = this->parent_->traits.get_options(); + if (options.empty()) { + ESP_LOGW(TAG, "'%s' - Select has no options", name); + return {}; + } + + if (this->operation_ == SELECT_OP_FIRST) { + return 0; + } + + if (this->operation_ == SELECT_OP_LAST) { + return options.size() - 1; + } + + if (this->operation_ == SELECT_OP_SET) { + ESP_LOGD(TAG, "'%s' - Setting", name); + if (!this->index_.has_value()) { + ESP_LOGW(TAG, "'%s' - No option set", name); + return {}; + } + return this->index_.value(); + } + + // SELECT_OP_NEXT or SELECT_OP_PREVIOUS + ESP_LOGD(TAG, "'%s' - Selecting %s, with%s cycling", name, + this->operation_ == SELECT_OP_NEXT ? LOG_STR_LITERAL("next") : LOG_STR_LITERAL("previous"), + this->cycle_ ? LOG_STR_LITERAL("") : LOG_STR_LITERAL("out")); + + const auto size = options.size(); + if (!this->parent_->has_state()) { + return this->operation_ == SELECT_OP_NEXT ? 0 : size - 1; + } + + // Use cached active_index_ instead of index_of() lookup + const auto active_index = this->parent_->active_index_; + if (this->cycle_) { + return (size + active_index + (this->operation_ == SELECT_OP_NEXT ? +1 : -1)) % size; + } + + if (this->operation_ == SELECT_OP_PREVIOUS && active_index > 0) { + return active_index - 1; + } + + if (this->operation_ == SELECT_OP_NEXT && active_index < size - 1) { + return active_index + 1; + } + + return {}; // Can't navigate further without cycling +} + void SelectCall::perform() { auto *parent = this->parent_; const auto *name = parent->get_name().c_str(); - const auto &traits = parent->traits; - const auto &options = traits.get_options(); if (this->operation_ == SELECT_OP_NONE) { ESP_LOGW(TAG, "'%s' - SelectCall performed without selecting an operation", name); return; } - if (options.empty()) { - ESP_LOGW(TAG, "'%s' - Cannot perform SelectCall, select has no options", name); + + // Calculate target index (with_index() and with_option() already validate bounds/existence) + auto target_index = this->calculate_target_index_(name); + if (!target_index.has_value()) { return; } - std::string target_value; - - if (this->operation_ == SELECT_OP_SET) { - ESP_LOGD(TAG, "'%s' - Setting", name); - if (!this->option_.has_value()) { - ESP_LOGW(TAG, "'%s' - No option value set for SelectCall", name); - return; - } - target_value = this->option_.value(); - } else if (this->operation_ == SELECT_OP_SET_INDEX) { - if (!this->index_.has_value()) { - ESP_LOGW(TAG, "'%s' - No index value set for SelectCall", name); - return; - } - if (this->index_.value() >= options.size()) { - ESP_LOGW(TAG, "'%s' - Index value %zu out of bounds", name, this->index_.value()); - return; - } - target_value = options[this->index_.value()]; - } else if (this->operation_ == SELECT_OP_FIRST) { - target_value = options.front(); - } else if (this->operation_ == SELECT_OP_LAST) { - target_value = options.back(); - } else if (this->operation_ == SELECT_OP_NEXT || this->operation_ == SELECT_OP_PREVIOUS) { - auto cycle = this->cycle_; - ESP_LOGD(TAG, "'%s' - Selecting %s, with%s cycling", name, this->operation_ == SELECT_OP_NEXT ? "next" : "previous", - cycle ? "" : "out"); - if (!parent->has_state()) { - target_value = this->operation_ == SELECT_OP_NEXT ? options.front() : options.back(); - } else { - auto index = parent->index_of(parent->state); - if (index.has_value()) { - auto size = options.size(); - if (cycle) { - auto use_index = (size + index.value() + (this->operation_ == SELECT_OP_NEXT ? +1 : -1)) % size; - target_value = options[use_index]; - } else { - if (this->operation_ == SELECT_OP_PREVIOUS && index.value() > 0) { - target_value = options[index.value() - 1]; - } else if (this->operation_ == SELECT_OP_NEXT && index.value() < options.size() - 1) { - target_value = options[index.value() + 1]; - } else { - return; - } - } - } else { - target_value = this->operation_ == SELECT_OP_NEXT ? options.front() : options.back(); - } - } - } - - if (!parent->has_option(target_value)) { - ESP_LOGW(TAG, "'%s' - Option %s is not a valid option", name, target_value.c_str()); - return; - } - - ESP_LOGD(TAG, "'%s' - Set selected option to: %s", name, target_value.c_str()); - parent->control(target_value); + auto idx = target_index.value(); + // All operations use indices, call control() by index to avoid string conversion + ESP_LOGD(TAG, "'%s' - Set selected option to: %s", name, parent->option_at(idx)); + parent->control(idx); } } // namespace select diff --git a/esphome/components/select/select_call.h b/esphome/components/select/select_call.h index efc9a982ec..eae7d3de1d 100644 --- a/esphome/components/select/select_call.h +++ b/esphome/components/select/select_call.h @@ -10,7 +10,6 @@ class Select; enum SelectOperation { SELECT_OP_NONE, SELECT_OP_SET, - SELECT_OP_SET_INDEX, SELECT_OP_NEXT, SELECT_OP_PREVIOUS, SELECT_OP_FIRST, @@ -23,6 +22,7 @@ class SelectCall { void perform(); SelectCall &set_option(const std::string &option); + SelectCall &set_option(const char *option); SelectCall &set_index(size_t index); SelectCall &select_next(bool cycle); @@ -33,11 +33,13 @@ class SelectCall { SelectCall &with_operation(SelectOperation operation); SelectCall &with_cycle(bool cycle); SelectCall &with_option(const std::string &option); + SelectCall &with_option(const char *option); SelectCall &with_index(size_t index); protected: + __attribute__((always_inline)) inline optional calculate_target_index_(const char *name); + Select *const parent_; - optional option_; optional index_; SelectOperation operation_{SELECT_OP_NONE}; bool cycle_; diff --git a/esphome/components/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index a8cd4290c8..c6ded98ebf 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -3,9 +3,16 @@ namespace esphome { namespace select { -void SelectTraits::set_options(std::vector options) { this->options_ = std::move(options); } +void SelectTraits::set_options(const std::initializer_list &options) { this->options_ = options; } -const std::vector &SelectTraits::get_options() const { return this->options_; } +void SelectTraits::set_options(const FixedVector &options) { + this->options_.init(options.size()); + for (size_t i = 0; i < options.size(); i++) { + this->options_[i] = options[i]; + } +} + +const FixedVector &SelectTraits::get_options() const { return this->options_; } } // namespace select } // namespace esphome diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index 128066dd6b..ee59a030ad 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -1,18 +1,19 @@ #pragma once -#include -#include +#include "esphome/core/helpers.h" +#include namespace esphome { namespace select { class SelectTraits { public: - void set_options(std::vector options); - const std::vector &get_options() const; + void set_options(const std::initializer_list &options); + void set_options(const FixedVector &options); + const FixedVector &get_options() const; protected: - std::vector options_; + FixedVector options_; }; } // namespace select diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 7e91bb83c4..e8fec222a1 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -261,6 +261,7 @@ ExponentialMovingAverageFilter = sensor_ns.class_( ) ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter, cg.Component) LambdaFilter = sensor_ns.class_("LambdaFilter", Filter) +StatelessLambdaFilter = sensor_ns.class_("StatelessLambdaFilter", Filter) OffsetFilter = sensor_ns.class_("OffsetFilter", Filter) MultiplyFilter = sensor_ns.class_("MultiplyFilter", Filter) ValueListFilter = sensor_ns.class_("ValueListFilter", Filter) @@ -368,11 +369,6 @@ def sensor_schema( return _SENSOR_SCHEMA.extend(schema) -# Remove before 2025.11.0 -SENSOR_SCHEMA = sensor_schema() -SENSOR_SCHEMA.add_extra(cv.deprecated_schema_constant("sensor")) - - @FILTER_REGISTRY.register("offset", OffsetFilter, cv.templatable(cv.float_)) async def offset_filter_to_code(config, filter_id): template_ = await cg.templatable(config, [], float) @@ -573,7 +569,7 @@ async def lambda_filter_to_code(config, filter_id): lambda_ = await cg.process_lambda( config, [(float, "x")], return_type=cg.optional.template(float) ) - return cg.new_Pvariable(filter_id, lambda_) + return automation.new_lambda_pvariable(filter_id, lambda_, StatelessLambdaFilter) DELTA_SCHEMA = cv.Schema( @@ -878,7 +874,9 @@ async def setup_sensor_core_(var, config): cg.add(var.set_unit_of_measurement(unit_of_measurement)) if (accuracy_decimals := config.get(CONF_ACCURACY_DECIMALS)) is not None: cg.add(var.set_accuracy_decimals(accuracy_decimals)) - cg.add(var.set_force_update(config[CONF_FORCE_UPDATE])) + # Only set force_update if True (default is False) + if config[CONF_FORCE_UPDATE]: + cg.add(var.set_force_update(True)) if config.get(CONF_FILTERS): # must exist and not be empty filters = await build_filters(config[CONF_FILTERS]) cg.add(var.set_filters(filters)) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index ecd55308d1..75e28a1efe 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -296,6 +296,21 @@ class LambdaFilter : public Filter { lambda_filter_t lambda_filter_; }; +/** Optimized lambda filter for stateless lambdas (no capture). + * + * Uses function pointer instead of std::function to reduce memory overhead. + * Memory: 4 bytes (function pointer on 32-bit) vs 32 bytes (std::function). + */ +class StatelessLambdaFilter : public Filter { + public: + explicit StatelessLambdaFilter(optional (*lambda_filter)(float)) : lambda_filter_(lambda_filter) {} + + optional new_value(float value) override { return this->lambda_filter_(value); } + + protected: + optional (*lambda_filter_)(float); +}; + /// A simple filter that adds `offset` to each value it receives. class OffsetFilter : public Filter { public: diff --git a/esphome/components/sntp/sntp_component.cpp b/esphome/components/sntp/sntp_component.cpp index 1cca5e8043..331a9b3509 100644 --- a/esphome/components/sntp/sntp_component.cpp +++ b/esphome/components/sntp/sntp_component.cpp @@ -27,7 +27,7 @@ void SNTPComponent::setup() { esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL); size_t i = 0; for (auto &server : this->servers_) { - esp_sntp_setservername(i++, server.c_str()); + esp_sntp_setservername(i++, server); } esp_sntp_set_sync_interval(this->get_update_interval()); esp_sntp_set_time_sync_notification_cb([](struct timeval *tv) { @@ -42,7 +42,7 @@ void SNTPComponent::setup() { size_t i = 0; for (auto &server : this->servers_) { - sntp_setservername(i++, server.c_str()); + sntp_setservername(i++, server); } #if defined(USE_ESP8266) @@ -59,7 +59,7 @@ void SNTPComponent::dump_config() { ESP_LOGCONFIG(TAG, "SNTP Time:"); size_t i = 0; for (auto &server : this->servers_) { - ESP_LOGCONFIG(TAG, " Server %zu: '%s'", i++, server.c_str()); + ESP_LOGCONFIG(TAG, " Server %zu: '%s'", i++, server); } } void SNTPComponent::update() { diff --git a/esphome/components/sntp/sntp_component.h b/esphome/components/sntp/sntp_component.h index dd4c71e082..8f2e411c18 100644 --- a/esphome/components/sntp/sntp_component.h +++ b/esphome/components/sntp/sntp_component.h @@ -2,10 +2,14 @@ #include "esphome/core/component.h" #include "esphome/components/time/real_time_clock.h" +#include namespace esphome { namespace sntp { +// Server count is calculated at compile time by Python codegen +// SNTP_SERVER_COUNT will always be defined + /// The SNTP component allows you to configure local timekeeping via Simple Network Time Protocol. /// /// \note @@ -14,10 +18,7 @@ namespace sntp { /// \see https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html class SNTPComponent : public time::RealTimeClock { public: - SNTPComponent(const std::vector &servers) : servers_(servers) {} - - // Note: set_servers() has been removed and replaced by a constructor - calling set_servers after setup would - // have had no effect anyway, and making the strings immutable avoids the need to strdup their contents. + SNTPComponent(const std::array &servers) : servers_(servers) {} void setup() override; void dump_config() override; @@ -29,7 +30,10 @@ class SNTPComponent : public time::RealTimeClock { void time_synced(); protected: - std::vector servers_; + // Store const char pointers to string literals + // ESP8266: strings in rodata (RAM), but avoids std::string overhead (~24 bytes each) + // Other platforms: strings in flash + std::array servers_; bool has_time_{false}; #if defined(USE_ESP32) diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index 1c8ee402ad..d27fc9991d 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -43,6 +43,11 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): servers = config[CONF_SERVERS] + + # Define server count at compile time + cg.add_define("SNTP_SERVER_COUNT", len(servers)) + + # Pass string literals to constructor - stored in flash/rodata by compiler var = cg.new_Pvariable(config[CONF_ID], servers) await cg.register_component(var, config) diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index 57bd795416..801593c2ac 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -29,7 +29,7 @@ void SpeedFan::control(const fan::FanCall &call) { this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value()) this->direction = *call.get_direction(); - this->preset_mode = call.get_preset_mode(); + this->set_preset_mode_(call.get_preset_mode()); this->write_state_(); this->publish_state(); diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index 454b7fc136..e9a389e0f3 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -1,7 +1,5 @@ #pragma once -#include - #include "esphome/core/component.h" #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" @@ -18,7 +16,7 @@ class SpeedFan : public Component, public fan::Fan { void set_output(output::FloatOutput *output) { this->output_ = output; } void set_oscillating(output::BinaryOutput *oscillating) { this->oscillating_ = oscillating; } void set_direction(output::BinaryOutput *direction) { this->direction_ = direction; } - void set_preset_modes(const std::vector &presets) { this->preset_modes_ = presets; } + void set_preset_modes(std::initializer_list presets) { this->preset_modes_ = presets; } fan::FanTraits get_traits() override { return this->traits_; } protected: @@ -30,7 +28,7 @@ class SpeedFan : public Component, public fan::Fan { output::BinaryOutput *direction_{nullptr}; int speed_count_{}; fan::FanTraits traits_; - std::vector preset_modes_{}; + std::vector preset_modes_{}; }; } // namespace speed diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 098d56bfad..7e15f714f7 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -1,4 +1,6 @@ import logging +from re import Match +from typing import Any from esphome import core from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered @@ -39,7 +41,34 @@ async def to_code(config): pass -def _expand_jinja(value, orig_value, path, jinja, ignore_missing): +def _restore_data_base(value: Any, orig_value: ESPHomeDataBase) -> ESPHomeDataBase: + """This function restores ESPHomeDataBase metadata held by the original string. + This is needed because during jinja evaluation, strings can be replaced by other types, + but we want to keep the original metadata for error reporting and source mapping. + For example, if a substitution replaces a string with a dictionary, we want that items + in the dictionary to still point to the original document location + """ + if isinstance(value, ESPHomeDataBase): + return value + if isinstance(value, dict): + return { + _restore_data_base(k, orig_value): _restore_data_base(v, orig_value) + for k, v in value.items() + } + if isinstance(value, list): + return [_restore_data_base(v, orig_value) for v in value] + if isinstance(value, str): + return make_data_base(value, orig_value) + return value + + +def _expand_jinja( + value: str | JinjaStr, + orig_value: str | JinjaStr, + path, + jinja: Jinja, + ignore_missing: bool, +) -> Any: if has_jinja(value): # If the original value passed in to this function is a JinjaStr, it means it contains an unresolved # Jinja expression from a previous pass. @@ -65,10 +94,17 @@ def _expand_jinja(value, orig_value, path, jinja, ignore_missing): f"\nSee {'->'.join(str(x) for x in path)}", path, ) + # If the original, unexpanded string, contained document metadata (ESPHomeDatabase), + # assign this same document metadata to the resulting value. + if isinstance(orig_value, ESPHomeDataBase): + value = _restore_data_base(value, orig_value) + return value -def _expand_substitutions(substitutions, value, path, jinja, ignore_missing): +def _expand_substitutions( + substitutions: dict, value: str, path, jinja: Jinja, ignore_missing: bool +) -> Any: if "$" not in value: return value @@ -76,14 +112,14 @@ def _expand_substitutions(substitutions, value, path, jinja, ignore_missing): i = 0 while True: - m = cv.VARIABLE_PROG.search(value, i) + m: Match[str] = cv.VARIABLE_PROG.search(value, i) if not m: # No more variable substitutions found. See if the remainder looks like a jinja template value = _expand_jinja(value, orig_value, path, jinja, ignore_missing) break i, j = m.span(0) - name = m.group(1) + name: str = m.group(1) if name.startswith("{") and name.endswith("}"): name = name[1:-1] if name not in substitutions: @@ -98,7 +134,7 @@ def _expand_substitutions(substitutions, value, path, jinja, ignore_missing): i = j continue - sub = substitutions[name] + sub: Any = substitutions[name] if i == 0 and j == len(value): # The variable spans the whole expression, e.g., "${varName}". Return its resolved value directly @@ -121,7 +157,13 @@ def _expand_substitutions(substitutions, value, path, jinja, ignore_missing): return value -def _substitute_item(substitutions, item, path, jinja, ignore_missing): +def _substitute_item( + substitutions: dict, + item: Any, + path: list[int | str], + jinja: Jinja, + ignore_missing: bool, +) -> Any | None: if isinstance(item, ESPLiteralValue): return None # do not substitute inside literal blocks if isinstance(item, list): @@ -160,7 +202,9 @@ def _substitute_item(substitutions, item, path, jinja, ignore_missing): return None -def do_substitution_pass(config, command_line_substitutions, ignore_missing=False): +def do_substitution_pass( + config: dict, command_line_substitutions: dict, ignore_missing: bool = False +) -> None: if CONF_SUBSTITUTIONS not in config and not command_line_substitutions: return diff --git a/esphome/components/substitutions/jinja.py b/esphome/components/substitutions/jinja.py index dde0162993..fb9f843da2 100644 --- a/esphome/components/substitutions/jinja.py +++ b/esphome/components/substitutions/jinja.py @@ -1,10 +1,14 @@ from ast import literal_eval +from collections.abc import Iterator +from itertools import chain, islice import logging import math import re +from types import GeneratorType +from typing import Any import jinja2 as jinja -from jinja2.sandbox import SandboxedEnvironment +from jinja2.nativetypes import NativeCodeGenerator, NativeTemplate from esphome.yaml_util import ESPLiteralValue @@ -24,7 +28,7 @@ detect_jinja_re = re.compile( ) -def has_jinja(st): +def has_jinja(st: str) -> bool: return detect_jinja_re.search(st) is not None @@ -109,12 +113,62 @@ class TrackerContext(jinja.runtime.Context): return val -class Jinja(SandboxedEnvironment): +def _concat_nodes_override(values: Iterator[Any]) -> Any: + """ + This function customizes how Jinja preserves native types when concatenating + multiple result nodes together. If the result is a single node, its value + is returned. Otherwise, the nodes are concatenated as strings. If + the result can be parsed with `ast.literal_eval`, the parsed + value is returned. Otherwise, the string is returned. + This helps preserve metadata such as ESPHomeDataBase from original values + and mimicks how HomeAssistant deals with template evaluation and preserving + the original datatype. + """ + head: list[Any] = list(islice(values, 2)) + + if not head: + return None + + if len(head) == 1: + raw = head[0] + if not isinstance(raw, str): + return raw + else: + if isinstance(values, GeneratorType): + values = chain(head, values) + raw = "".join([str(v) for v in values]) + + result = None + try: + # Attempt to parse the concatenated string into a Python literal. + # This allows expressions like "1 + 2" to be evaluated to the integer 3. + # If the result is also a string or there is a parsing error, + # fall back to returning the raw string. This is consistent with + # Home Assistant's behavior when evaluating templates + result = literal_eval(raw) + except (ValueError, SyntaxError, MemoryError, TypeError): + pass + else: + if isinstance(result, set): + # Sets are not supported, return raw string + return raw + + if not isinstance(result, str): + return result + + return raw + + +class Jinja(jinja.Environment): """ Wraps a Jinja environment """ - def __init__(self, context_vars): + # jinja environment customization overrides + code_generator_class = NativeCodeGenerator + concat = staticmethod(_concat_nodes_override) + + def __init__(self, context_vars: dict): super().__init__( trim_blocks=True, lstrip_blocks=True, @@ -142,19 +196,10 @@ class Jinja(SandboxedEnvironment): **SAFE_GLOBALS, } - def safe_eval(self, expr): - try: - result = literal_eval(expr) - if not isinstance(result, str): - return result - except (ValueError, SyntaxError, MemoryError, TypeError): - pass - return expr - - def expand(self, content_str): + def expand(self, content_str: str | JinjaStr) -> Any: """ Renders a string that may contain Jinja expressions or statements - Returns the resulting processed string if all values could be resolved. + Returns the resulting value if all variables and expressions could be resolved. Otherwise, it returns a tagged (JinjaStr) string that captures variables in scope (upvalues), like a closure for later evaluation. """ @@ -172,7 +217,7 @@ class Jinja(SandboxedEnvironment): self.context_trace = {} try: template = self.from_string(content_str) - result = self.safe_eval(template.render(override_vars)) + result = template.render(override_vars) if isinstance(result, Undefined): print("" + result) # force a UndefinedError exception except (TemplateSyntaxError, UndefinedError) as err: @@ -201,3 +246,10 @@ class Jinja(SandboxedEnvironment): content_str.result = result return result, None + + +class JinjaTemplate(NativeTemplate): + environment_class = Jinja + + +Jinja.template_class = JinjaTemplate diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 0e7b35b373..e9473012cf 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -139,11 +139,6 @@ def switch_schema( return _SWITCH_SCHEMA.extend(schema) -# Remove before 2025.11.0 -SWITCH_SCHEMA = switch_schema(Switch) -SWITCH_SCHEMA.add_extra(cv.deprecated_schema_constant("switch")) - - async def setup_switch_core_(var, config): await setup_entity(var, config, "switch") diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index eac0629480..f7e9872ce1 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -25,6 +25,20 @@ void TemplateAlarmControlPanel::add_sensor(binary_sensor::BinarySensor *sensor, this->sensor_data_.push_back(sd); this->sensor_map_[sensor].store_index = this->next_store_index_++; }; + +static const LogString *sensor_type_to_string(AlarmSensorType type) { + switch (type) { + case ALARM_SENSOR_TYPE_INSTANT: + return LOG_STR("instant"); + case ALARM_SENSOR_TYPE_DELAYED_FOLLOWER: + return LOG_STR("delayed_follower"); + case ALARM_SENSOR_TYPE_INSTANT_ALWAYS: + return LOG_STR("instant_always"); + case ALARM_SENSOR_TYPE_DELAYED: + default: + return LOG_STR("delayed"); + } +} #endif void TemplateAlarmControlPanel::dump_config() { @@ -46,53 +60,31 @@ void TemplateAlarmControlPanel::dump_config() { " Supported Features: %" PRIu32, (this->pending_time_ / 1000), (this->trigger_time_ / 1000), this->get_supported_features()); #ifdef USE_BINARY_SENSOR - for (auto sensor_info : this->sensor_map_) { - ESP_LOGCONFIG(TAG, " Binary Sensor:"); + for (auto const &[sensor, info] : this->sensor_map_) { ESP_LOGCONFIG(TAG, + " Binary Sensor:\n" " Name: %s\n" + " Type: %s\n" " Armed home bypass: %s\n" " Armed night bypass: %s\n" " Auto bypass: %s\n" " Chime mode: %s", - sensor_info.first->get_name().c_str(), - TRUEFALSE(sensor_info.second.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_HOME), - TRUEFALSE(sensor_info.second.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_NIGHT), - TRUEFALSE(sensor_info.second.flags & BINARY_SENSOR_MODE_BYPASS_AUTO), - TRUEFALSE(sensor_info.second.flags & BINARY_SENSOR_MODE_CHIME)); - const char *sensor_type; - switch (sensor_info.second.type) { - case ALARM_SENSOR_TYPE_INSTANT: - sensor_type = "instant"; - break; - case ALARM_SENSOR_TYPE_DELAYED_FOLLOWER: - sensor_type = "delayed_follower"; - break; - case ALARM_SENSOR_TYPE_INSTANT_ALWAYS: - sensor_type = "instant_always"; - break; - case ALARM_SENSOR_TYPE_DELAYED: - default: - sensor_type = "delayed"; - } - ESP_LOGCONFIG(TAG, " Sensor type: %s", sensor_type); + sensor->get_name().c_str(), LOG_STR_ARG(sensor_type_to_string(info.type)), + TRUEFALSE(info.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_HOME), + TRUEFALSE(info.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_NIGHT), + TRUEFALSE(info.flags & BINARY_SENSOR_MODE_BYPASS_AUTO), + TRUEFALSE(info.flags & BINARY_SENSOR_MODE_CHIME)); } #endif } void TemplateAlarmControlPanel::setup() { - switch (this->restore_mode_) { - case ALARM_CONTROL_PANEL_ALWAYS_DISARMED: - this->current_state_ = ACP_STATE_DISARMED; - break; - case ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED: { - uint8_t value; - this->pref_ = global_preferences->make_preference(this->get_preference_hash()); - if (this->pref_.load(&value)) { - this->current_state_ = static_cast(value); - } else { - this->current_state_ = ACP_STATE_DISARMED; - } - break; + this->current_state_ = ACP_STATE_DISARMED; + if (this->restore_mode_ == ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED) { + uint8_t value; + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); + if (this->pref_.load(&value)) { + this->current_state_ = static_cast(value); } } this->desired_state_ = this->current_state_; @@ -119,86 +111,83 @@ void TemplateAlarmControlPanel::loop() { this->publish_state(ACP_STATE_TRIGGERED); return; } - auto future_state = this->current_state_; + auto next_state = this->current_state_; // reset triggered if all clear if (this->current_state_ == ACP_STATE_TRIGGERED && this->trigger_time_ > 0 && (millis() - this->last_update_) > this->trigger_time_) { - future_state = this->desired_state_; + next_state = this->desired_state_; } - bool delayed_sensor_not_ready = false; - bool instant_sensor_not_ready = false; + bool delayed_sensor_faulted = false; + bool instant_sensor_faulted = false; #ifdef USE_BINARY_SENSOR - // Test all of the sensors in the list regardless of the alarm panel state - for (auto sensor_info : this->sensor_map_) { + // Test all of the sensors regardless of the alarm panel state + for (auto const &[sensor, info] : this->sensor_map_) { // Check for chime zones - if ((sensor_info.second.flags & BINARY_SENSOR_MODE_CHIME)) { + if (info.flags & BINARY_SENSOR_MODE_CHIME) { // Look for the transition from closed to open - if ((!this->sensor_data_[sensor_info.second.store_index].last_chime_state) && (sensor_info.first->state)) { + if ((!this->sensor_data_[info.store_index].last_chime_state) && (sensor->state)) { // Must be disarmed to chime if (this->current_state_ == ACP_STATE_DISARMED) { this->chime_callback_.call(); } } // Record the sensor state change - this->sensor_data_[sensor_info.second.store_index].last_chime_state = sensor_info.first->state; + this->sensor_data_[info.store_index].last_chime_state = sensor->state; } - // Check for triggered sensors - if (sensor_info.first->state) { // Sensor triggered? + // Check for faulted sensors + if (sensor->state) { // Skip if auto bypassed if (std::count(this->bypassed_sensor_indicies_.begin(), this->bypassed_sensor_indicies_.end(), - sensor_info.second.store_index) == 1) { + info.store_index) == 1) { continue; } // Skip if bypass armed home - if (this->current_state_ == ACP_STATE_ARMED_HOME && - (sensor_info.second.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_HOME)) { + if ((this->current_state_ == ACP_STATE_ARMED_HOME) && (info.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_HOME)) { continue; } // Skip if bypass armed night - if (this->current_state_ == ACP_STATE_ARMED_NIGHT && - (sensor_info.second.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_NIGHT)) { + if ((this->current_state_ == ACP_STATE_ARMED_NIGHT) && (info.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_NIGHT)) { continue; } - switch (sensor_info.second.type) { - case ALARM_SENSOR_TYPE_INSTANT: - instant_sensor_not_ready = true; - break; + switch (info.type) { case ALARM_SENSOR_TYPE_INSTANT_ALWAYS: - instant_sensor_not_ready = true; - future_state = ACP_STATE_TRIGGERED; + next_state = ACP_STATE_TRIGGERED; + [[fallthrough]]; + case ALARM_SENSOR_TYPE_INSTANT: + instant_sensor_faulted = true; break; case ALARM_SENSOR_TYPE_DELAYED_FOLLOWER: // Look to see if we are in the pending state if (this->current_state_ == ACP_STATE_PENDING) { - delayed_sensor_not_ready = true; + delayed_sensor_faulted = true; } else { - instant_sensor_not_ready = true; + instant_sensor_faulted = true; } break; case ALARM_SENSOR_TYPE_DELAYED: default: - delayed_sensor_not_ready = true; + delayed_sensor_faulted = true; } } } - // Update all sensors not ready flag - this->sensors_ready_ = ((!instant_sensor_not_ready) && (!delayed_sensor_not_ready)); + // Update all sensors ready flag + bool sensors_ready = !(instant_sensor_faulted || delayed_sensor_faulted); // Call the ready state change callback if there was a change - if (this->sensors_ready_ != this->sensors_ready_last_) { + if (this->sensors_ready_ != sensors_ready) { + this->sensors_ready_ = sensors_ready; this->ready_callback_.call(); - this->sensors_ready_last_ = this->sensors_ready_; } #endif - if (this->is_state_armed(future_state) && (!this->sensors_ready_)) { + if (this->is_state_armed(next_state) && (!this->sensors_ready_)) { // Instant sensors - if (instant_sensor_not_ready) { + if (instant_sensor_faulted) { this->publish_state(ACP_STATE_TRIGGERED); - } else if (delayed_sensor_not_ready) { + } else if (delayed_sensor_faulted) { // Delayed sensors if ((this->pending_time_ > 0) && (this->current_state_ != ACP_STATE_TRIGGERED)) { this->publish_state(ACP_STATE_PENDING); @@ -206,8 +195,8 @@ void TemplateAlarmControlPanel::loop() { this->publish_state(ACP_STATE_TRIGGERED); } } - } else if (future_state != this->current_state_) { - this->publish_state(future_state); + } else if (next_state != this->current_state_) { + this->publish_state(next_state); } } @@ -234,8 +223,6 @@ uint32_t TemplateAlarmControlPanel::get_supported_features() const { return features; } -bool TemplateAlarmControlPanel::get_requires_code() const { return !this->codes_.empty(); } - void TemplateAlarmControlPanel::arm_(optional code, alarm_control_panel::AlarmControlPanelState state, uint32_t delay) { if (this->current_state_ != ACP_STATE_DISARMED) { @@ -257,11 +244,11 @@ void TemplateAlarmControlPanel::arm_(optional code, alarm_control_p void TemplateAlarmControlPanel::bypass_before_arming() { #ifdef USE_BINARY_SENSOR - for (auto sensor_info : this->sensor_map_) { - // Check for sensors left on and set to bypass automatically and remove them from monitoring - if ((sensor_info.second.flags & BINARY_SENSOR_MODE_BYPASS_AUTO) && (sensor_info.first->state)) { - ESP_LOGW(TAG, "'%s' is left on and will be automatically bypassed", sensor_info.first->get_name().c_str()); - this->bypassed_sensor_indicies_.push_back(sensor_info.second.store_index); + for (auto const &[sensor, info] : this->sensor_map_) { + // Check for faulted bypass_auto sensors and remove them from monitoring + if ((info.flags & BINARY_SENSOR_MODE_BYPASS_AUTO) && (sensor->state)) { + ESP_LOGW(TAG, "'%s' is faulted and will be automatically bypassed", sensor->get_name().c_str()); + this->bypassed_sensor_indicies_.push_back(info.store_index); } } #endif diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index c3b28e8efa..40a79004da 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -56,7 +56,7 @@ class TemplateAlarmControlPanel : public alarm_control_panel::AlarmControlPanel, void setup() override; void loop() override; uint32_t get_supported_features() const override; - bool get_requires_code() const override; + bool get_requires_code() const override { return !this->codes_.empty(); } bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } @@ -66,7 +66,8 @@ class TemplateAlarmControlPanel : public alarm_control_panel::AlarmControlPanel, /** Add a binary_sensor to the alarm_panel. * * @param sensor The BinarySensor instance. - * @param ignore_when_home if this should be ignored when armed_home mode + * @param flags The OR of BinarySensorFlags for the sensor. + * @param type The sensor type which determines its triggering behaviour. */ void add_sensor(binary_sensor::BinarySensor *sensor, uint16_t flags = 0, AlarmSensorType type = ALARM_SENSOR_TYPE_DELAYED); @@ -121,7 +122,7 @@ class TemplateAlarmControlPanel : public alarm_control_panel::AlarmControlPanel, protected: void control(const alarm_control_panel::AlarmControlPanelCall &call) override; #ifdef USE_BINARY_SENSOR - // This maps a binary sensor to its type and attribute bits + // This maps a binary sensor to its alarm specific info std::map sensor_map_; // a list of automatically bypassed sensors std::vector bypassed_sensor_indicies_; @@ -147,7 +148,6 @@ class TemplateAlarmControlPanel : public alarm_control_panel::AlarmControlPanel, bool supports_arm_home_ = false; bool supports_arm_night_ = false; bool sensors_ready_ = false; - bool sensors_ready_last_ = false; uint8_t next_store_index_ = 0; // check if the code is valid bool is_code_valid_(optional code); diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index c93876380d..9d4208dcca 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -38,8 +38,14 @@ async def to_code(config): condition = await automation.build_condition( condition, cg.TemplateArguments(), [] ) + # Generate a stateless lambda that calls condition.check() + # capture="" is safe because condition is a global variable in generated C++ code + # and doesn't need to be captured. This allows implicit conversion to function pointer. template_ = LambdaExpression( - f"return {condition.check()};", [], return_type=cg.optional.template(bool) + f"return {condition.check()};", + [], + return_type=cg.optional.template(bool), + capture="", ) cg.add(var.set_template(template_)) diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.cpp b/esphome/components/template/binary_sensor/template_binary_sensor.cpp index d1fb618695..806aed49b1 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.cpp +++ b/esphome/components/template/binary_sensor/template_binary_sensor.cpp @@ -6,17 +6,21 @@ namespace template_ { static const char *const TAG = "template.binary_sensor"; -void TemplateBinarySensor::setup() { this->loop(); } +void TemplateBinarySensor::setup() { + if (!this->f_.has_value()) { + this->disable_loop(); + } else { + this->loop(); + } +} void TemplateBinarySensor::loop() { - if (this->f_ == nullptr) - return; - auto s = this->f_(); if (s.has_value()) { this->publish_state(*s); } } + void TemplateBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Template Binary Sensor", this); } } // namespace template_ diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.h b/esphome/components/template/binary_sensor/template_binary_sensor.h index 5e5624d82e..bc591391b9 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.h +++ b/esphome/components/template/binary_sensor/template_binary_sensor.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/template_lambda.h" #include "esphome/components/binary_sensor/binary_sensor.h" namespace esphome { @@ -8,7 +9,7 @@ namespace template_ { class TemplateBinarySensor : public Component, public binary_sensor::BinarySensor { public: - void set_template(std::function()> &&f) { this->f_ = f; } + template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; void loop() override; @@ -17,7 +18,7 @@ class TemplateBinarySensor : public Component, public binary_sensor::BinarySenso float get_setup_priority() const override { return setup_priority::HARDWARE; } protected: - std::function()> f_{nullptr}; + TemplateLambda f_; }; } // namespace template_ diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 84c687536e..a87f28ccec 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -33,28 +33,27 @@ void TemplateCover::setup() { break; } } + if (!this->state_f_.has_value() && !this->tilt_f_.has_value()) + this->disable_loop(); } void TemplateCover::loop() { bool changed = false; - if (this->state_f_.has_value()) { - auto s = (*this->state_f_)(); - if (s.has_value()) { - auto pos = clamp(*s, 0.0f, 1.0f); - if (pos != this->position) { - this->position = pos; - changed = true; - } + auto s = this->state_f_(); + if (s.has_value()) { + auto pos = clamp(*s, 0.0f, 1.0f); + if (pos != this->position) { + this->position = pos; + changed = true; } } - if (this->tilt_f_.has_value()) { - auto s = (*this->tilt_f_)(); - if (s.has_value()) { - auto tilt = clamp(*s, 0.0f, 1.0f); - if (tilt != this->tilt) { - this->tilt = tilt; - changed = true; - } + + auto tilt = this->tilt_f_(); + if (tilt.has_value()) { + auto tilt_val = clamp(*tilt, 0.0f, 1.0f); + if (tilt_val != this->tilt) { + this->tilt = tilt_val; + changed = true; } } @@ -63,7 +62,6 @@ void TemplateCover::loop() { } void TemplateCover::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void TemplateCover::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } -void TemplateCover::set_state_lambda(std::function()> &&f) { this->state_f_ = f; } float TemplateCover::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateCover::get_open_trigger() const { return this->open_trigger_; } Trigger<> *TemplateCover::get_close_trigger() const { return this->close_trigger_; } @@ -124,7 +122,6 @@ CoverTraits TemplateCover::get_traits() { } Trigger *TemplateCover::get_position_trigger() const { return this->position_trigger_; } Trigger *TemplateCover::get_tilt_trigger() const { return this->tilt_trigger_; } -void TemplateCover::set_tilt_lambda(std::function()> &&tilt_f) { this->tilt_f_ = tilt_f; } void TemplateCover::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } void TemplateCover::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void TemplateCover::set_has_position(bool has_position) { this->has_position_ = has_position; } diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index 958c94b0a6..faff69f867 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" +#include "esphome/core/template_lambda.h" #include "esphome/components/cover/cover.h" namespace esphome { @@ -17,7 +18,8 @@ class TemplateCover : public cover::Cover, public Component { public: TemplateCover(); - void set_state_lambda(std::function()> &&f); + template void set_state_lambda(F &&f) { this->state_f_.set(std::forward(f)); } + template void set_tilt_lambda(F &&f) { this->tilt_f_.set(std::forward(f)); } Trigger<> *get_open_trigger() const; Trigger<> *get_close_trigger() const; Trigger<> *get_stop_trigger() const; @@ -26,7 +28,6 @@ class TemplateCover : public cover::Cover, public Component { Trigger *get_tilt_trigger() const; void set_optimistic(bool optimistic); void set_assumed_state(bool assumed_state); - void set_tilt_lambda(std::function()> &&tilt_f); void set_has_stop(bool has_stop); void set_has_position(bool has_position); void set_has_tilt(bool has_tilt); @@ -45,8 +46,8 @@ class TemplateCover : public cover::Cover, public Component { void stop_prev_trigger_(); TemplateCoverRestoreMode restore_mode_{COVER_RESTORE}; - optional()>> state_f_; - optional()>> tilt_f_; + TemplateLambda state_f_; + TemplateLambda tilt_f_; bool assumed_state_{false}; bool optimistic_{false}; Trigger<> *open_trigger_; diff --git a/esphome/components/template/datetime/template_date.cpp b/esphome/components/template/datetime/template_date.cpp index 2fa8016802..3f6626e847 100644 --- a/esphome/components/template/datetime/template_date.cpp +++ b/esphome/components/template/datetime/template_date.cpp @@ -40,14 +40,13 @@ void TemplateDate::update() { if (!this->f_.has_value()) return; - auto val = (*this->f_)(); - if (!val.has_value()) - return; - - this->year_ = val->year; - this->month_ = val->month; - this->day_ = val->day_of_month; - this->publish_state(); + auto val = this->f_(); + if (val.has_value()) { + this->year_ = val->year; + this->month_ = val->month; + this->day_ = val->day_of_month; + this->publish_state(); + } } void TemplateDate::control(const datetime::DateCall &call) { diff --git a/esphome/components/template/datetime/template_date.h b/esphome/components/template/datetime/template_date.h index 185c7ed49d..7fed704d0e 100644 --- a/esphome/components/template/datetime/template_date.h +++ b/esphome/components/template/datetime/template_date.h @@ -9,13 +9,14 @@ #include "esphome/core/component.h" #include "esphome/core/preferences.h" #include "esphome/core/time.h" +#include "esphome/core/template_lambda.h" namespace esphome { namespace template_ { class TemplateDate : public datetime::DateEntity, public PollingComponent { public: - void set_template(std::function()> &&f) { this->f_ = f; } + template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; void update() override; @@ -35,7 +36,7 @@ class TemplateDate : public datetime::DateEntity, public PollingComponent { ESPTime initial_value_{}; bool restore_value_{false}; Trigger *set_trigger_ = new Trigger(); - optional()>> f_; + TemplateLambda f_; ESPPreferenceObject pref_; }; diff --git a/esphome/components/template/datetime/template_datetime.cpp b/esphome/components/template/datetime/template_datetime.cpp index a4a4e47d65..62f842a7ad 100644 --- a/esphome/components/template/datetime/template_datetime.cpp +++ b/esphome/components/template/datetime/template_datetime.cpp @@ -43,17 +43,16 @@ void TemplateDateTime::update() { if (!this->f_.has_value()) return; - auto val = (*this->f_)(); - if (!val.has_value()) - return; - - this->year_ = val->year; - this->month_ = val->month; - this->day_ = val->day_of_month; - this->hour_ = val->hour; - this->minute_ = val->minute; - this->second_ = val->second; - this->publish_state(); + auto val = this->f_(); + if (val.has_value()) { + this->year_ = val->year; + this->month_ = val->month; + this->day_ = val->day_of_month; + this->hour_ = val->hour; + this->minute_ = val->minute; + this->second_ = val->second; + this->publish_state(); + } } void TemplateDateTime::control(const datetime::DateTimeCall &call) { diff --git a/esphome/components/template/datetime/template_datetime.h b/esphome/components/template/datetime/template_datetime.h index ef80ded89a..ec45bf0326 100644 --- a/esphome/components/template/datetime/template_datetime.h +++ b/esphome/components/template/datetime/template_datetime.h @@ -9,13 +9,14 @@ #include "esphome/core/component.h" #include "esphome/core/preferences.h" #include "esphome/core/time.h" +#include "esphome/core/template_lambda.h" namespace esphome { namespace template_ { class TemplateDateTime : public datetime::DateTimeEntity, public PollingComponent { public: - void set_template(std::function()> &&f) { this->f_ = f; } + template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; void update() override; @@ -35,7 +36,7 @@ class TemplateDateTime : public datetime::DateTimeEntity, public PollingComponen ESPTime initial_value_{}; bool restore_value_{false}; Trigger *set_trigger_ = new Trigger(); - optional()>> f_; + TemplateLambda f_; ESPPreferenceObject pref_; }; diff --git a/esphome/components/template/datetime/template_time.cpp b/esphome/components/template/datetime/template_time.cpp index 349700f187..dab28d01cc 100644 --- a/esphome/components/template/datetime/template_time.cpp +++ b/esphome/components/template/datetime/template_time.cpp @@ -40,14 +40,13 @@ void TemplateTime::update() { if (!this->f_.has_value()) return; - auto val = (*this->f_)(); - if (!val.has_value()) - return; - - this->hour_ = val->hour; - this->minute_ = val->minute; - this->second_ = val->second; - this->publish_state(); + auto val = this->f_(); + if (val.has_value()) { + this->hour_ = val->hour; + this->minute_ = val->minute; + this->second_ = val->second; + this->publish_state(); + } } void TemplateTime::control(const datetime::TimeCall &call) { diff --git a/esphome/components/template/datetime/template_time.h b/esphome/components/template/datetime/template_time.h index 4a7c0098ec..ea7474c0ba 100644 --- a/esphome/components/template/datetime/template_time.h +++ b/esphome/components/template/datetime/template_time.h @@ -9,13 +9,14 @@ #include "esphome/core/component.h" #include "esphome/core/preferences.h" #include "esphome/core/time.h" +#include "esphome/core/template_lambda.h" namespace esphome { namespace template_ { class TemplateTime : public datetime::TimeEntity, public PollingComponent { public: - void set_template(std::function()> &&f) { this->f_ = f; } + template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; void update() override; @@ -35,7 +36,7 @@ class TemplateTime : public datetime::TimeEntity, public PollingComponent { ESPTime initial_value_{}; bool restore_value_{false}; Trigger *set_trigger_ = new Trigger(); - optional()>> f_; + TemplateLambda f_; ESPPreferenceObject pref_; }; diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index 5f4a2ae8f7..eba4c673b5 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -29,7 +29,7 @@ void TemplateFan::control(const fan::FanCall &call) { this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value() && this->has_direction_) this->direction = *call.get_direction(); - this->preset_mode = call.get_preset_mode(); + this->set_preset_mode_(call.get_preset_mode()); this->publish_state(); } diff --git a/esphome/components/template/fan/template_fan.h b/esphome/components/template/fan/template_fan.h index 5d780f61f0..b09352f4d4 100644 --- a/esphome/components/template/fan/template_fan.h +++ b/esphome/components/template/fan/template_fan.h @@ -1,7 +1,5 @@ #pragma once -#include - #include "esphome/core/component.h" #include "esphome/components/fan/fan.h" @@ -16,7 +14,7 @@ class TemplateFan : public Component, public fan::Fan { void set_has_direction(bool has_direction) { this->has_direction_ = has_direction; } void set_has_oscillating(bool has_oscillating) { this->has_oscillating_ = has_oscillating; } void set_speed_count(int count) { this->speed_count_ = count; } - void set_preset_modes(const std::initializer_list &presets) { this->preset_modes_ = presets; } + void set_preset_modes(std::initializer_list presets) { this->preset_modes_ = presets; } fan::FanTraits get_traits() override { return this->traits_; } protected: @@ -26,7 +24,7 @@ class TemplateFan : public Component, public fan::Fan { bool has_direction_{false}; int speed_count_{0}; fan::FanTraits traits_; - std::vector preset_modes_{}; + std::vector preset_modes_{}; }; } // namespace template_ diff --git a/esphome/components/template/lock/template_lock.cpp b/esphome/components/template/lock/template_lock.cpp index 87ba1046eb..8ed87b9736 100644 --- a/esphome/components/template/lock/template_lock.cpp +++ b/esphome/components/template/lock/template_lock.cpp @@ -11,14 +11,16 @@ static const char *const TAG = "template.lock"; TemplateLock::TemplateLock() : lock_trigger_(new Trigger<>()), unlock_trigger_(new Trigger<>()), open_trigger_(new Trigger<>()) {} -void TemplateLock::loop() { +void TemplateLock::setup() { if (!this->f_.has_value()) - return; - auto val = (*this->f_)(); - if (!val.has_value()) - return; + this->disable_loop(); +} - this->publish_state(*val); +void TemplateLock::loop() { + auto val = this->f_(); + if (val.has_value()) { + this->publish_state(*val); + } } void TemplateLock::control(const lock::LockCall &call) { if (this->prev_trigger_ != nullptr) { @@ -45,7 +47,6 @@ void TemplateLock::open_latch() { this->open_trigger_->trigger(); } void TemplateLock::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } -void TemplateLock::set_state_lambda(std::function()> &&f) { this->f_ = f; } float TemplateLock::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateLock::get_lock_trigger() const { return this->lock_trigger_; } Trigger<> *TemplateLock::get_unlock_trigger() const { return this->unlock_trigger_; } diff --git a/esphome/components/template/lock/template_lock.h b/esphome/components/template/lock/template_lock.h index 4f798eca81..14fca4635e 100644 --- a/esphome/components/template/lock/template_lock.h +++ b/esphome/components/template/lock/template_lock.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" +#include "esphome/core/template_lambda.h" #include "esphome/components/lock/lock.h" namespace esphome { @@ -11,9 +12,10 @@ class TemplateLock : public lock::Lock, public Component { public: TemplateLock(); + void setup() override; void dump_config() override; - void set_state_lambda(std::function()> &&f); + template void set_state_lambda(F &&f) { this->f_.set(std::forward(f)); } Trigger<> *get_lock_trigger() const; Trigger<> *get_unlock_trigger() const; Trigger<> *get_open_trigger() const; @@ -26,7 +28,7 @@ class TemplateLock : public lock::Lock, public Component { void control(const lock::LockCall &call) override; void open_latch() override; - optional()>> f_; + TemplateLambda f_; bool optimistic_{false}; Trigger<> *lock_trigger_; Trigger<> *unlock_trigger_; diff --git a/esphome/components/template/number/template_number.cpp b/esphome/components/template/number/template_number.cpp index 187f426273..145a89a2f7 100644 --- a/esphome/components/template/number/template_number.cpp +++ b/esphome/components/template/number/template_number.cpp @@ -30,11 +30,10 @@ void TemplateNumber::update() { if (!this->f_.has_value()) return; - auto val = (*this->f_)(); - if (!val.has_value()) - return; - - this->publish_state(*val); + auto val = this->f_(); + if (val.has_value()) { + this->publish_state(*val); + } } void TemplateNumber::control(float value) { diff --git a/esphome/components/template/number/template_number.h b/esphome/components/template/number/template_number.h index 9a82e44339..a9307e9246 100644 --- a/esphome/components/template/number/template_number.h +++ b/esphome/components/template/number/template_number.h @@ -4,13 +4,14 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/preferences.h" +#include "esphome/core/template_lambda.h" namespace esphome { namespace template_ { class TemplateNumber : public number::Number, public PollingComponent { public: - void set_template(std::function()> &&f) { this->f_ = f; } + template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; void update() override; @@ -28,7 +29,7 @@ class TemplateNumber : public number::Number, public PollingComponent { float initial_value_{NAN}; bool restore_value_{false}; Trigger *set_trigger_ = new Trigger(); - optional()>> f_; + TemplateLambda f_; ESPPreferenceObject pref_; }; diff --git a/esphome/components/template/select/__init__.py b/esphome/components/template/select/__init__.py index 3282092d63..0e9c240547 100644 --- a/esphome/components/template/select/__init__.py +++ b/esphome/components/template/select/__init__.py @@ -73,11 +73,18 @@ async def to_code(config): cg.add(var.set_template(template_)) else: - cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) - cg.add(var.set_initial_option(config[CONF_INITIAL_OPTION])) + # Only set if non-default to avoid bloating setup() function + if config[CONF_OPTIMISTIC]: + cg.add(var.set_optimistic(True)) + initial_option_index = config[CONF_OPTIONS].index(config[CONF_INITIAL_OPTION]) + # Only set if non-zero to avoid bloating setup() function + # (initial_option_index_ is zero-initialized in the header) + if initial_option_index != 0: + cg.add(var.set_initial_option_index(initial_option_index)) - if CONF_RESTORE_VALUE in config: - cg.add(var.set_restore_value(config[CONF_RESTORE_VALUE])) + # Only set if True (default is False) + if config.get(CONF_RESTORE_VALUE): + cg.add(var.set_restore_value(True)) if CONF_SET_ACTION in config: await automation.build_automation( diff --git a/esphome/components/template/select/template_select.cpp b/esphome/components/template/select/template_select.cpp index 95b0ee0d2b..112f24e919 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -10,54 +10,45 @@ void TemplateSelect::setup() { if (this->f_.has_value()) return; - std::string value; - if (!this->restore_value_) { - value = this->initial_option_; - ESP_LOGD(TAG, "State from initial: %s", value.c_str()); - } else { - size_t index; + size_t index = this->initial_option_index_; + if (this->restore_value_) { this->pref_ = global_preferences->make_preference(this->get_preference_hash()); - if (!this->pref_.load(&index)) { - value = this->initial_option_; - ESP_LOGD(TAG, "State from initial (could not load stored index): %s", value.c_str()); - } else if (!this->has_index(index)) { - value = this->initial_option_; - ESP_LOGD(TAG, "State from initial (restored index %d out of bounds): %s", index, value.c_str()); + size_t restored_index; + if (this->pref_.load(&restored_index) && this->has_index(restored_index)) { + index = restored_index; + ESP_LOGD(TAG, "State from restore: %s", this->option_at(index)); } else { - value = this->at(index).value(); - ESP_LOGD(TAG, "State from restore: %s", value.c_str()); + ESP_LOGD(TAG, "State from initial (could not load or invalid stored index): %s", this->option_at(index)); } + } else { + ESP_LOGD(TAG, "State from initial: %s", this->option_at(index)); } - this->publish_state(value); + this->publish_state(index); } void TemplateSelect::update() { if (!this->f_.has_value()) return; - auto val = (*this->f_)(); - if (!val.has_value()) - return; - - if (!this->has_option(*val)) { - ESP_LOGE(TAG, "Lambda returned an invalid option: %s", (*val).c_str()); - return; + auto val = this->f_(); + if (val.has_value()) { + if (!this->has_option(*val)) { + ESP_LOGE(TAG, "Lambda returned an invalid option: %s", (*val).c_str()); + return; + } + this->publish_state(*val); } - - this->publish_state(*val); } -void TemplateSelect::control(const std::string &value) { - this->set_trigger_->trigger(value); +void TemplateSelect::control(size_t index) { + this->set_trigger_->trigger(std::string(this->option_at(index))); if (this->optimistic_) - this->publish_state(value); + this->publish_state(index); - if (this->restore_value_) { - auto index = this->index_of(value); - this->pref_.save(&index.value()); - } + if (this->restore_value_) + this->pref_.save(&index); } void TemplateSelect::dump_config() { @@ -69,7 +60,7 @@ void TemplateSelect::dump_config() { " Optimistic: %s\n" " Initial Option: %s\n" " Restore Value: %s", - YESNO(this->optimistic_), this->initial_option_.c_str(), YESNO(this->restore_value_)); + YESNO(this->optimistic_), this->option_at(this->initial_option_index_), YESNO(this->restore_value_)); } } // namespace template_ diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 2f00765c3d..2dad059ade 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -4,13 +4,14 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/preferences.h" +#include "esphome/core/template_lambda.h" namespace esphome { namespace template_ { class TemplateSelect : public select::Select, public PollingComponent { public: - void set_template(std::function()> &&f) { this->f_ = f; } + template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; void update() override; @@ -19,16 +20,16 @@ class TemplateSelect : public select::Select, public PollingComponent { Trigger *get_set_trigger() const { return this->set_trigger_; } void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_initial_option(const std::string &initial_option) { this->initial_option_ = initial_option; } + void set_initial_option_index(size_t initial_option_index) { this->initial_option_index_ = initial_option_index; } void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } protected: - void control(const std::string &value) override; + void control(size_t index) override; bool optimistic_ = false; - std::string initial_option_; + size_t initial_option_index_{0}; bool restore_value_ = false; Trigger *set_trigger_ = new Trigger(); - optional()>> f_; + TemplateLambda f_; ESPPreferenceObject pref_; }; diff --git a/esphome/components/template/sensor/template_sensor.cpp b/esphome/components/template/sensor/template_sensor.cpp index f2d0e7363e..1558ea9b15 100644 --- a/esphome/components/template/sensor/template_sensor.cpp +++ b/esphome/components/template/sensor/template_sensor.cpp @@ -11,13 +11,14 @@ void TemplateSensor::update() { if (!this->f_.has_value()) return; - auto val = (*this->f_)(); + auto val = this->f_(); if (val.has_value()) { this->publish_state(*val); } } + float TemplateSensor::get_setup_priority() const { return setup_priority::HARDWARE; } -void TemplateSensor::set_template(std::function()> &&f) { this->f_ = f; } + void TemplateSensor::dump_config() { LOG_SENSOR("", "Template Sensor", this); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/template/sensor/template_sensor.h b/esphome/components/template/sensor/template_sensor.h index 2630cb0b14..793d754a0f 100644 --- a/esphome/components/template/sensor/template_sensor.h +++ b/esphome/components/template/sensor/template_sensor.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/template_lambda.h" #include "esphome/components/sensor/sensor.h" namespace esphome { @@ -8,7 +9,7 @@ namespace template_ { class TemplateSensor : public sensor::Sensor, public PollingComponent { public: - void set_template(std::function()> &&f); + template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; @@ -17,7 +18,7 @@ class TemplateSensor : public sensor::Sensor, public PollingComponent { float get_setup_priority() const override; protected: - optional()>> f_; + TemplateLambda f_; }; } // namespace template_ diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index fa236f6364..95e8692da5 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -9,13 +9,10 @@ static const char *const TAG = "template.switch"; TemplateSwitch::TemplateSwitch() : turn_on_trigger_(new Trigger<>()), turn_off_trigger_(new Trigger<>()) {} void TemplateSwitch::loop() { - if (!this->f_.has_value()) - return; - auto s = (*this->f_)(); - if (!s.has_value()) - return; - - this->publish_state(*s); + auto s = this->f_(); + if (s.has_value()) { + this->publish_state(*s); + } } void TemplateSwitch::write_state(bool state) { if (this->prev_trigger_ != nullptr) { @@ -35,11 +32,13 @@ void TemplateSwitch::write_state(bool state) { } void TemplateSwitch::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } bool TemplateSwitch::assumed_state() { return this->assumed_state_; } -void TemplateSwitch::set_state_lambda(std::function()> &&f) { this->f_ = f; } float TemplateSwitch::get_setup_priority() const { return setup_priority::HARDWARE - 2.0f; } Trigger<> *TemplateSwitch::get_turn_on_trigger() const { return this->turn_on_trigger_; } Trigger<> *TemplateSwitch::get_turn_off_trigger() const { return this->turn_off_trigger_; } void TemplateSwitch::setup() { + if (!this->f_.has_value()) + this->disable_loop(); + optional initial_state = this->get_initial_state_with_restore_mode(); if (initial_state.has_value()) { diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index bfe9ac25d6..18a374df35 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" +#include "esphome/core/template_lambda.h" #include "esphome/components/switch/switch.h" namespace esphome { @@ -14,7 +15,7 @@ class TemplateSwitch : public switch_::Switch, public Component { void setup() override; void dump_config() override; - void set_state_lambda(std::function()> &&f); + template void set_state_lambda(F &&f) { this->f_.set(std::forward(f)); } Trigger<> *get_turn_on_trigger() const; Trigger<> *get_turn_off_trigger() const; void set_optimistic(bool optimistic); @@ -28,7 +29,7 @@ class TemplateSwitch : public switch_::Switch, public Component { void write_state(bool state) override; - optional()>> f_; + TemplateLambda f_; bool optimistic_{false}; bool assumed_state_{false}; Trigger<> *turn_on_trigger_; diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index d8e840ba7e..a917c72a14 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -7,10 +7,8 @@ namespace template_ { static const char *const TAG = "template.text"; void TemplateText::setup() { - if (!(this->f_ == nullptr)) { - if (this->f_.has_value()) - return; - } + if (this->f_.has_value()) + return; std::string value = this->initial_value_; if (!this->pref_) { ESP_LOGD(TAG, "State from initial: %s", value.c_str()); @@ -26,17 +24,13 @@ void TemplateText::setup() { } void TemplateText::update() { - if (this->f_ == nullptr) - return; - if (!this->f_.has_value()) return; - auto val = (*this->f_)(); - if (!val.has_value()) - return; - - this->publish_state(*val); + auto val = this->f_(); + if (val.has_value()) { + this->publish_state(*val); + } } void TemplateText::control(const std::string &value) { diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index bcfc54a2ba..c12021f80e 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -4,6 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/preferences.h" +#include "esphome/core/template_lambda.h" namespace esphome { namespace template_ { @@ -61,7 +62,7 @@ template class TextSaver : public TemplateTextSaverBase { class TemplateText : public text::Text, public PollingComponent { public: - void set_template(std::function()> &&f) { this->f_ = f; } + template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; void update() override; @@ -78,7 +79,7 @@ class TemplateText : public text::Text, public PollingComponent { bool optimistic_ = false; std::string initial_value_; Trigger *set_trigger_ = new Trigger(); - optional()>> f_{nullptr}; + TemplateLambda f_{}; TemplateTextSaverBase *pref_ = nullptr; }; diff --git a/esphome/components/template/text_sensor/template_text_sensor.cpp b/esphome/components/template/text_sensor/template_text_sensor.cpp index 885ad47bbf..024d0093a2 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.cpp +++ b/esphome/components/template/text_sensor/template_text_sensor.cpp @@ -10,13 +10,14 @@ void TemplateTextSensor::update() { if (!this->f_.has_value()) return; - auto val = (*this->f_)(); + auto val = this->f_(); if (val.has_value()) { this->publish_state(*val); } } + float TemplateTextSensor::get_setup_priority() const { return setup_priority::HARDWARE; } -void TemplateTextSensor::set_template(std::function()> &&f) { this->f_ = f; } + void TemplateTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Template Sensor", this); } } // namespace template_ diff --git a/esphome/components/template/text_sensor/template_text_sensor.h b/esphome/components/template/text_sensor/template_text_sensor.h index 07a2bd96fc..0d01c72023 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.h +++ b/esphome/components/template/text_sensor/template_text_sensor.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" +#include "esphome/core/template_lambda.h" #include "esphome/components/text_sensor/text_sensor.h" namespace esphome { @@ -9,7 +10,7 @@ namespace template_ { class TemplateTextSensor : public text_sensor::TextSensor, public PollingComponent { public: - void set_template(std::function()> &&f); + template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; @@ -18,7 +19,7 @@ class TemplateTextSensor : public text_sensor::TextSensor, public PollingCompone void dump_config() override; protected: - optional()>> f_{}; + TemplateLambda f_{}; }; } // namespace template_ diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index 5fa14a2de7..b91b32473e 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -33,19 +33,19 @@ void TemplateValve::setup() { break; } } + if (!this->state_f_.has_value()) + this->disable_loop(); } void TemplateValve::loop() { bool changed = false; - if (this->state_f_.has_value()) { - auto s = (*this->state_f_)(); - if (s.has_value()) { - auto pos = clamp(*s, 0.0f, 1.0f); - if (pos != this->position) { - this->position = pos; - changed = true; - } + auto s = this->state_f_(); + if (s.has_value()) { + auto pos = clamp(*s, 0.0f, 1.0f); + if (pos != this->position) { + this->position = pos; + changed = true; } } @@ -55,7 +55,6 @@ void TemplateValve::loop() { void TemplateValve::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void TemplateValve::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } -void TemplateValve::set_state_lambda(std::function()> &&f) { this->state_f_ = f; } float TemplateValve::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateValve::get_open_trigger() const { return this->open_trigger_; } diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index 5e3fb6aff3..d6235f8e5c 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" +#include "esphome/core/template_lambda.h" #include "esphome/components/valve/valve.h" namespace esphome { @@ -17,7 +18,7 @@ class TemplateValve : public valve::Valve, public Component { public: TemplateValve(); - void set_state_lambda(std::function()> &&f); + template void set_state_lambda(F &&f) { this->state_f_.set(std::forward(f)); } Trigger<> *get_open_trigger() const; Trigger<> *get_close_trigger() const; Trigger<> *get_stop_trigger() const; @@ -42,7 +43,7 @@ class TemplateValve : public valve::Valve, public Component { void stop_prev_trigger_(); TemplateValveRestoreMode restore_mode_{VALVE_NO_RESTORE}; - optional()>> state_f_; + TemplateLambda state_f_; bool assumed_state_{false}; bool optimistic_{false}; Trigger<> *open_trigger_; diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 1baacc239f..9ceea0dfdf 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -84,11 +84,6 @@ def text_schema( return _TEXT_SCHEMA.extend(schema) -# Remove before 2025.11.0 -TEXT_SCHEMA = text_schema() -TEXT_SCHEMA.add_extra(cv.deprecated_schema_constant("text")) - - async def setup_text_core_( var, config, diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 7a9e947abd..0d22400a8e 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -57,6 +57,7 @@ validate_filters = cv.validate_registry("filter", FILTER_REGISTRY) # Filters Filter = text_sensor_ns.class_("Filter") LambdaFilter = text_sensor_ns.class_("LambdaFilter", Filter) +StatelessLambdaFilter = text_sensor_ns.class_("StatelessLambdaFilter", Filter) ToUpperFilter = text_sensor_ns.class_("ToUpperFilter", Filter) ToLowerFilter = text_sensor_ns.class_("ToLowerFilter", Filter) AppendFilter = text_sensor_ns.class_("AppendFilter", Filter) @@ -70,7 +71,7 @@ async def lambda_filter_to_code(config, filter_id): lambda_ = await cg.process_lambda( config, [(cg.std_string, "x")], return_type=cg.optional.template(cg.std_string) ) - return cg.new_Pvariable(filter_id, lambda_) + return automation.new_lambda_pvariable(filter_id, lambda_, StatelessLambdaFilter) @FILTER_REGISTRY.register("to_upper", ToUpperFilter, {}) @@ -192,11 +193,6 @@ def text_sensor_schema( return _TEXT_SENSOR_SCHEMA.extend(schema) -# Remove before 2025.11.0 -TEXT_SENSOR_SCHEMA = text_sensor_schema() -TEXT_SENSOR_SCHEMA.add_extra(cv.deprecated_schema_constant("text_sensor")) - - async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index c77c221235..85acac5c8d 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -62,6 +62,21 @@ class LambdaFilter : public Filter { lambda_filter_t lambda_filter_; }; +/** Optimized lambda filter for stateless lambdas (no capture). + * + * Uses function pointer instead of std::function to reduce memory overhead. + * Memory: 4 bytes (function pointer on 32-bit) vs 32 bytes (std::function). + */ +class StatelessLambdaFilter : public Filter { + public: + explicit StatelessLambdaFilter(optional (*lambda_filter)(std::string)) : lambda_filter_(lambda_filter) {} + + optional new_value(std::string value) override { return this->lambda_filter_(value); } + + protected: + optional (*lambda_filter_)(std::string); +}; + /// A simple filter that converts all text to uppercase class ToUpperFilter : public Filter { public: diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index 18efe3984e..8258fa9d65 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -223,7 +223,8 @@ void ThermostatClimate::control(const climate::ClimateCall &call) { if (this->setup_complete_) { this->change_custom_preset_(call.get_custom_preset().value()); } else { - this->custom_preset = call.get_custom_preset().value(); + // Use the base class method which handles pointer lookup internally + this->set_custom_preset_(call.get_custom_preset().value().c_str()); } } @@ -321,9 +322,17 @@ climate::ClimateTraits ThermostatClimate::traits() { for (auto &it : this->preset_config_) { traits.add_supported_preset(it.first); } - for (auto &it : this->custom_preset_config_) { - traits.add_supported_custom_preset(it.first); + + // Extract custom preset names from the custom_preset_config_ map + if (!this->custom_preset_config_.empty()) { + std::vector custom_preset_names; + custom_preset_names.reserve(this->custom_preset_config_.size()); + for (const auto &it : this->custom_preset_config_) { + custom_preset_names.push_back(it.first.c_str()); + } + traits.set_supported_custom_presets(custom_preset_names); } + return traits; } @@ -1153,7 +1162,7 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { this->preset.value() != preset) { // Fire any preset changed trigger if defined Trigger<> *trig = this->preset_change_trigger_; - this->preset = preset; + this->set_preset_(preset); if (trig != nullptr) { trig->trigger(); } @@ -1163,8 +1172,6 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { } else { ESP_LOGI(TAG, "No changes required to apply preset %s", LOG_STR_ARG(climate::climate_preset_to_string(preset))); } - this->custom_preset.reset(); - this->preset = preset; } else { ESP_LOGW(TAG, "Preset %s not configured; ignoring", LOG_STR_ARG(climate::climate_preset_to_string(preset))); } @@ -1175,11 +1182,12 @@ void ThermostatClimate::change_custom_preset_(const std::string &custom_preset) if (config != this->custom_preset_config_.end()) { ESP_LOGV(TAG, "Custom preset %s requested", custom_preset.c_str()); - if (this->change_preset_internal_(config->second) || (!this->custom_preset.has_value()) || - this->custom_preset.value() != custom_preset) { + if (this->change_preset_internal_(config->second) || !this->has_custom_preset() || + strcmp(this->get_custom_preset(), custom_preset.c_str()) != 0) { // Fire any preset changed trigger if defined Trigger<> *trig = this->preset_change_trigger_; - this->custom_preset = custom_preset; + // Use the base class method which handles pointer lookup and preset reset internally + this->set_custom_preset_(custom_preset.c_str()); if (trig != nullptr) { trig->trigger(); } @@ -1189,8 +1197,9 @@ void ThermostatClimate::change_custom_preset_(const std::string &custom_preset) } else { ESP_LOGI(TAG, "No changes required to apply custom preset %s", custom_preset.c_str()); } - this->preset.reset(); - this->custom_preset = custom_preset; + // Note: set_custom_preset_() above handles preset.reset() and custom_preset_ assignment internally. + // The old code had these lines here unconditionally, which was a bug (double assignment, state modification + // even when no changes were needed). Now properly handled by the protected setter with mutual exclusion. } else { ESP_LOGW(TAG, "Custom preset %s not configured; ignoring", custom_preset.c_str()); } diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 363d2b09fc..42adab7751 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -40,6 +40,10 @@ enum OnBootRestoreFrom : uint8_t { }; struct ThermostatClimateTimer { + ThermostatClimateTimer() = default; + ThermostatClimateTimer(bool active, uint32_t time, uint32_t started, std::function func) + : active(active), time(time), started(started), func(std::move(func)) {} + bool active; uint32_t time; uint32_t started; diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 36e5a21ffa..5efa70d6b4 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -405,7 +405,7 @@ void ToshibaClimate::setup() { this->swing_modes_ = this->toshiba_swing_modes_(); // Ensure swing mode is always initialized to a valid value - if (this->swing_modes_.empty() || this->swing_modes_.find(this->swing_mode) == this->swing_modes_.end()) { + if (this->swing_modes_.empty() || !this->swing_modes_.count(this->swing_mode)) { // No swing support for this model or current swing mode not supported, reset to OFF this->swing_mode = climate::CLIMATE_SWING_OFF; } diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index d76833f406..ee1dec5cc9 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -71,10 +71,10 @@ class ToshibaClimate : public climate_ir::ClimateIR { return TOSHIBA_RAS_2819T_TEMP_C_MAX; return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models } - std::set toshiba_swing_modes_() { + climate::ClimateSwingModeMask toshiba_swing_modes_() { return (this->model_ == MODEL_GENERIC) - ? std::set{} - : std::set{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; + ? climate::ClimateSwingModeMask() + : climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; } void encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, uint8_t nbytes, uint8_t repeat); bool decode_(remote_base::RemoteReceiveData *data, uint8_t *message, uint8_t nbytes); diff --git a/esphome/components/tuya/climate/tuya_climate.cpp b/esphome/components/tuya/climate/tuya_climate.cpp index 04fb14acff..4d8fd4b310 100644 --- a/esphome/components/tuya/climate/tuya_climate.cpp +++ b/esphome/components/tuya/climate/tuya_climate.cpp @@ -67,7 +67,9 @@ void TuyaClimate::setup() { } if (this->eco_id_.has_value()) { this->parent_->register_listener(*this->eco_id_, [this](const TuyaDatapoint &datapoint) { + // Whether data type is BOOL or ENUM, it will still be a 1 or a 0, so the functions below are valid in both cases this->eco_ = datapoint.value_bool; + this->eco_type_ = datapoint.type; ESP_LOGV(TAG, "MCU reported eco is: %s", ONOFF(this->eco_)); this->compute_preset_(); this->compute_target_temperature_(); @@ -176,7 +178,11 @@ void TuyaClimate::control(const climate::ClimateCall &call) { if (this->eco_id_.has_value()) { const bool eco = preset == climate::CLIMATE_PRESET_ECO; ESP_LOGV(TAG, "Setting eco: %s", ONOFF(eco)); - this->parent_->set_boolean_datapoint_value(*this->eco_id_, eco); + if (this->eco_type_ == TuyaDatapointType::ENUM) { + this->parent_->set_enum_datapoint_value(*this->eco_id_, eco); + } else { + this->parent_->set_boolean_datapoint_value(*this->eco_id_, eco); + } } if (this->sleep_id_.has_value()) { const bool sleep = preset == climate::CLIMATE_PRESET_SLEEP; @@ -306,18 +312,12 @@ climate::ClimateTraits TuyaClimate::traits() { traits.add_supported_preset(climate::CLIMATE_PRESET_NONE); } if (this->swing_vertical_id_.has_value() && this->swing_horizontal_id_.has_value()) { - std::set supported_swing_modes = { - climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, climate::CLIMATE_SWING_VERTICAL, - climate::CLIMATE_SWING_HORIZONTAL}; - traits.set_supported_swing_modes(std::move(supported_swing_modes)); + traits.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, + climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL}); } else if (this->swing_vertical_id_.has_value()) { - std::set supported_swing_modes = {climate::CLIMATE_SWING_OFF, - climate::CLIMATE_SWING_VERTICAL}; - traits.set_supported_swing_modes(std::move(supported_swing_modes)); + traits.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}); } else if (this->swing_horizontal_id_.has_value()) { - std::set supported_swing_modes = {climate::CLIMATE_SWING_OFF, - climate::CLIMATE_SWING_HORIZONTAL}; - traits.set_supported_swing_modes(std::move(supported_swing_modes)); + traits.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL}); } if (fan_speed_id_) { diff --git a/esphome/components/tuya/climate/tuya_climate.h b/esphome/components/tuya/climate/tuya_climate.h index d6258c21e1..31bef57639 100644 --- a/esphome/components/tuya/climate/tuya_climate.h +++ b/esphome/components/tuya/climate/tuya_climate.h @@ -104,6 +104,7 @@ class TuyaClimate : public climate::Climate, public Component { optional eco_id_{}; optional sleep_id_{}; optional eco_temperature_{}; + TuyaDatapointType eco_type_{}; uint8_t active_state_; uint8_t fan_state_; optional swing_vertical_id_{}; diff --git a/esphome/components/tuya/select/tuya_select.cpp b/esphome/components/tuya/select/tuya_select.cpp index 91ddbc77ec..07e3ce44ee 100644 --- a/esphome/components/tuya/select/tuya_select.cpp +++ b/esphome/components/tuya/select/tuya_select.cpp @@ -10,7 +10,6 @@ void TuyaSelect::setup() { this->parent_->register_listener(this->select_id_, [this](const TuyaDatapoint &datapoint) { uint8_t enum_value = datapoint.value_enum; ESP_LOGV(TAG, "MCU reported select %u value %u", this->select_id_, enum_value); - auto options = this->traits.get_options(); auto mappings = this->mappings_; auto it = std::find(mappings.cbegin(), mappings.cend(), enum_value); if (it == mappings.end()) { @@ -18,8 +17,7 @@ void TuyaSelect::setup() { return; } size_t mapping_idx = std::distance(mappings.cbegin(), it); - auto value = this->at(mapping_idx); - this->publish_state(value.value()); + this->publish_state(mapping_idx); }); } @@ -49,9 +47,9 @@ void TuyaSelect::dump_config() { " Data type: %s\n" " Options are:", this->select_id_, this->is_int_ ? "int" : "enum"); - auto options = this->traits.get_options(); + const auto &options = this->traits.get_options(); for (size_t i = 0; i < this->mappings_.size(); i++) { - ESP_LOGCONFIG(TAG, " %i: %s", this->mappings_.at(i), options.at(i).c_str()); + ESP_LOGCONFIG(TAG, " %i: %s", this->mappings_.at(i), options.at(i)); } } diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index f8f927d469..eb911ed007 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -446,7 +446,7 @@ async def uart_write_to_code(config, action_id, template_arg, args): templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) cg.add(var.set_data_template(templ)) else: - cg.add(var.set_data_static(data)) + cg.add(var.set_data_static(cg.ArrayInitializer(*data))) return var diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index b6a50ea22d..9c599253de 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -14,8 +14,12 @@ template class UARTWriteAction : public Action, public Pa this->data_func_ = func; this->static_ = false; } - void set_data_static(const std::vector &data) { - this->data_static_ = data; + void set_data_static(std::vector &&data) { + this->data_static_ = std::move(data); + this->static_ = true; + } + void set_data_static(std::initializer_list data) { + this->data_static_ = std::vector(data); this->static_ = true; } diff --git a/esphome/components/uart/button/__init__.py b/esphome/components/uart/button/__init__.py index 5b811de07d..95fe21271d 100644 --- a/esphome/components/uart/button/__init__.py +++ b/esphome/components/uart/button/__init__.py @@ -33,4 +33,4 @@ async def to_code(config): data = config[CONF_DATA] if isinstance(data, bytes): data = [HexInt(x) for x in data] - cg.add(var.set_data(data)) + cg.add(var.set_data(cg.ArrayInitializer(*data))) diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 2d600b199a..8c7d762a05 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -11,7 +11,8 @@ namespace uart { class UARTButton : public button::Button, public UARTDevice, public Component { public: - void set_data(const std::vector &data) { this->data_ = data; } + void set_data(std::vector &&data) { this->data_ = std::move(data); } + void set_data(std::initializer_list data) { this->data_ = std::vector(data); } void dump_config() override; diff --git a/esphome/components/uart/switch/__init__.py b/esphome/components/uart/switch/__init__.py index b25e070461..290bbed5d3 100644 --- a/esphome/components/uart/switch/__init__.py +++ b/esphome/components/uart/switch/__init__.py @@ -44,16 +44,16 @@ async def to_code(config): if data_on := data.get(CONF_TURN_ON): if isinstance(data_on, bytes): data_on = [HexInt(x) for x in data_on] - cg.add(var.set_data_on(data_on)) + cg.add(var.set_data_on(cg.ArrayInitializer(*data_on))) if data_off := data.get(CONF_TURN_OFF): if isinstance(data_off, bytes): data_off = [HexInt(x) for x in data_off] - cg.add(var.set_data_off(data_off)) + cg.add(var.set_data_off(cg.ArrayInitializer(*data_off))) else: data = config[CONF_DATA] if isinstance(data, bytes): data = [HexInt(x) for x in data] - cg.add(var.set_data_on(data)) + cg.add(var.set_data_on(cg.ArrayInitializer(*data))) cg.add(var.set_single_state(True)) if CONF_SEND_EVERY in config: cg.add(var.set_send_every(config[CONF_SEND_EVERY])) diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index 4ef5b6da4b..909307d57e 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -14,8 +14,10 @@ class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { public: void loop() override; - void set_data_on(const std::vector &data) { this->data_on_ = data; } - void set_data_off(const std::vector &data) { this->data_off_ = data; } + void set_data_on(std::vector &&data) { this->data_on_ = std::move(data); } + void set_data_on(std::initializer_list data) { this->data_on_ = std::vector(data); } + void set_data_off(std::vector &&data) { this->data_off_ = std::move(data); } + void set_data_off(std::initializer_list data) { this->data_off_ = std::vector(data); } void set_send_every(uint32_t send_every) { this->send_every_ = send_every; } void set_single_state(bool single) { this->single_state_ = single; } diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index cffa3308eb..73813d2d5b 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -99,10 +99,26 @@ void IDFUARTComponent::setup() { } void IDFUARTComponent::load_settings(bool dump_config) { - uart_config_t uart_config = this->get_config_(); - esp_err_t err = uart_param_config(this->uart_num_, &uart_config); + esp_err_t err; + + if (uart_is_driver_installed(this->uart_num_)) { + err = uart_driver_delete(this->uart_num_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + } + err = uart_driver_install(this->uart_num_, // UART number + this->rx_buffer_size_, // RX ring buffer size + 0, // TX ring buffer size. If zero, driver will not use a TX buffer and TX function will + // block task until all data has been sent out + 20, // event queue size/depth + &this->uart_event_queue_, // event queue + 0 // Flags used to allocate the interrupt + ); if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "uart_driver_install failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } @@ -119,10 +135,12 @@ void IDFUARTComponent::load_settings(bool dump_config) { int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; uint32_t invert = 0; - if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { invert |= UART_SIGNAL_TXD_INV; - if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) + } + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { invert |= UART_SIGNAL_RXD_INV; + } err = uart_set_line_inverse(this->uart_num_, invert); if (err != ESP_OK) { @@ -138,26 +156,6 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } - if (uart_is_driver_installed(this->uart_num_)) { - uart_driver_delete(this->uart_num_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - } - err = uart_driver_install(this->uart_num_, /* UART RX ring buffer size. */ this->rx_buffer_size_, - /* UART TX ring buffer size. If set to zero, driver will not use TX buffer, TX function will - block task until all data have been sent out.*/ - 0, - /* UART event queue size/depth. */ 20, &(this->uart_event_queue_), - /* Flags used to allocate the interrupt. */ 0); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_driver_install failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); @@ -173,24 +171,32 @@ void IDFUARTComponent::load_settings(bool dump_config) { } auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; - err = uart_set_mode(this->uart_num_, mode); + err = uart_set_mode(this->uart_num_, mode); // per docs, must be called only after uart_driver_install() if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } + uart_config_t uart_config = this->get_config_(); + err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + if (dump_config) { - ESP_LOGCONFIG(TAG, "UART %u was reloaded.", this->uart_num_); + ESP_LOGCONFIG(TAG, "Reloaded UART %u", this->uart_num_); this->dump_config(); } } void IDFUARTComponent::dump_config() { ESP_LOGCONFIG(TAG, "UART Bus %u:", this->uart_num_); - LOG_PIN(" TX Pin: ", tx_pin_); - LOG_PIN(" RX Pin: ", rx_pin_); - LOG_PIN(" Flow Control Pin: ", flow_control_pin_); + LOG_PIN(" TX Pin: ", this->tx_pin_); + LOG_PIN(" RX Pin: ", this->rx_pin_); + LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); if (this->rx_pin_ != nullptr) { ESP_LOGCONFIG(TAG, " RX Buffer Size: %u\n" diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 9c065fe5df..8d1d28fce4 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -46,40 +46,58 @@ uint16_t LibreTinyUARTComponent::get_config() { } void LibreTinyUARTComponent::setup() { - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); - } - int8_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); int8_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); bool rx_inverted = rx_pin_ != nullptr && rx_pin_->is_inverted(); + auto shouldFallbackToSoftwareSerial = [&]() -> bool { + auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { + return pin && pin->get_flags() & mask != gpio::Flags::FLAG_NONE; + }; + if (hasFlags(this->tx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || + hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { +#if LT_ARD_HAS_SOFTSERIAL + ESP_LOGI(TAG, "Pins has flags set. Using Software Serial"); + return true; +#else + ESP_LOGW(TAG, "Pin flags are set but not supported for hardware serial. Ignoring"); +#endif + } + return false; + }; + if (false) return; #if LT_HW_UART0 - else if ((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX)) { + else if ((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX) && + !shouldFallbackToSoftwareSerial()) { this->serial_ = &Serial0; this->hardware_idx_ = 0; } #endif #if LT_HW_UART1 - else if ((tx_pin == -1 || tx_pin == PIN_SERIAL1_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL1_RX)) { + else if ((tx_pin == -1 || tx_pin == PIN_SERIAL1_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL1_RX) && + !shouldFallbackToSoftwareSerial()) { this->serial_ = &Serial1; this->hardware_idx_ = 1; } #endif #if LT_HW_UART2 - else if ((tx_pin == -1 || tx_pin == PIN_SERIAL2_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL2_RX)) { + else if ((tx_pin == -1 || tx_pin == PIN_SERIAL2_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL2_RX) && + !shouldFallbackToSoftwareSerial()) { this->serial_ = &Serial2; this->hardware_idx_ = 2; } #endif else { #if LT_ARD_HAS_SOFTSERIAL + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } this->serial_ = new SoftwareSerial(rx_pin, tx_pin, rx_inverted || tx_inverted); #else this->serial_ = &Serial; diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 35fc4eaf1d..7a381c85a8 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -84,11 +84,6 @@ def update_schema( return _UPDATE_SCHEMA.extend(schema) -# Remove before 2025.11.0 -UPDATE_SCHEMA = update_schema() -UPDATE_SCHEMA.add_extra(cv.deprecated_schema_constant("update")) - - async def setup_update_core_(var, config): await setup_entity(var, config, "update") diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 43b24a54a5..31bdde2df8 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -55,7 +55,7 @@ static const uint8_t USB_DIR_IN = 1 << 7; static const uint8_t USB_DIR_OUT = 0; static const size_t SETUP_PACKET_SIZE = 8; -static const size_t MAX_REQUESTS = USB_HOST_MAX_REQUESTS; // maximum number of outstanding requests possible. +static constexpr size_t MAX_REQUESTS = USB_HOST_MAX_REQUESTS; // maximum number of outstanding requests possible. static_assert(MAX_REQUESTS >= 1 && MAX_REQUESTS <= 32, "MAX_REQUESTS must be between 1 and 32"); // Select appropriate bitmask type for tracking allocation of TransferRequest slots. @@ -65,6 +65,7 @@ static_assert(MAX_REQUESTS >= 1 && MAX_REQUESTS <= 32, "MAX_REQUESTS must be bet // This is tied to the static_assert above, which enforces MAX_REQUESTS is between 1 and 32. // If MAX_REQUESTS is increased above 32, this logic and the static_assert must be updated. using trq_bitmask_t = std::conditional<(MAX_REQUESTS <= 16), uint16_t, uint32_t>::type; +static constexpr trq_bitmask_t ALL_REQUESTS_IN_USE = MAX_REQUESTS == 32 ? ~0 : (1 << MAX_REQUESTS) - 1; static constexpr size_t USB_EVENT_QUEUE_SIZE = 32; // Size of event queue between USB task and main loop static constexpr size_t USB_TASK_STACK_SIZE = 4096; // Stack size for USB task (same as ESP-IDF USB examples) @@ -133,11 +134,11 @@ class USBClient : public Component { float get_setup_priority() const override { return setup_priority::IO; } void on_opened(uint8_t addr); void on_removed(usb_device_handle_t handle); - void control_transfer_callback(const usb_transfer_t *xfer) const; - void transfer_in(uint8_t ep_address, const transfer_cb_t &callback, uint16_t length); - void transfer_out(uint8_t ep_address, const transfer_cb_t &callback, const uint8_t *data, uint16_t length); + bool transfer_in(uint8_t ep_address, const transfer_cb_t &callback, uint16_t length); + bool transfer_out(uint8_t ep_address, const transfer_cb_t &callback, const uint8_t *data, uint16_t length); void dump_config() override; void release_trq(TransferRequest *trq); + trq_bitmask_t get_trq_in_use() const { return trq_in_use_; } bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, const std::vector &data = {}); @@ -147,7 +148,6 @@ class USBClient : public Component { EventPool event_pool; protected: - bool register_(); TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe) virtual void disconnect(); virtual void on_connected() {} @@ -158,7 +158,7 @@ class USBClient : public Component { // USB task management static void usb_task_fn(void *arg); - void usb_task_loop(); + [[noreturn]] void usb_task_loop() const; TaskHandle_t usb_task_handle_{nullptr}; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 2139ed869a..dc216a209d 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -188,9 +188,9 @@ void USBClient::setup() { } // Pre-allocate USB transfer buffers for all slots at startup // This avoids any dynamic allocation during runtime - for (size_t i = 0; i < MAX_REQUESTS; i++) { - usb_host_transfer_alloc(64, 0, &this->requests_[i].transfer); - this->requests_[i].client = this; // Set once, never changes + for (auto &request : this->requests_) { + usb_host_transfer_alloc(64, 0, &request.transfer); + request.client = this; // Set once, never changes } // Create and start USB task @@ -210,8 +210,7 @@ void USBClient::usb_task_fn(void *arg) { auto *client = static_cast(arg); client->usb_task_loop(); } - -void USBClient::usb_task_loop() { +void USBClient::usb_task_loop() const { while (true) { usb_host_client_handle_events(this->handle_, portMAX_DELAY); } @@ -334,22 +333,23 @@ static void control_callback(const usb_transfer_t *xfer) { // This multi-threaded access is intentional for performance - USB task can // immediately restart transfers without waiting for main loop scheduling. TransferRequest *USBClient::get_trq_() { - trq_bitmask_t mask = this->trq_in_use_.load(std::memory_order_relaxed); + trq_bitmask_t mask = this->trq_in_use_.load(std::memory_order_acquire); // Find first available slot (bit = 0) and try to claim it atomically // We use a while loop to allow retrying the same slot after CAS failure - size_t i = 0; - while (i != MAX_REQUESTS) { - if (mask & (static_cast(1) << i)) { - // Slot is in use, move to next slot - i++; - continue; + for (;;) { + if (mask == ALL_REQUESTS_IN_USE) { + ESP_LOGE(TAG, "All %zu transfer slots in use", MAX_REQUESTS); + return nullptr; } + // find the least significant zero bit + trq_bitmask_t lsb = ~mask & (mask + 1); // Slot i appears available, try to claim it atomically - trq_bitmask_t desired = mask | (static_cast(1) << i); // Set bit i to mark as in-use + trq_bitmask_t desired = mask | lsb; - if (this->trq_in_use_.compare_exchange_weak(mask, desired, std::memory_order_acquire, std::memory_order_relaxed)) { + if (this->trq_in_use_.compare_exchange_weak(mask, desired, std::memory_order::acquire)) { + auto i = __builtin_ctz(lsb); // count trailing zeroes // Successfully claimed slot i - prepare the TransferRequest auto *trq = &this->requests_[i]; trq->transfer->context = trq; @@ -358,13 +358,9 @@ TransferRequest *USBClient::get_trq_() { } // CAS failed - another thread modified the bitmask // mask was already updated by compare_exchange_weak with the current value - // No need to reload - the CAS already did that for us - i = 0; } - - ESP_LOGE(TAG, "All %zu transfer slots in use", MAX_REQUESTS); - return nullptr; } + void USBClient::disconnect() { this->on_disconnected(); auto err = usb_host_device_close(this->handle_, this->device_handle_); @@ -446,11 +442,11 @@ static void transfer_callback(usb_transfer_t *xfer) { * * @throws None. */ -void USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, uint16_t length) { +bool USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, uint16_t length) { auto *trq = this->get_trq_(); if (trq == nullptr) { ESP_LOGE(TAG, "Too many requests queued"); - return; + return false; } trq->callback = callback; trq->transfer->callback = transfer_callback; @@ -460,7 +456,9 @@ void USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, u if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to submit transfer, address=%x, length=%d, err=%x", ep_address, length, err); this->release_trq(trq); + return false; } + return true; } /** @@ -476,11 +474,11 @@ void USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, u * * @throws None. */ -void USBClient::transfer_out(uint8_t ep_address, const transfer_cb_t &callback, const uint8_t *data, uint16_t length) { +bool USBClient::transfer_out(uint8_t ep_address, const transfer_cb_t &callback, const uint8_t *data, uint16_t length) { auto *trq = this->get_trq_(); if (trq == nullptr) { ESP_LOGE(TAG, "Too many requests queued"); - return; + return false; } trq->callback = callback; trq->transfer->callback = transfer_callback; @@ -491,7 +489,9 @@ void USBClient::transfer_out(uint8_t ep_address, const transfer_cb_t &callback, if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to submit transfer, address=%x, length=%d, err=%x", ep_address, length, err); this->release_trq(trq); + return false; } + return true; } void USBClient::dump_config() { ESP_LOGCONFIG(TAG, @@ -505,7 +505,7 @@ void USBClient::dump_config() { // - Main loop: When transfer submission fails // // THREAD SAFETY: Lock-free using atomic AND to clear bit -// Thread-safe atomic operation allows multi-threaded deallocation +// Thread-safe atomic operation allows multithreaded deallocation void USBClient::release_trq(TransferRequest *trq) { if (trq == nullptr) return; @@ -517,10 +517,10 @@ void USBClient::release_trq(TransferRequest *trq) { return; } - // Atomically clear bit i to mark slot as available + // Atomically clear the bit to mark slot as available // fetch_and with inverted bitmask clears the bit atomically - trq_bitmask_t bit = static_cast(1) << index; - this->trq_in_use_.fetch_and(static_cast(~bit), std::memory_order_release); + trq_bitmask_t mask = ~(static_cast(1) << index); + this->trq_in_use_.fetch_and(mask, std::memory_order_release); } } // namespace usb_host diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 29003e071e..c24fffb11d 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -214,7 +214,7 @@ void USBUartComponent::dump_config() { } } void USBUartComponent::start_input(USBUartChannel *channel) { - if (!channel->initialised_.load() || channel->input_started_.load()) + if (!channel->initialised_.load()) return; // THREAD CONTEXT: Called from both USB task and main loop threads // - USB task: Immediate restart after successful transfer for continuous data flow @@ -226,12 +226,18 @@ void USBUartComponent::start_input(USBUartChannel *channel) { // // The underlying transfer_in() uses lock-free atomic allocation from the // TransferRequest pool, making this multi-threaded access safe + + // if already started, don't restart. A spurious failure in compare_exchange_weak + // is not a problem, as it will be retried on the next read_array() + auto started = false; + if (!channel->input_started_.compare_exchange_weak(started, true)) + return; const auto *ep = channel->cdc_dev_.in_ep; // CALLBACK CONTEXT: This lambda is executed in USB task via transfer_callback auto callback = [this, channel](const usb_host::TransferStatus &status) { ESP_LOGV(TAG, "Transfer result: length: %u; status %X", status.data_len, status.error_code); if (!status.success) { - ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); + ESP_LOGE(TAG, "Input transfer failed, status=%s", esp_err_to_name(status.error_code)); // On failure, don't restart - let next read_array() trigger it channel->input_started_.store(false); return; @@ -263,8 +269,9 @@ void USBUartComponent::start_input(USBUartChannel *channel) { channel->input_started_.store(false); this->start_input(channel); }; - channel->input_started_.store(true); - this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); + if (!this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize)) { + channel->input_started_.store(false); + } } void USBUartComponent::start_output(USBUartChannel *channel) { @@ -357,11 +364,12 @@ void USBUartTypeCdcAcm::on_disconnected() { usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); } usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number); - channel->initialised_.store(false); - channel->input_started_.store(false); - channel->output_started_.store(false); + // Reset the input and output started flags to their initial state to avoid the possibility of spurious restarts + channel->input_started_.store(true); + channel->output_started_.store(true); channel->input_buffer_.clear(); channel->output_buffer_.clear(); + channel->initialised_.store(false); } USBClient::on_disconnected(); } diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 6f31fc3a20..73e907eb0f 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -129,11 +129,6 @@ def valve_schema( return _VALVE_SCHEMA.extend(schema) -# Remove before 2025.11.0 -VALVE_SCHEMA = valve_schema() -VALVE_SCHEMA.add_extra(cv.deprecated_schema_constant("valve")) - - async def _setup_valve_core(var, config): await setup_entity(var, config, "valve") diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1d08ef5a35..1a3dec0e85 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -111,8 +111,7 @@ void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_ // Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size for (auto &event : this->deferred_queue_) { if (event == item) { - event = item; - return; + return; // Already in queue, no need to update since items are equal } } this->deferred_queue_.push_back(item); @@ -220,50 +219,51 @@ void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServer DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, "/events"); this->push_back(es); - es->onConnect([this, ws, es](AsyncEventSourceClient *client) { - ws->defer([this, ws, es]() { this->on_client_connect_(ws, es); }); - }); + es->onConnect([this, es](AsyncEventSourceClient *client) { this->on_client_connect_(es); }); - es->onDisconnect([this, ws, es](AsyncEventSourceClient *client) { - ws->defer([this, es]() { this->on_client_disconnect_((DeferredUpdateEventSource *) es); }); - }); + es->onDisconnect([this, es](AsyncEventSourceClient *client) { this->on_client_disconnect_(es); }); es->handleRequest(request); } -void DeferredUpdateEventSourceList::on_client_connect_(WebServer *ws, DeferredUpdateEventSource *source) { - // Configure reconnect timeout and send config - // this should always go through since the AsyncEventSourceClient event queue is empty on connect - std::string message = ws->get_config_json(); - source->try_send_nodefer(message.c_str(), "ping", millis(), 30000); +void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource *source) { + WebServer *ws = source->web_server_; + ws->defer([ws, source]() { + // Configure reconnect timeout and send config + // this should always go through since the AsyncEventSourceClient event queue is empty on connect + std::string message = ws->get_config_json(); + source->try_send_nodefer(message.c_str(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING - for (auto &group : ws->sorting_groups_) { - json::JsonBuilder builder; - JsonObject root = builder.root(); - root["name"] = group.second.name; - root["sorting_weight"] = group.second.weight; - message = builder.serialize(); + for (auto &group : ws->sorting_groups_) { + json::JsonBuilder builder; + JsonObject root = builder.root(); + root["name"] = group.second.name; + root["sorting_weight"] = group.second.weight; + message = builder.serialize(); - // up to 31 groups should be able to be queued initially without defer - source->try_send_nodefer(message.c_str(), "sorting_group"); - } + // up to 31 groups should be able to be queued initially without defer + source->try_send_nodefer(message.c_str(), "sorting_group"); + } #endif - source->entities_iterator_.begin(ws->include_internal_); + source->entities_iterator_.begin(ws->include_internal_); - // just dump them all up-front and take advantage of the deferred queue - // on second thought that takes too long, but leaving the commented code here for debug purposes - // while(!source->entities_iterator_.completed()) { - // source->entities_iterator_.advance(); - //} + // just dump them all up-front and take advantage of the deferred queue + // on second thought that takes too long, but leaving the commented code here for debug purposes + // while(!source->entities_iterator_.completed()) { + // source->entities_iterator_.advance(); + //} + }); } void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSource *source) { - // This method was called via WebServer->defer() and is no longer executing in the - // context of the network callback. The object is now dead and can be safely deleted. - this->remove(source); - delete source; // NOLINT + source->web_server_->defer([this, source]() { + // This method was called via WebServer->defer() and is no longer executing in the + // context of the network callback. The object is now dead and can be safely deleted. + this->remove(source); + delete source; // NOLINT + }); } #endif @@ -435,9 +435,10 @@ void WebServer::on_sensor_update(sensor::Sensor *obj, float state) { } void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (sensor::Sensor *obj : App.get_sensors()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + // Note: request->method() is always HTTP_GET here (canHandle ensures this) + if (match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->sensor_json(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); @@ -477,9 +478,10 @@ void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj, const std::s } void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (text_sensor::TextSensor *obj : App.get_text_sensors()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + // Note: request->method() is always HTTP_GET here (canHandle ensures this) + if (match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->text_sensor_json(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); @@ -516,7 +518,7 @@ void WebServer::on_switch_update(switch_::Switch *obj, bool state) { } void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (switch_::Switch *obj : App.get_switches()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { @@ -585,7 +587,7 @@ std::string WebServer::switch_json(switch_::Switch *obj, bool value, JsonDetail #ifdef USE_BUTTON void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (button::Button *obj : App.get_buttons()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); @@ -627,9 +629,10 @@ void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) { } void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + // Note: request->method() is always HTTP_GET here (canHandle ensures this) + if (match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->binary_sensor_json(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); @@ -665,7 +668,7 @@ void WebServer::on_fan_update(fan::Fan *obj) { } void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (fan::Fan *obj : App.get_fans()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { @@ -739,7 +742,7 @@ void WebServer::on_light_update(light::LightState *obj) { } void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (light::LightState *obj : App.get_lights()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { @@ -812,7 +815,7 @@ void WebServer::on_cover_update(cover::Cover *obj) { } void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (cover::Cover *obj : App.get_covers()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { @@ -897,7 +900,7 @@ void WebServer::on_number_update(number::Number *obj, float state) { } void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_numbers()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { @@ -962,7 +965,7 @@ void WebServer::on_date_update(datetime::DateEntity *obj) { } void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_dates()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); @@ -1017,7 +1020,7 @@ void WebServer::on_time_update(datetime::TimeEntity *obj) { } void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_times()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); @@ -1071,7 +1074,7 @@ void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) { } void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_datetimes()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); @@ -1126,7 +1129,7 @@ void WebServer::on_text_update(text::Text *obj, const std::string &state) { } void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_texts()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { @@ -1180,12 +1183,12 @@ void WebServer::on_select_update(select::Select *obj, const std::string &state, } void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_selects()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); - std::string data = this->select_json(obj, obj->state, detail); + std::string data = this->select_json(obj, obj->has_state() ? obj->current_option() : "", detail); request->send(200, "application/json", data.c_str()); return; } @@ -1205,12 +1208,14 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM request->send(404); } std::string WebServer::select_state_json_generator(WebServer *web_server, void *source) { - return web_server->select_json((select::Select *) (source), ((select::Select *) (source))->state, DETAIL_STATE); + auto *obj = (select::Select *) (source); + return web_server->select_json(obj, obj->has_state() ? obj->current_option() : "", DETAIL_STATE); } std::string WebServer::select_all_json_generator(WebServer *web_server, void *source) { - return web_server->select_json((select::Select *) (source), ((select::Select *) (source))->state, DETAIL_ALL); + auto *obj = (select::Select *) (source); + return web_server->select_json(obj, obj->has_state() ? obj->current_option() : "", DETAIL_ALL); } -std::string WebServer::select_json(select::Select *obj, const std::string &value, JsonDetail start_config) { +std::string WebServer::select_json(select::Select *obj, const char *value, JsonDetail start_config) { json::JsonBuilder builder; JsonObject root = builder.root(); @@ -1236,7 +1241,7 @@ void WebServer::on_climate_update(climate::Climate *obj) { } void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_climates()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { @@ -1312,7 +1317,7 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf for (climate::ClimatePreset m : traits.get_supported_presets()) opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); } - if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { + if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { JsonArray opt = root["custom_presets"].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) opt.add(custom_preset); @@ -1333,14 +1338,14 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { root["fan_mode"] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); } - if (!traits.get_supported_custom_fan_modes().empty() && obj->custom_fan_mode.has_value()) { - root["custom_fan_mode"] = obj->custom_fan_mode.value().c_str(); + if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { + root["custom_fan_mode"] = obj->get_custom_fan_mode(); } if (traits.get_supports_presets() && obj->preset.has_value()) { root["preset"] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); } - if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { - root["custom_preset"] = obj->custom_preset.value().c_str(); + if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { + root["custom_preset"] = obj->get_custom_preset(); } if (traits.get_supports_swing_modes()) { root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); @@ -1377,7 +1382,7 @@ void WebServer::on_lock_update(lock::Lock *obj) { } void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (lock::Lock *obj : App.get_locks()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { @@ -1448,7 +1453,7 @@ void WebServer::on_valve_update(valve::Valve *obj) { } void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (valve::Valve *obj : App.get_valves()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { @@ -1529,7 +1534,7 @@ void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlP } void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { @@ -1608,10 +1613,11 @@ void WebServer::on_event(event::Event *obj, const std::string &event_type) { void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (event::Event *obj : App.get_events()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + // Note: request->method() is always HTTP_GET here (canHandle ensures this) + if (match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->event_json(obj, "", detail); request->send(200, "application/json", data.c_str()); @@ -1673,7 +1679,7 @@ void WebServer::on_update(update::UpdateEntity *obj) { } void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (update::UpdateEntity *obj : App.get_updates()) { - if (!match.id_equals(obj->get_object_id())) + if (!match.id_equals_entity(obj)) continue; if (request->method() == HTTP_GET && match.method_empty()) { diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 2e5d58d375..328140cfae 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -48,8 +48,15 @@ struct UrlMatch { return domain && domain_len == strlen(str) && memcmp(domain, str, domain_len) == 0; } - bool id_equals(const std::string &str) const { - return id && id_len == str.length() && memcmp(id, str.c_str(), id_len) == 0; + bool id_equals_entity(EntityBase *entity) const { + // Zero-copy comparison using StringRef + StringRef static_ref = entity->get_object_id_ref_for_api_(); + if (!static_ref.empty()) { + return id && id_len == static_ref.size() && memcmp(id, static_ref.c_str(), id_len) == 0; + } + // Fallback to allocation (rare) + const auto &obj_id = entity->get_object_id(); + return id && id_len == obj_id.length() && memcmp(id, obj_id.c_str(), id_len) == 0; } bool method_equals(const char *str) const { @@ -141,7 +148,7 @@ class DeferredUpdateEventSource : public AsyncEventSource { class DeferredUpdateEventSourceList : public std::list { protected: - void on_client_connect_(WebServer *ws, DeferredUpdateEventSource *source); + void on_client_connect_(DeferredUpdateEventSource *source); void on_client_disconnect_(DeferredUpdateEventSource *source); public: @@ -403,7 +410,7 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { static std::string select_state_json_generator(WebServer *web_server, void *source); static std::string select_all_json_generator(WebServer *web_server, void *source); /// Dump the select state with its value as a JSON string. - std::string select_json(select::Select *obj, const std::string &value, JsonDetail start_config); + std::string select_json(select::Select *obj, const char *value, JsonDetail start_config); #endif #ifdef USE_CLIMATE diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index c3ba7ddc2b..1135c4b16a 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -245,8 +246,8 @@ void AsyncWebServerRequest::redirect(const std::string &url) { } void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code, const char *content_type) { - // Set status code - use constants for common codes to avoid string allocation - const char *status = nullptr; + // Set status code - use constants for common codes, default to 500 for unknown codes + const char *status; switch (code) { case 200: status = HTTPD_200; @@ -258,9 +259,10 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code status = HTTPD_409; break; default: + status = HTTPD_500; break; } - httpd_resp_set_status(*this, status == nullptr ? to_string(code).c_str() : status); + httpd_resp_set_status(*this, status); if (content_type && *content_type) { httpd_resp_set_type(*this, content_type); @@ -348,7 +350,14 @@ void AsyncWebServerResponse::addHeader(const char *name, const char *value) { httpd_resp_set_hdr(*this->req_, name, value); } -void AsyncResponseStream::print(float value) { this->print(to_string(value)); } +void AsyncResponseStream::print(float value) { + // Use stack buffer to avoid temporary string allocation + // Size: sign (1) + digits (10) + decimal (1) + precision (6) + exponent (5) + null (1) = 24, use 32 for safety + constexpr size_t float_buf_size = 32; + char buf[float_buf_size]; + int len = snprintf(buf, float_buf_size, "%f", value); + this->content_.append(buf, len); +} void AsyncResponseStream::printf(const char *fmt, ...) { va_list args; @@ -494,8 +503,7 @@ void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_g // Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size for (auto &event : this->deferred_queue_) { if (event == item) { - event = item; - return; + return; // Already in queue, no need to update since items are equal } } this->deferred_queue_.push_back(item); @@ -594,16 +602,19 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char event_buffer_.append(chunk_len_header); + // Use stack buffer for formatting numeric fields to avoid temporary string allocations + // Size: "retry: " (7) + max uint32 (10 digits) + CRLF (2) + null (1) = 20 bytes, use 32 for safety + constexpr size_t num_buf_size = 32; + char num_buf[num_buf_size]; + if (reconnect) { - event_buffer_.append("retry: ", sizeof("retry: ") - 1); - event_buffer_.append(to_string(reconnect)); - event_buffer_.append(CRLF_STR, CRLF_LEN); + int len = snprintf(num_buf, num_buf_size, "retry: %" PRIu32 CRLF_STR, reconnect); + event_buffer_.append(num_buf, len); } if (id) { - event_buffer_.append("id: ", sizeof("id: ") - 1); - event_buffer_.append(to_string(id)); - event_buffer_.append(CRLF_STR, CRLF_LEN); + int len = snprintf(num_buf, num_buf_size, "id: %" PRIu32 CRLF_STR, id); + event_buffer_.append(num_buf, len); } if (event && *event) { diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index ba488728b7..b980bab4aa 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -3,7 +3,7 @@ from esphome.automation import Condition import esphome.codegen as cg from esphome.components.const import CONF_USE_PSRAM from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant -from esphome.components.network import IPAddress +from esphome.components.network import ip_address_literal from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.config_validation import only_with_esp_idf @@ -334,9 +334,7 @@ def eap_auth(config): def safe_ip(ip): - if ip is None: - return IPAddress(0, 0, 0, 0) - return IPAddress(str(ip)) + return ip_address_literal(ip) def manual_ip(config): diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 634c99876b..a2fb12a5e2 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -234,6 +234,9 @@ def copy_files(): "url": "https://esphome.io/", "vendor": "esphome", "build": { + "bsp": { + "name": "adafruit" + }, "softdevice": { "sd_fwid": "0x00B6" } diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index ad7a148cdb..365b6b8ed2 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -3,7 +3,7 @@ #include #include #include -#include +#include #include "esphome/core/hal.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 4b84910368..41b983535c 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -8,8 +8,8 @@ namespace zephyr { static const char *const TAG = "zephyr"; -static int flags_to_mode(gpio::Flags flags, bool inverted, bool value) { - int ret = 0; +static gpio_flags_t flags_to_mode(gpio::Flags flags, bool inverted, bool value) { + gpio_flags_t ret = 0; if (flags & gpio::FLAG_INPUT) { ret |= GPIO_INPUT; } @@ -79,7 +79,10 @@ void ZephyrGPIOPin::pin_mode(gpio::Flags flags) { if (nullptr == this->gpio_) { return; } - gpio_pin_configure(this->gpio_, this->pin_ % 32, flags_to_mode(flags, this->inverted_, this->value_)); + auto ret = gpio_pin_configure(this->gpio_, this->pin_ % 32, flags_to_mode(flags, this->inverted_, this->value_)); + if (ret != 0) { + ESP_LOGE(TAG, "gpio %u cannot be configured %d.", this->pin_, ret); + } } std::string ZephyrGPIOPin::dump_summary() const { diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index f512ae4648..6e8f81857a 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -26,10 +26,10 @@ class ZephyrGPIOPin : public InternalGPIOPin { protected: void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; uint8_t pin_; - bool inverted_; - gpio::Flags flags_; - const device *gpio_ = nullptr; - bool value_ = false; + bool inverted_{}; + gpio::Flags flags_{}; + const device *gpio_{nullptr}; + bool value_{false}; }; } // namespace zephyr diff --git a/esphome/config.py b/esphome/config.py index 634dba8dad..e508ca585b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -319,6 +319,9 @@ def iter_ids(config, path=None): yield from iter_ids(item, path + [i]) elif isinstance(config, dict): for key, value in config.items(): + if len(path) == 0 and key == CONF_SUBSTITUTIONS: + # Ignore IDs in substitution definitions. + continue if isinstance(key, core.ID): yield key, path yield from iter_ids(value, path + [key]) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c613a984c4..a3fd271a86 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from contextlib import contextmanager, suppress from dataclasses import dataclass from datetime import datetime @@ -18,6 +19,7 @@ import logging from pathlib import Path import re from string import ascii_letters, digits +import typing import uuid as uuid_ import voluptuous as vol @@ -1763,16 +1765,37 @@ class SplitDefault(Optional): class OnlyWith(Optional): - """Set the default value only if the given component is loaded.""" + """Set the default value only if the given component(s) is/are loaded. - def __init__(self, key, component, default=None): + This validator allows configuration keys to have defaults that are only applied + when specific component(s) are loaded. Supports both single component names and + lists of components. + + Args: + key: Configuration key + component: Single component name (str) or list of component names. + For lists, ALL components must be loaded for the default to apply. + default: Default value to use when condition is met + + Example: + # Single component + cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(MQTTComponent) + + # Multiple components (all must be loaded) + cv.OnlyWith(CONF_ZIGBEE_ID, ["zigbee", "nrf52"]): cv.use_id(Zigbee) + """ + + def __init__(self, key, component: str | list[str], default=None) -> None: super().__init__(key) self._component = component self._default = vol.default_factory(default) @property - def default(self): - if self._component in CORE.loaded_integrations: + def default(self) -> Callable[[], typing.Any] | vol.Undefined: + if isinstance(self._component, list): + if all(c in CORE.loaded_integrations for c in self._component): + return self._default + elif self._component in CORE.loaded_integrations: return self._default return vol.UNDEFINED @@ -2195,26 +2218,3 @@ def rename_key(old_key, new_key): return config return validator - - -# Remove before 2025.11.0 -def deprecated_schema_constant(entity_type: str): - def validator(config): - type: str = "unknown" - if (id := config.get(CONF_ID)) is not None and isinstance(id, core.ID): - type = str(id.type).split("::", maxsplit=1)[0] - _LOGGER.warning( - "Using `%s.%s_SCHEMA` is deprecated and will be removed in ESPHome 2025.11.0. " - "Please use `%s.%s_schema(...)` instead. " - "If you are seeing this, report an issue to the external_component author and ask them to update it. " - "https://developers.esphome.io/blog/2025/05/14/_schema-deprecations/. " - "Component using this schema: %s", - entity_type, - entity_type.upper(), - entity_type, - entity_type, - type, - ) - return config - - return validator diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index c745aa0ae5..61cfcc7585 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -576,10 +576,11 @@ void Application::yield_with_select_(uint32_t delay_ms) { // Update fd_set if socket list has changed if (this->socket_fds_changed_) { FD_ZERO(&this->base_read_fds_); + // fd bounds are already validated in register_socket_fd() or guaranteed by platform design: + // - ESP32: LwIP guarantees fd < FD_SETSIZE by design (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS) + // - Other platforms: register_socket_fd() validates fd < FD_SETSIZE for (int fd : this->socket_fds_) { - if (fd >= 0 && fd < FD_SETSIZE) { - FD_SET(fd, &this->base_read_fds_); - } + FD_SET(fd, &this->base_read_fds_); } this->socket_fds_changed_ = false; } diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 0512752d50..c22b3ca0e3 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -4,6 +4,8 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include +#include #include #include @@ -27,11 +29,20 @@ template class TemplatableValue { public: TemplatableValue() : type_(NONE) {} - template::value, int> = 0> TemplatableValue(F value) : type_(VALUE) { + template TemplatableValue(F value) requires(!std::invocable) : type_(VALUE) { new (&this->value_) T(std::move(value)); } - template::value, int> = 0> TemplatableValue(F f) : type_(LAMBDA) { + // For stateless lambdas (convertible to function pointer): use function pointer + template + TemplatableValue(F f) requires std::invocable && std::convertible_to + : type_(STATELESS_LAMBDA) { + this->stateless_f_ = f; // Implicit conversion to function pointer + } + + // For stateful lambdas (not convertible to function pointer): use std::function + template + TemplatableValue(F f) requires std::invocable &&(!std::convertible_to) : type_(LAMBDA) { this->f_ = new std::function(std::move(f)); } @@ -41,6 +52,8 @@ template class TemplatableValue { new (&this->value_) T(other.value_); } else if (type_ == LAMBDA) { this->f_ = new std::function(*other.f_); + } else if (type_ == STATELESS_LAMBDA) { + this->stateless_f_ = other.stateless_f_; } } @@ -51,6 +64,8 @@ template class TemplatableValue { } else if (type_ == LAMBDA) { this->f_ = other.f_; other.f_ = nullptr; + } else if (type_ == STATELESS_LAMBDA) { + this->stateless_f_ = other.stateless_f_; } other.type_ = NONE; } @@ -78,16 +93,23 @@ template class TemplatableValue { } else if (type_ == LAMBDA) { delete this->f_; } + // STATELESS_LAMBDA/NONE: no cleanup needed (function pointer or empty, not heap-allocated) } bool has_value() { return this->type_ != NONE; } T value(X... x) { - if (this->type_ == LAMBDA) { - return (*this->f_)(x...); + switch (this->type_) { + case STATELESS_LAMBDA: + return this->stateless_f_(x...); // Direct function pointer call + case LAMBDA: + return (*this->f_)(x...); // std::function call + case VALUE: + return this->value_; + case NONE: + default: + return T{}; } - // return value also when none - return this->type_ == VALUE ? this->value_ : T{}; } optional optional_value(X... x) { @@ -109,11 +131,13 @@ template class TemplatableValue { NONE, VALUE, LAMBDA, + STATELESS_LAMBDA, } type_; union { T value_; std::function *f_; + T (*stateless_f_)(X...); }; }; @@ -196,6 +220,7 @@ template class Action { protected: friend ActionList; + template friend class ContinuationAction; virtual void play(Ts... x) = 0; void play_next_(Ts... x) { diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index af8cde971b..78838c70c8 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -10,6 +10,7 @@ #include "esphome/core/helpers.h" #include +#include namespace esphome { @@ -79,6 +80,18 @@ template class LambdaCondition : public Condition { std::function f_; }; +/// Optimized lambda condition for stateless lambdas (no capture). +/// Uses function pointer instead of std::function to reduce memory overhead. +/// Memory: 4 bytes (function pointer on 32-bit) vs 32 bytes (std::function). +template class StatelessLambdaCondition : public Condition { + public: + explicit StatelessLambdaCondition(bool (*f)(Ts...)) : f_(f) {} + bool check(Ts... x) override { return this->f_(x...); } + + protected: + bool (*f_)(Ts...); +}; + template class ForCondition : public Condition, public Component { public: explicit ForCondition(Condition<> *condition) : condition_(condition) {} @@ -190,18 +203,59 @@ template class LambdaAction : public Action { std::function f_; }; +/// Optimized lambda action for stateless lambdas (no capture). +/// Uses function pointer instead of std::function to reduce memory overhead. +/// Memory: 4 bytes (function pointer on 32-bit) vs 32 bytes (std::function). +template class StatelessLambdaAction : public Action { + public: + explicit StatelessLambdaAction(void (*f)(Ts...)) : f_(f) {} + + void play(Ts... x) override { this->f_(x...); } + + protected: + void (*f_)(Ts...); +}; + +/// Simple continuation action that calls play_next_ on a parent action. +/// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. +/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). +template class ContinuationAction : public Action { + public: + explicit ContinuationAction(Action *parent) : parent_(parent) {} + + void play(Ts... x) override { this->parent_->play_next_(x...); } + + protected: + Action *parent_; +}; + +// Forward declaration for WhileLoopContinuation +template class WhileAction; + +/// Loop continuation for WhileAction that checks condition and repeats or continues. +/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). +template class WhileLoopContinuation : public Action { + public: + explicit WhileLoopContinuation(WhileAction *parent) : parent_(parent) {} + + void play(Ts... x) override; + + protected: + WhileAction *parent_; +}; + template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new LambdaAction([this](Ts... x) { this->play_next_(x...); })); + this->then_.add_action(new ContinuationAction(this)); } void add_else(const std::initializer_list *> &actions) { this->else_.add_actions(actions); - this->else_.add_action(new LambdaAction([this](Ts... x) { this->play_next_(x...); })); + this->else_.add_action(new ContinuationAction(this)); } void play_complex(Ts... x) override { @@ -242,33 +296,23 @@ template class WhileAction : public Action { void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new LambdaAction([this](Ts... x) { - if (this->num_running_ > 0 && this->condition_->check_tuple(this->var_)) { - // play again - if (this->num_running_ > 0) { - this->then_.play_tuple(this->var_); - } - } else { - // condition false, play next - this->play_next_tuple_(this->var_); - } - })); + this->then_.add_action(new WhileLoopContinuation(this)); } + friend class WhileLoopContinuation; + void play_complex(Ts... x) override { this->num_running_++; - // Store loop parameters - this->var_ = std::make_tuple(x...); // Initial condition check - if (!this->condition_->check_tuple(this->var_)) { + if (!this->condition_->check(x...)) { // If new condition check failed, stop loop if running this->then_.stop(); - this->play_next_tuple_(this->var_); + this->play_next_(x...); return; } if (this->num_running_ > 0) { - this->then_.play_tuple(this->var_); + this->then_.play(x...); } } @@ -280,7 +324,32 @@ template class WhileAction : public Action { protected: Condition *condition_; ActionList then_; - std::tuple var_{}; +}; + +// Implementation of WhileLoopContinuation::play +template void WhileLoopContinuation::play(Ts... x) { + if (this->parent_->num_running_ > 0 && this->parent_->condition_->check(x...)) { + // play again + this->parent_->then_.play(x...); + } else { + // condition false, play next + this->parent_->play_next_(x...); + } +} + +// Forward declaration for RepeatLoopContinuation +template class RepeatAction; + +/// Loop continuation for RepeatAction that increments iteration and repeats or continues. +/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). +template class RepeatLoopContinuation : public Action { + public: + explicit RepeatLoopContinuation(RepeatAction *parent) : parent_(parent) {} + + void play(uint32_t iteration, Ts... x) override; + + protected: + RepeatAction *parent_; }; template class RepeatAction : public Action { @@ -289,23 +358,17 @@ template class RepeatAction : public Action { void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new LambdaAction([this](uint32_t iteration, Ts... x) { - iteration++; - if (iteration >= this->count_.value(x...)) { - this->play_next_tuple_(this->var_); - } else { - this->then_.play(iteration, x...); - } - })); + this->then_.add_action(new RepeatLoopContinuation(this)); } + friend class RepeatLoopContinuation; + void play_complex(Ts... x) override { this->num_running_++; - this->var_ = std::make_tuple(x...); if (this->count_.value(x...) > 0) { this->then_.play(0, x...); } else { - this->play_next_tuple_(this->var_); + this->play_next_(x...); } } @@ -316,15 +379,36 @@ template class RepeatAction : public Action { protected: ActionList then_; - std::tuple var_; }; +// Implementation of RepeatLoopContinuation::play +template void RepeatLoopContinuation::play(uint32_t iteration, Ts... x) { + iteration++; + if (iteration >= this->parent_->count_.value(x...)) { + this->parent_->play_next_(x...); + } else { + this->parent_->then_.play(iteration, x...); + } +} + +/** Wait until a condition is true to continue execution. + * + * Uses queue-based storage to safely handle concurrent executions. + * While concurrent execution from the same trigger is uncommon, it's possible + * (e.g., rapid button presses, high-frequency sensor updates), so we use + * queue-based storage for correctness. + */ template class WaitUntilAction : public Action, public Component { public: WaitUntilAction(Condition *condition) : condition_(condition) {} TEMPLATABLE_VALUE(uint32_t, timeout_value) + void setup() override { + // Start with loop disabled - only enable when there's work to do + this->disable_loop(); + } + void play_complex(Ts... x) override { this->num_running_++; // Check if we can continue immediately. @@ -334,13 +418,14 @@ template class WaitUntilAction : public Action, public Co } return; } - this->var_ = std::make_tuple(x...); - if (this->timeout_value_.has_value()) { - auto f = std::bind(&WaitUntilAction::play_next_, this, x...); - this->set_timeout("timeout", this->timeout_value_.value(x...), f); - } + // Store for later processing + auto now = millis(); + auto timeout = this->timeout_value_.optional_value(x...); + this->var_queue_.emplace_front(now, timeout, std::make_tuple(x...)); + // Enable loop now that we have work to do + this->enable_loop(); this->loop(); } @@ -348,13 +433,32 @@ template class WaitUntilAction : public Action, public Co if (this->num_running_ == 0) return; - if (!this->condition_->check_tuple(this->var_)) { - return; + auto now = millis(); + + this->var_queue_.remove_if([&](auto &queued) { + auto start = std::get(queued); + auto timeout = std::get>(queued); + auto &var = std::get>(queued); + + auto expired = timeout && (now - start) >= *timeout; + + if (!expired && !this->condition_->check_tuple(var)) { + return false; + } + + this->play_next_tuple_(var); + return true; + }); + + // If queue is now empty, disable loop until next play_complex + if (this->var_queue_.empty()) { + this->disable_loop(); } + } - this->cancel_timeout("timeout"); - - this->play_next_tuple_(this->var_); + void stop() override { + this->var_queue_.clear(); + this->disable_loop(); } float get_setup_priority() const override { return setup_priority::DATA; } @@ -362,11 +466,9 @@ template class WaitUntilAction : public Action, public Co void play(Ts... x) override { /* ignore - see play_complex */ } - void stop() override { this->cancel_timeout("timeout"); } - protected: Condition *condition_; - std::tuple var_{}; + std::forward_list, std::tuple>> var_queue_{}; }; template class UpdateComponentAction : public Action { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 39698c1004..868df6e254 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -87,6 +87,7 @@ #define USE_MDNS_STORE_SERVICES #define MDNS_SERVICE_COUNT 3 #define MDNS_DYNAMIC_TXT_COUNT 3 +#define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER #define USE_NEXTION_TFT_UPLOAD #define USE_NUMBER @@ -122,6 +123,7 @@ #define USE_API_NOISE #define USE_API_PLAINTEXT #define USE_API_SERVICES +#define USE_API_CUSTOM_SERVICES #define API_MAX_SEND_QUEUE 8 #define USE_MD5 #define USE_SHA256 @@ -185,6 +187,7 @@ #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 #define USE_ESP32_CAMERA_JPEG_ENCODER +#define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_IMPROV #define USE_ESP32_IMPROV_NEXT_URL @@ -235,6 +238,7 @@ #define USE_CAPTIVE_PORTAL #define USE_ESP8266_PREFERENCES_FLASH #define USE_HTTP_REQUEST_ESP8266_HTTPS +#define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_SOCKET_IMPL_LWIP_TCP @@ -255,6 +259,7 @@ #ifdef USE_RP2040 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) +#define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP @@ -271,6 +276,7 @@ #endif #ifdef USE_HOST +#define USE_HTTP_REQUEST_RESPONSE #define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_SOCKET_SELECT_SUPPORT #endif diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 4a6460e708..80cd6b8e77 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -17,6 +17,10 @@ namespace api { class APIConnection; } // namespace api +namespace web_server { +struct UrlMatch; +} // namespace web_server + enum EntityCategory : uint8_t { ENTITY_CATEGORY_NONE = 0, ENTITY_CATEGORY_CONFIG = 1, @@ -116,6 +120,7 @@ class EntityBase { protected: friend class api::APIConnection; + friend struct web_server::UrlMatch; // Get object_id as StringRef when it's static (for API usage) // Returns empty StringRef if object_id is dynamic (needs allocation) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index f0a04b4860..9b4786f835 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -105,7 +105,9 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: config[CONF_NAME], platform, ) - add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) + # Only set disabled_by_default if True (default is False) + if config[CONF_DISABLED_BY_DEFAULT]: + add(var.set_disabled_by_default(True)) if CONF_INTERNAL in config: add(var.set_internal(config[CONF_INTERNAL])) if CONF_ICON in config: diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index e542805a8d..38ed2158ab 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -307,6 +307,11 @@ template class FixedVector { return data_[size_ - 1]; } + /// Access first element (no bounds checking - matches std::vector behavior) + /// Caller must ensure vector is not empty (size() > 0) + T &front() { return data_[0]; } + const T &front() const { return data_[0]; } + /// Access last element (no bounds checking - matches std::vector behavior) /// Caller must ensure vector is not empty (size() > 0) T &back() { return data_[size_ - 1]; } @@ -320,6 +325,11 @@ template class FixedVector { T &operator[](size_t i) { return data_[i]; } const T &operator[](size_t i) const { return data_[i]; } + /// Access element with bounds checking (matches std::vector behavior) + /// Note: No exception thrown on out of bounds - caller must ensure index is valid + T &at(size_t i) { return data_[i]; } + const T &at(size_t i) const { return data_[i]; } + // Iterator support for range-based for loops T *begin() { return data_; } T *end() { return data_ + size_; } diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 0d4715f621..11d59c2499 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -316,59 +316,37 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { return 0; return next_exec - now_64; } + +void Scheduler::full_cleanup_removed_items_() { + // We hold the lock for the entire cleanup operation because: + // 1. We're rebuilding the entire items_ list, so we need exclusive access throughout + // 2. Other threads must see either the old state or the new state, not intermediate states + // 3. The operation is already expensive (O(n)), so lock overhead is negligible + // 4. No operations inside can block or take other locks, so no deadlock risk + LockGuard guard{this->lock_}; + + std::vector> valid_items; + + // Move all non-removed items to valid_items, recycle removed ones + for (auto &item : this->items_) { + if (!is_item_removed_(item.get())) { + valid_items.push_back(std::move(item)); + } else { + // Recycle removed items + this->recycle_item_(std::move(item)); + } + } + + // Replace items_ with the filtered list + this->items_ = std::move(valid_items); + // Rebuild the heap structure since items are no longer in heap order + std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); + this->to_remove_ = 0; +} + void HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE - // Process defer queue first to guarantee FIFO execution order for deferred items. - // Previously, defer() used the heap which gave undefined order for equal timestamps, - // causing race conditions on multi-core systems (ESP32, BK7200). - // With the defer queue: - // - Deferred items (delay=0) go directly to defer_queue_ in set_timer_common_ - // - Items execute in exact order they were deferred (FIFO guarantee) - // - No deferred items exist in to_add_, so processing order doesn't affect correctness - // Single-core platforms don't use this queue and fall back to the heap-based approach. - // - // Note: Items cancelled via cancel_item_locked_() are marked with remove=true but still - // processed here. They are skipped during execution by should_skip_item_(). - // This is intentional - no memory leak occurs. - // - // We use an index (defer_queue_front_) to track the read position instead of calling - // erase() on every pop, which would be O(n). The queue is processed once per loop - - // any items added during processing are left for the next loop iteration. - - // Snapshot the queue end point - only process items that existed at loop start - // Items added during processing (by callbacks or other threads) run next loop - // No lock needed: single consumer (main loop), stale read just means we process less this iteration - size_t defer_queue_end = this->defer_queue_.size(); - - while (this->defer_queue_front_ < defer_queue_end) { - std::unique_ptr item; - { - LockGuard lock(this->lock_); - // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. - // This is intentional and safe because: - // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function - // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_ - // and has_cancelled_timeout_in_container_ in scheduler.h) - // 3. The lock protects concurrent access, but the nullptr remains until cleanup - item = std::move(this->defer_queue_[this->defer_queue_front_]); - this->defer_queue_front_++; - } - - // Execute callback without holding lock to prevent deadlocks - // if the callback tries to call defer() again - if (!this->should_skip_item_(item.get())) { - now = this->execute_item_(item.get(), now); - } - // Recycle the defer item after execution - this->recycle_item_(std::move(item)); - } - - // If we've consumed all items up to the snapshot point, clean up the dead space - // Single consumer (main loop), so no lock needed for this check - if (this->defer_queue_front_ >= defer_queue_end) { - LockGuard lock(this->lock_); - this->cleanup_defer_queue_locked_(); - } + this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ // Convert the fresh timestamp from main loop to 64-bit for scheduler operations @@ -429,30 +407,7 @@ void HOT Scheduler::call(uint32_t now) { // If we still have too many cancelled items, do a full cleanup // This only happens if cancelled items are stuck in the middle/bottom of the heap if (this->to_remove_ >= MAX_LOGICALLY_DELETED_ITEMS) { - // We hold the lock for the entire cleanup operation because: - // 1. We're rebuilding the entire items_ list, so we need exclusive access throughout - // 2. Other threads must see either the old state or the new state, not intermediate states - // 3. The operation is already expensive (O(n)), so lock overhead is negligible - // 4. No operations inside can block or take other locks, so no deadlock risk - LockGuard guard{this->lock_}; - - std::vector> valid_items; - - // Move all non-removed items to valid_items, recycle removed ones - for (auto &item : this->items_) { - if (!is_item_removed_(item.get())) { - valid_items.push_back(std::move(item)); - } else { - // Recycle removed items - this->recycle_item_(std::move(item)); - } - } - - // Replace items_ with the filtered list - this->items_ = std::move(valid_items); - // Rebuild the heap structure since items are no longer in heap order - std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - this->to_remove_ = 0; + this->full_cleanup_removed_items_(); } while (!this->items_.empty()) { // Don't copy-by value yet diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index ad0ec0284e..f6ec07294d 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -4,7 +4,6 @@ #include #include #include -#include #ifdef ESPHOME_THREAD_MULTI_ATOMICS #include #endif @@ -264,7 +263,65 @@ class Scheduler { // Helper to recycle a SchedulerItem void recycle_item_(std::unique_ptr item); + // Helper to perform full cleanup when too many items are cancelled + void full_cleanup_removed_items_(); + #ifndef ESPHOME_THREAD_SINGLE + // Helper to process defer queue - inline for performance in hot path + inline void process_defer_queue_(uint32_t &now) { + // Process defer queue first to guarantee FIFO execution order for deferred items. + // Previously, defer() used the heap which gave undefined order for equal timestamps, + // causing race conditions on multi-core systems (ESP32, BK7200). + // With the defer queue: + // - Deferred items (delay=0) go directly to defer_queue_ in set_timer_common_ + // - Items execute in exact order they were deferred (FIFO guarantee) + // - No deferred items exist in to_add_, so processing order doesn't affect correctness + // Single-core platforms don't use this queue and fall back to the heap-based approach. + // + // Note: Items cancelled via cancel_item_locked_() are marked with remove=true but still + // processed here. They are skipped during execution by should_skip_item_(). + // This is intentional - no memory leak occurs. + // + // We use an index (defer_queue_front_) to track the read position instead of calling + // erase() on every pop, which would be O(n). The queue is processed once per loop - + // any items added during processing are left for the next loop iteration. + + // Snapshot the queue end point - only process items that existed at loop start + // Items added during processing (by callbacks or other threads) run next loop + // No lock needed: single consumer (main loop), stale read just means we process less this iteration + size_t defer_queue_end = this->defer_queue_.size(); + + while (this->defer_queue_front_ < defer_queue_end) { + std::unique_ptr item; + { + LockGuard lock(this->lock_); + // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. + // This is intentional and safe because: + // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function + // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_ + // and has_cancelled_timeout_in_container_ in scheduler.h) + // 3. The lock protects concurrent access, but the nullptr remains until cleanup + item = std::move(this->defer_queue_[this->defer_queue_front_]); + this->defer_queue_front_++; + } + + // Execute callback without holding lock to prevent deadlocks + // if the callback tries to call defer() again + if (!this->should_skip_item_(item.get())) { + now = this->execute_item_(item.get(), now); + } + // Recycle the defer item after execution + this->recycle_item_(std::move(item)); + } + + // If we've consumed all items up to the snapshot point, clean up the dead space + // Single consumer (main loop), so no lock needed for this check + if (this->defer_queue_front_ >= defer_queue_end) { + LockGuard lock(this->lock_); + this->cleanup_defer_queue_locked_(); + } + } + // Helper to cleanup defer_queue_ after processing // IMPORTANT: Caller must hold the scheduler lock before calling this function. inline void cleanup_defer_queue_locked_() { diff --git a/esphome/core/template_lambda.h b/esphome/core/template_lambda.h new file mode 100644 index 0000000000..7b8f4374aa --- /dev/null +++ b/esphome/core/template_lambda.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/core/optional.h" + +namespace esphome { + +/** Lightweight wrapper for template platform lambdas (stateless function pointers only). + * + * This optimizes template platforms by storing only a function pointer (4 bytes on ESP32) + * instead of std::function (16-32 bytes). + * + * IMPORTANT: This only supports stateless lambdas (no captures). The set_template() method + * is an internal API used by YAML codegen, not intended for external use. + * + * Lambdas must return optional to support the pattern: + * return {}; // Don't publish a value + * return 42.0; // Publish this value + * + * operator() returns optional, returning nullopt when no lambda is set (nullptr check). + * + * @tparam T The return type (e.g., float for sensor values) + * @tparam Args Optional arguments for the lambda + */ +template class TemplateLambda { + public: + TemplateLambda() : f_(nullptr) {} + + /** Set the lambda function pointer. + * INTERNAL API: Only for use by YAML codegen. + * Only stateless lambdas (no captures) are supported. + */ + void set(optional (*f)(Args...)) { this->f_ = f; } + + /** Check if a lambda is set */ + bool has_value() const { return this->f_ != nullptr; } + + /** Call the lambda, returning nullopt if no lambda is set */ + optional operator()(Args &&...args) { + if (this->f_ == nullptr) + return nullopt; + return this->f_(std::forward(args)...); + } + + /** Alias for operator() for compatibility */ + optional call(Args &&...args) { return (*this)(std::forward(args)...); } + + protected: + optional (*f_)(Args...); // Function pointer (4 bytes on ESP32) +}; + +} // namespace esphome diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 1285ec6448..d30dac4394 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -46,24 +46,18 @@ struct tm ESPTime::to_c_tm() { return c_tm; } -std::string ESPTime::strftime(const std::string &format) { - std::string timestr; - timestr.resize(format.size() * 4); +std::string ESPTime::strftime(const char *format) { struct tm c_tm = this->to_c_tm(); - size_t len = ::strftime(×tr[0], timestr.size(), format.c_str(), &c_tm); - while (len == 0) { - if (timestr.size() >= 128) { - // strftime has failed for reasons unrelated to the size of the buffer - // so return a formatting error - return "ERROR"; - } - timestr.resize(timestr.size() * 2); - len = ::strftime(×tr[0], timestr.size(), format.c_str(), &c_tm); + char buf[128]; + size_t len = ::strftime(buf, sizeof(buf), format, &c_tm); + if (len > 0) { + return std::string(buf, len); } - timestr.resize(len); - return timestr; + return "ERROR"; } +std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); } + bool ESPTime::strptime(const std::string &time_to_parse, ESPTime &esp_time) { uint16_t year; uint8_t month; diff --git a/esphome/core/time.h b/esphome/core/time.h index a53fca2346..ffcfced418 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -44,17 +44,19 @@ struct ESPTime { size_t strftime(char *buffer, size_t buffer_len, const char *format); /** Convert this ESPTime struct to a string as specified by the format argument. - * @see https://www.gnu.org/software/libc/manual/html_node/Formatting-Calendar-Time.html#index-strftime + * @see https://en.cppreference.com/w/c/chrono/strftime * - * @warning This method uses dynamically allocated strings which can cause heap fragmentation with some + * @warning This method returns a dynamically allocated string which can cause heap fragmentation with some * microcontrollers. * - * @warning This method can return "ERROR" when the underlying strftime() call fails, e.g. when the - * format string contains unsupported specifiers or when the format string doesn't produce any - * output. + * @warning This method can return "ERROR" when the underlying strftime() call fails or when the + * output exceeds 128 bytes. */ std::string strftime(const std::string &format); + /// @copydoc strftime(const std::string &format) + std::string strftime(const char *format); + /// Check if this ESPTime is valid (all fields in range and year is greater than 2018) bool is_valid() const { return this->year >= 2019 && this->fields_in_range(); } diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index b2022c7ae6..6f1af01a5b 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -198,6 +198,8 @@ class LambdaExpression(Expression): self.return_type = safe_exp(return_type) if return_type is not None else None def __str__(self): + # Stateless lambdas (empty capture) implicitly convert to function pointers + # when assigned to function pointer types - no unary + needed cpp = f"[{self.capture}]({self.parameters})" if self.return_type is not None: cpp += f" -> {self.return_type}" @@ -348,7 +350,7 @@ def safe_exp(obj: SafeExpType) -> Expression: return IntLiteral(int(obj.total_seconds)) if isinstance(obj, TimePeriodMinutes): return IntLiteral(int(obj.total_minutes)) - if isinstance(obj, tuple | list): + if isinstance(obj, (tuple, list)): return ArrayInitializer(*[safe_exp(o) for o in obj]) if obj is bool: return bool_ @@ -700,6 +702,12 @@ async def process_lambda( parts[i * 3 + 1] = var parts[i * 3 + 2] = "" + # All id() references are global variables in generated C++ code. + # Global variables should not be captured - they're accessible everywhere. + # Use empty capture instead of capture-by-value. + if capture == "=": + capture = "" + if isinstance(value, ESPHomeDataBase) and value.esp_range is not None: location = value.esp_range.start_mark location.line += value.content_offset diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index a0dd62cb4e..0d1813f63b 100644 --- a/esphome/cpp_types.py +++ b/esphome/cpp_types.py @@ -23,6 +23,7 @@ size_t = global_ns.namespace("size_t") const_char_ptr = global_ns.namespace("const char *") NAN = global_ns.namespace("NAN") esphome_ns = global_ns # using namespace esphome; +FixedVector = esphome_ns.class_("FixedVector") App = esphome_ns.App EntityBase = esphome_ns.class_("EntityBase") Component = esphome_ns.class_("Component") diff --git a/esphome/helpers.py b/esphome/helpers.py index fb7b71775d..ea6abff50a 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -224,36 +224,37 @@ def resolve_ip_address( return res # Process hosts - cached_addresses: list[str] = [] + uncached_hosts: list[str] = [] - has_cache = address_cache is not None for h in hosts: if is_ip_address(h): - if has_cache: - # If we have a cache, treat IPs as cached - cached_addresses.append(h) - else: - # If no cache, pass IPs through to resolver with hostnames - uncached_hosts.append(h) + _add_ip_addresses_to_addrinfo([h], port, res) elif address_cache and (cached := address_cache.get_addresses(h)): - # Found in cache - cached_addresses.extend(cached) + _add_ip_addresses_to_addrinfo(cached, port, res) else: # Not cached, need to resolve if address_cache and address_cache.has_cache(): _LOGGER.info("Host %s not in cache, will need to resolve", h) uncached_hosts.append(h) - # Process cached addresses (includes direct IPs and cached lookups) - _add_ip_addresses_to_addrinfo(cached_addresses, port, res) - # If we have uncached hosts (only non-IP hostnames), resolve them if uncached_hosts: + from aioesphomeapi.host_resolver import AddrInfo as AioAddrInfo + + from esphome.core import EsphomeError from esphome.resolver import AsyncResolver resolver = AsyncResolver(uncached_hosts, port) - addr_infos = resolver.resolve() + addr_infos: list[AioAddrInfo] = [] + try: + addr_infos = resolver.resolve() + except EsphomeError as err: + if not res: + # No pre-resolved addresses available, DNS resolution is fatal + raise + _LOGGER.info("%s (using %d already resolved IP addresses)", err, len(res)) + # Convert aioesphomeapi AddrInfo to our format for addr_info in addr_infos: sockaddr = addr_info.sockaddr diff --git a/esphome/mqtt.py b/esphome/mqtt.py index f1c631697a..093ee64df4 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -120,7 +120,7 @@ def prepare( cert_file.flush() key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY)) key_file.flush() - context.load_cert_chain(cert_file, key_file) + context.load_cert_chain(cert_file.name, key_file.name) client.tls_set_context(context) try: diff --git a/pyproject.toml b/pyproject.toml index b7b4a48d7e..d6aa584237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,9 @@ classifiers = [ "Programming Language :: Python :: 3", "Topic :: Home Automation", ] -requires-python = ">=3.11.0" + +# Python 3.14 is currently not supported by IDF <= 5.5.1, see https://github.com/esphome/esphome/issues/11502 +requires-python = ">=3.11.0,<3.14" dynamic = ["dependencies", "optional-dependencies", "version"] @@ -131,7 +133,6 @@ ignore = [ "PLW1641", # Object does not implement `__hash__` method "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target - "UP038", # https://github.com/astral-sh/ruff/issues/7871 https://github.com/astral-sh/ruff/pull/16681 ] [tool.ruff.lint.isort] diff --git a/requirements.txt b/requirements.txt index ec7794c75a..660b18c933 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,16 +12,17 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==42.2.0 +aioesphomeapi==42.5.0 zeroconf==0.148.0 puremagic==1.30 -ruamel.yaml==0.18.15 # dashboard_import +ruamel.yaml==0.18.16 # dashboard_import ruamel.yaml.clib==0.2.14 # dashboard_import esphome-glyphsets==0.2.0 pillow==11.3.0 cairosvg==2.8.2 freetype-py==2.5.1 jinja2==3.1.6 +bleak==1.1.1 # esp-idf >= 5.0 requires this pyparsing >= 3.0 diff --git a/requirements_test.txt b/requirements_test.txt index 5f94329e3f..11367172b1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ 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 +ruff==0.14.3 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.0 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 78f5ca3344..38d1f8c2b7 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -77,6 +77,7 @@ ISOLATED_COMPONENTS = { "esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", + "gps": "TinyGPSPlus library declares millis() function that creates ambiguity with ESPHome millis() macro when merged with components using millis() in lambdas", "lvgl": "Defines multiple SDL displays on host platform that conflict when merged with other display configs", "mapping": "Uses dict format for image/display sections incompatible with standard list format - ESPHome merge_config cannot handle", "openthread": "Conflicts with wifi: used by most components", diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 2f83b0bd79..3b756095a1 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1162,7 +1162,11 @@ class SInt64Type(TypeInfo): def _generate_array_dump_content( - ti, field_name: str, name: str, is_bool: bool = False + ti, + field_name: str, + name: str, + is_bool: bool = False, + is_const_char_ptr: bool = False, ) -> str: """Generate dump content for array types (repeated or fixed array). @@ -1170,7 +1174,10 @@ def _generate_array_dump_content( """ o = f"for (const auto {'' if is_bool else '&'}it : {field_name}) {{\n" # Check if underlying type can use dump_field - if ti.can_use_dump_field(): + if is_const_char_ptr: + # Special case for const char* - use it directly + o += f' dump_field(out, "{name}", it, 4);\n' + elif ti.can_use_dump_field(): # For types that have dump_field overloads, use them with extra indent # std::vector iterators return proxy objects, need explicit cast value_expr = "static_cast(it)" if is_bool else ti.dump_field_value("it") @@ -1533,11 +1540,16 @@ class RepeatedTypeInfo(TypeInfo): def encode_content(self) -> str: if self._use_pointer: # For pointer fields, just dereference (pointer should never be null in our use case) - o = f"for (const auto &it : *this->{self.field_name}) {{\n" - if isinstance(self._ti, EnumType): - o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" + # Special handling for const char* elements (when container_no_template contains "const char") + if "const char" in self._container_no_template: + o = f"for (const char *it : *this->{self.field_name}) {{\n" + o += f" buffer.{self._ti.encode_func}({self.number}, it, strlen(it), true);\n" else: - o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o = f"for (const auto &it : *this->{self.field_name}) {{\n" + if isinstance(self._ti, EnumType): + o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" + else: + o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" o += "}" return o o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" @@ -1550,10 +1562,18 @@ class RepeatedTypeInfo(TypeInfo): @property def dump_content(self) -> str: + # Check if this is const char* elements + is_const_char_ptr = ( + self._use_pointer and "const char" in self._container_no_template + ) if self._use_pointer: # For pointer fields, dereference and use the existing helper return _generate_array_dump_content( - self._ti, f"*this->{self.field_name}", self.name, is_bool=False + self._ti, + f"*this->{self.field_name}", + self.name, + is_bool=False, + is_const_char_ptr=is_const_char_ptr, ) return _generate_array_dump_content( self._ti, f"this->{self.field_name}", self.name, is_bool=self._ti_is_bool @@ -1588,9 +1608,15 @@ class RepeatedTypeInfo(TypeInfo): o += f" size.add_precalculated_size({size_expr} * {bytes_per_element});\n" else: # Other types need the actual value - auto_ref = "" if self._ti_is_bool else "&" - o += f" for (const auto {auto_ref}it : {container_ref}) {{\n" - o += f" {self._ti.get_size_calculation('it', True)}\n" + # Special handling for const char* elements + if self._use_pointer and "const char" in self._container_no_template: + field_id_size = self.calculate_field_id_size() + o += f" for (const char *it : {container_ref}) {{\n" + o += f" size.add_length_force({field_id_size}, strlen(it));\n" + else: + auto_ref = "" if self._ti_is_bool else "&" + o += f" for (const auto {auto_ref}it : {container_ref}) {{\n" + o += f" {self._ti.get_size_calculation('it', True)}\n" o += " }\n" o += "}" @@ -2542,6 +2568,12 @@ static void dump_field(std::string &out, const char *field_name, StringRef value out.append("\\n"); } +static void dump_field(std::string &out, const char *field_name, const char *value, int indent = 2) { + append_field_prefix(out, field_name, indent); + out.append("'").append(value).append("'"); + out.append("\\n"); +} + template static void dump_field(std::string &out, const char *field_name, T value, int indent = 2) { append_field_prefix(out, field_name, indent); diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 1ffe3c2873..c9501cb193 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -300,7 +300,7 @@ def fix_remote_receiver(): remote_receiver_schema["CONFIG_SCHEMA"] = { "type": "schema", "schema": { - "extends": ["binary_sensor.BINARY_SENSOR_SCHEMA", "core.COMPONENT_SCHEMA"], + "extends": ["binary_sensor._BINARY_SENSOR_SCHEMA", "core.COMPONENT_SCHEMA"], "config_vars": output["remote_base"].pop("binary"), }, } diff --git a/script/determine-jobs.py b/script/determine-jobs.py index ac384d74f1..4a0edebb0d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -43,13 +43,14 @@ from enum import StrEnum from functools import cache import json import os +from pathlib import Path import subprocess import sys from typing import Any from helpers import ( - BASE_BUS_COMPONENTS, CPP_FILE_EXTENSIONS, + ESPHOME_TESTS_COMPONENTS_PATH, PYTHON_FILE_EXTENSIONS, changed_files, core_changed, @@ -66,12 +67,17 @@ from helpers import ( parse_test_filename, root_path, ) +from split_components_for_ci import create_intelligent_batches # 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 +# Component test batch size (weighted) +# Isolated components count as 10x, groupable components count as 1x +COMPONENT_TEST_BATCH_SIZE = 40 + class Platform(StrEnum): """Platform identifiers for memory impact analysis.""" @@ -453,7 +459,7 @@ def detect_memory_impact_config( # Get actually changed files (not dependencies) files = changed_files(branch) - # Find all changed components (excluding core and base bus components) + # Find all changed components (excluding core) # Also collect platform hints from platform-specific filenames changed_component_set: set[str] = set() has_core_cpp_changes = False @@ -462,13 +468,13 @@ def detect_memory_impact_config( for file in files: component = get_component_from_path(file) if component: - # Skip base bus components as they're used across many builds - if component not in BASE_BUS_COMPONENTS: - changed_component_set.add(component) - # Check if this is a platform-specific file - platform_hint = _detect_platform_hint_from_filename(file) - if platform_hint: - platform_hints.append(platform_hint) + # Add all changed components, including base bus components + # Base bus components (uart, i2c, spi, etc.) should still be analyzed + # when directly changed, even though they're also used as dependencies + changed_component_set.add(component) + # Check if this is a platform-specific file + if platform_hint := _detect_platform_hint_from_filename(file): + 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 @@ -687,6 +693,22 @@ def main() -> None: # Determine which C++ unit tests to run cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch) + # Split components into batches for CI testing + # This intelligently groups components with similar bus configurations + component_test_batches: list[str] + if changed_components_with_tests: + tests_dir = Path(root_path) / ESPHOME_TESTS_COMPONENTS_PATH + batches, _ = create_intelligent_batches( + components=changed_components_with_tests, + tests_dir=tests_dir, + batch_size=COMPONENT_TEST_BATCH_SIZE, + directly_changed=directly_changed_with_tests, + ) + # Convert batches to space-separated strings for CI matrix + component_test_batches = [" ".join(batch) for batch in batches] + else: + component_test_batches = [] + output: dict[str, Any] = { "integration_tests": run_integration, "clang_tidy": run_clang_tidy, @@ -704,6 +726,7 @@ def main() -> None: "memory_impact": memory_impact, "cpp_unit_tests_run_all": cpp_run_all, "cpp_unit_tests_components": cpp_components, + "component_test_batches": component_test_batches, } # Output as JSON diff --git a/script/helpers.py b/script/helpers.py index 78c11b427e..447d54fa54 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -90,16 +90,18 @@ def get_component_from_path(file_path: str) -> str | None: """Extract component name from a file path. Args: - file_path: Path to a file (e.g., "esphome/components/wifi/wifi.cpp") + file_path: Path to a file (e.g., "esphome/components/wifi/wifi.cpp" + or "tests/components/uart/test.esp32-idf.yaml") Returns: - Component name if path is in components directory, None otherwise + Component name if path is in components or tests directory, None otherwise """ - if not file_path.startswith(ESPHOME_COMPONENTS_PATH): - return None - parts = file_path.split("/") - if len(parts) >= 3: - return parts[2] + if file_path.startswith(ESPHOME_COMPONENTS_PATH) or file_path.startswith( + ESPHOME_TESTS_COMPONENTS_PATH + ): + parts = file_path.split("/") + if len(parts) >= 3 and parts[2]: + return parts[2] return None diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 87da540d43..65d09efb9b 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -62,6 +62,10 @@ def create_intelligent_batches( ) -> tuple[list[list[str]], dict[tuple[str, str], list[str]]]: """Create batches optimized for component grouping. + IMPORTANT: This function is called from both split_components_for_ci.py (standalone script) + and determine-jobs.py (integrated into job determination). Be careful when refactoring + to ensure changes work in both contexts. + Args: components: List of component names to batch tests_dir: Path to tests/components directory diff --git a/tests/component_tests/gpio/test_gpio_binary_sensor.py b/tests/component_tests/gpio/test_gpio_binary_sensor.py index 74fa2ab1c1..73665dc45d 100644 --- a/tests/component_tests/gpio/test_gpio_binary_sensor.py +++ b/tests/component_tests/gpio/test_gpio_binary_sensor.py @@ -18,7 +18,8 @@ def test_gpio_binary_sensor_basic_setup( assert "new gpio::GPIOBinarySensor();" in main_cpp assert "App.register_binary_sensor" in main_cpp - assert "bs_gpio->set_use_interrupt(true);" in main_cpp + # set_use_interrupt(true) should NOT be generated (uses C++ default) + assert "bs_gpio->set_use_interrupt(true);" not in main_cpp assert "bs_gpio->set_interrupt_type(gpio::INTERRUPT_ANY_EDGE);" in main_cpp @@ -51,8 +52,8 @@ def test_gpio_binary_sensor_esp8266_other_pins_use_interrupt( "tests/component_tests/gpio/test_gpio_binary_sensor_esp8266.yaml" ) - # GPIO5 should still use interrupts - assert "bs_gpio5->set_use_interrupt(true);" in main_cpp + # GPIO5 should still use interrupts (default, so no setter call) + assert "bs_gpio5->set_use_interrupt(true);" not in main_cpp assert "bs_gpio5->set_interrupt_type(gpio::INTERRUPT_ANY_EDGE);" in main_cpp diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index d66ca58a69..1c4c91aa52 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.components.packages import do_packages_pass +from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv @@ -94,6 +94,50 @@ def test_package_invalid_dict(basic_esphome, basic_wifi): packages_pass(config) +@pytest.mark.parametrize( + "package", + [ + {"package1": "github://esphome/non-existant-repo/file1.yml@main"}, + {"package2": "github://esphome/non-existant-repo/file1.yml"}, + {"package3": "github://esphome/non-existant-repo/other-folder/file1.yml"}, + [ + "github://esphome/non-existant-repo/file1.yml@main", + "github://esphome/non-existant-repo/file1.yml", + "github://esphome/non-existant-repo/other-folder/file1.yml", + ], + ], +) +def test_package_shorthand(package): + CONFIG_SCHEMA(package) + + +@pytest.mark.parametrize( + "package", + [ + # not github + {"package1": "someplace://esphome/non-existant-repo/file1.yml@main"}, + # missing repo + {"package2": "github://esphome/file1.yml"}, + # missing file + {"package3": "github://esphome/non-existant-repo/@main"}, + {"a": "invalid string, not shorthand"}, + "some string", + 3, + False, + {"a": 8}, + ["someplace://esphome/non-existant-repo/file1.yml@main"], + ["github://esphome/file1.yml"], + ["github://esphome/non-existant-repo/@main"], + ["some string"], + [True], + [3], + ], +) +def test_package_invalid(package): + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(package) + + def test_package_include(basic_wifi, basic_esphome): """ Tests the simple case where an independent config present in a package is added to the top-level config as is. diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 75f1c4b88b..1cc31a288b 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -58,7 +58,7 @@ def test_text_config_value_mode_set(generate_main): def test_text_config_lamda_is_set(generate_main): """ - Test if lambda is set for lambda mode + Test if lambda is set for lambda mode (optimized with stateless lambda) """ # Given @@ -66,5 +66,6 @@ def test_text_config_lamda_is_set(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert "it_4->set_template([=]() -> esphome::optional {" in main_cpp + # Stateless lambda optimization: empty capture list allows function pointer conversion + assert "it_4->set_template([]() -> esphome::optional {" in main_cpp assert 'return std::string{"Hello"};' in main_cpp diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 6483d5a997..c90fa4dfef 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -87,3 +87,99 @@ api: - float_arr.size() - string_arr[0].c_str() - string_arr.size() + # Test ContinuationAction (IfAction with then/else branches) + - action: test_if_action + variables: + condition: bool + value: int + then: + - if: + condition: + lambda: 'return condition;' + then: + - logger.log: + format: "Condition true, value: %d" + args: ['value'] + else: + - logger.log: + format: "Condition false, value: %d" + args: ['value'] + - logger.log: "After if/else" + # Test nested IfAction (multiple ContinuationAction instances) + - action: test_nested_if + variables: + outer: bool + inner: bool + then: + - if: + condition: + lambda: 'return outer;' + then: + - if: + condition: + lambda: 'return inner;' + then: + - logger.log: "Both true" + else: + - logger.log: "Outer true, inner false" + else: + - logger.log: "Outer false" + - logger.log: "After nested if" + # Test WhileLoopContinuation (WhileAction) + - action: test_while_action + variables: + max_count: int + then: + - lambda: 'id(api_continuation_test_counter) = 0;' + - while: + condition: + lambda: 'return id(api_continuation_test_counter) < max_count;' + then: + - logger.log: + format: "While loop iteration: %d" + args: ['id(api_continuation_test_counter)'] + - lambda: 'id(api_continuation_test_counter)++;' + - logger.log: "After while loop" + # Test RepeatLoopContinuation (RepeatAction) + - action: test_repeat_action + variables: + count: int + then: + - repeat: + count: !lambda 'return count;' + then: + - logger.log: + format: "Repeat iteration: %d" + args: ['iteration'] + - logger.log: "After repeat" + # Test combined continuations (if + while + repeat) + - action: test_combined_continuations + variables: + do_loop: bool + loop_count: int + then: + - if: + condition: + lambda: 'return do_loop;' + then: + - repeat: + count: !lambda 'return loop_count;' + then: + - lambda: 'id(api_continuation_test_counter) = iteration;' + - while: + condition: + lambda: 'return id(api_continuation_test_counter) > 0;' + then: + - logger.log: + format: "Combined: repeat=%d, while=%d" + args: ['iteration', 'id(api_continuation_test_counter)'] + - lambda: 'id(api_continuation_test_counter)--;' + else: + - logger.log: "Skipped loops" + - logger.log: "After combined test" + +globals: + - id: api_continuation_test_counter + type: int + restore_value: false + initial_value: '0' diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index b5272d01f0..aa4b639463 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -3,3 +3,52 @@ esp32_ble_tracker: ble_client: - mac_address: 01:02:03:04:05:06 id: test_blec + on_connect: + - ble_client.ble_write: + id: test_blec + service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" + characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" + value: !lambda |- + return std::vector{0x01, 0x02, 0x03}; + - ble_client.ble_write: + id: test_blec + service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" + characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" + value: [0x04, 0x05, 0x06] + on_passkey_request: + - ble_client.passkey_reply: + id: test_blec + passkey: !lambda |- + return 123456; + - ble_client.passkey_reply: + id: test_blec + passkey: 654321 + on_numeric_comparison_request: + - ble_client.numeric_comparison_reply: + id: test_blec + accept: !lambda |- + return true; + - ble_client.numeric_comparison_reply: + id: test_blec + accept: false + +sensor: + - platform: ble_client + ble_client_id: test_blec + type: characteristic + id: test_sensor_lambda + name: "BLE Sensor with Lambda" + service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" + characteristic_uuid: "abcd1236-abcd-1234-abcd-abcd12345678" + lambda: |- + if (x.size() >= 2) { + return (float)(x[0] | (x[1] << 8)) / 100.0; + } + return NAN; + - platform: ble_client + ble_client_id: test_blec + type: characteristic + id: test_sensor_no_lambda + name: "BLE Sensor without Lambda" + service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" + characteristic_uuid: "abcd1237-abcd-1234-abcd-abcd12345678" diff --git a/tests/components/hdc2010/common.yaml b/tests/components/hdc2010/common.yaml new file mode 100644 index 0000000000..a22b3f15ce --- /dev/null +++ b/tests/components/hdc2010/common.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: hdc2010 + i2c_id: i2c_bus + temperature: + name: Temperature + humidity: + name: Humidity diff --git a/tests/components/hdc2010/test.esp32-c3-idf.yaml b/tests/components/hdc2010/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..9990d96d29 --- /dev/null +++ b/tests/components/hdc2010/test.esp32-c3-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-c3-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hdc2010/test.esp32-idf.yaml b/tests/components/hdc2010/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/hdc2010/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hdc2010/test.esp8266-ard.yaml b/tests/components/hdc2010/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/hdc2010/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hdc2010/test.rp2040-ard.yaml b/tests/components/hdc2010/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/hdc2010/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/http_request/common.yaml b/tests/components/http_request/common.yaml index 9ff9f9fb67..62d0a7941a 100644 --- a/tests/components/http_request/common.yaml +++ b/tests/components/http_request/common.yaml @@ -4,51 +4,6 @@ wifi: ssid: MySSID password: password1 -esphome: - on_boot: - then: - - http_request.get: - url: https://esphome.io - request_headers: - Content-Type: application/json - collect_headers: - - age - on_error: - logger.log: "Request failed" - on_response: - then: - - logger.log: - format: "Response status: %d, Duration: %lu ms, age: %s" - args: - - response->status_code - - (long) response->duration_ms - - response->get_response_header("age").c_str() - - http_request.post: - url: https://esphome.io - request_headers: - Content-Type: application/json - json: - key: value - - http_request.send: - method: PUT - url: https://esphome.io - request_headers: - Content-Type: application/json - body: "Some data" - -http_request: - useragent: esphome/tagreader - timeout: 10s - verify_ssl: ${verify_ssl} - -script: - - id: does_not_compile - parameters: - api_url: string - then: - - http_request.get: - url: "http://google.com" - ota: - platform: http_request id: http_request_ota diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index ea7f6bf5a7..13ca5ceba0 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -31,6 +31,20 @@ esphome: request_headers: Content-Type: application/json body: "Some data" + - http_request.post: + url: https://esphome.io + request_headers: + Content-Type: application/json + json: + key: value + capture_response: true + on_response: + then: + - logger.log: + format: "Captured response status: %d, Body: %s" + args: + - response->status_code + - body.c_str() http_request: useragent: esphome/tagreader diff --git a/tests/components/improv_base/common-uart0.yaml b/tests/components/improv_base/common-uart0.yaml new file mode 100644 index 0000000000..7b7730fd46 --- /dev/null +++ b/tests/components/improv_base/common-uart0.yaml @@ -0,0 +1,8 @@ +wifi: + ssid: MySSID + password: password1 + +logger: + hardware_uart: UART0 + +improv_serial: diff --git a/tests/components/improv_base/test-uart0.esp8266-ard.yaml b/tests/components/improv_base/test-uart0.esp8266-ard.yaml new file mode 100644 index 0000000000..ef8c799241 --- /dev/null +++ b/tests/components/improv_base/test-uart0.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common-uart0.yaml diff --git a/tests/components/json/common.yaml b/tests/components/json/common.yaml index f4074e1172..c36c7f2a5a 100644 --- a/tests/components/json/common.yaml +++ b/tests/components/json/common.yaml @@ -14,12 +14,14 @@ interval: // Test parse_json bool parse_ok = esphome::json::parse_json(json_str, [](JsonObject root) { - if (root.containsKey("sensor") && root.containsKey("value")) { + if (root["sensor"].is() && root["value"].is()) { const char* sensor = root["sensor"]; float value = root["value"]; ESP_LOGD("test", "Parsed: sensor=%s, value=%.1f", sensor, value); + return true; } else { ESP_LOGD("test", "Parsed JSON missing required keys"); + return false; } }); ESP_LOGD("test", "Parse result (JSON syntax only): %s", parse_ok ? "success" : "failed"); diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index d9b7013a1e..c70dd7568d 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -52,6 +52,19 @@ number: widget: spinbox_id id: lvgl_spinbox_number name: LVGL Spinbox Number + - platform: template + id: test_brightness + name: "Test Brightness" + min_value: 0 + max_value: 255 + step: 1 + optimistic: true + # Test lambda in automation accessing x parameter directly + # This is a real-world pattern from user configs + on_value: + - lambda: !lambda |- + // Direct use of x parameter in automation + ESP_LOGD("test", "Brightness: %.0f", x); light: - platform: lvgl @@ -110,3 +123,21 @@ text: platform: lvgl widget: hello_label mode: text + +text_sensor: + - platform: template + id: test_text_sensor + name: "Test Text Sensor" + # Test nested lambdas in LVGL actions can access automation parameters + on_value: + - lvgl.label.update: + id: hello_label + text: !lambda return x.c_str(); + - lvgl.label.update: + id: hello_label + text: !lambda |- + // Test complex lambda with conditionals accessing x parameter + if (x == "*") { + return "WILDCARD"; + } + return x.c_str(); diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 582531e943..14241a1669 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -257,7 +257,30 @@ lvgl: text: "Hello shiny day" text_color: 0xFFFFFF align: bottom_mid + - label: + id: setup_lambda_label + # Test lambda in widget property during setup (LvContext) + # Should NOT receive lv_component parameter + text: !lambda |- + char buf[32]; + snprintf(buf, sizeof(buf), "Setup: %d", 42); + return std::string(buf); + align: top_mid text_font: space16 + - label: + id: chip_info_label + # Test complex setup lambda (real-world pattern) + # Should NOT receive lv_component parameter + text: !lambda |- + // Test conditional compilation and string formatting + char buf[64]; + #ifdef USE_ESP_IDF + snprintf(buf, sizeof(buf), "IDF: v%d.%d", ESP_IDF_VERSION_MAJOR, ESP_IDF_VERSION_MINOR); + #else + snprintf(buf, sizeof(buf), "Arduino"); + #endif + return std::string(buf); + align: top_left - obj: align: center arc_opa: COVER diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index 6170b0f4fb..2450d28eb8 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -68,5 +68,13 @@ lvgl: enter_button: pushbutton group: general initial_focus: lv_roller + on_draw_start: + - logger.log: draw started + on_draw_end: + - logger.log: draw ended + - lvgl.pause: + - component.update: tft_display + - delay: 60s + - lvgl.resume: <<: !include common.yaml diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index ae5520e57d..ffaa1491c5 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -56,6 +56,14 @@ binary_sensor: register_type: read address: 0x3200 bitmask: 0x80 + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_binary_sensor2 + name: Test Binary Sensor with Lambda + register_type: read + address: 0x3201 + lambda: |- + return x; number: - platform: modbus_controller @@ -65,6 +73,16 @@ number: address: 0x9001 value_type: U_WORD multiply: 1.0 + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_number2 + name: Test Number with Lambda + address: 0x9002 + value_type: U_WORD + lambda: |- + return x * 2.0; + write_lambda: |- + return x / 2.0; output: - platform: modbus_controller @@ -74,6 +92,14 @@ output: register_type: holding value_type: U_WORD multiply: 1000 + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_output2 + address: 2049 + register_type: holding + value_type: U_WORD + write_lambda: |- + return x * 100.0; select: - platform: modbus_controller @@ -87,6 +113,34 @@ select: "One": 1 "Two": 2 "Three": 3 + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_select2 + name: Test Select with Lambda + address: 1001 + value_type: U_WORD + optionsmap: + "Off": 0 + "On": 1 + "Two": 2 + lambda: |- + ESP_LOGD("Reg1001", "Received value %lld", x); + if (x > 1) { + return std::string("Two"); + } else if (x == 1) { + return std::string("On"); + } + return std::string("Off"); + write_lambda: |- + ESP_LOGD("Reg1001", "Set option to %s (%lld)", x.c_str(), value); + if (x == "On") { + return 1; + } + if (x == "Two") { + payload.push_back(0x0002); + return 0; + } + return value; sensor: - platform: modbus_controller @@ -97,6 +151,15 @@ sensor: address: 0x9001 unit_of_measurement: "AH" value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor2 + name: Test Sensor with Lambda + register_type: holding + address: 0x9002 + value_type: U_WORD + lambda: |- + return x / 10.0; switch: - platform: modbus_controller @@ -106,6 +169,16 @@ switch: register_type: coil address: 0x15 bitmask: 1 + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_switch2 + name: Test Switch with Lambda + register_type: coil + address: 0x16 + lambda: |- + return !x; + write_lambda: |- + return !x; text_sensor: - platform: modbus_controller @@ -117,3 +190,13 @@ text_sensor: register_count: 3 raw_encode: HEXBYTES response_size: 6 + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_text_sensor2 + name: Test Text Sensor with Lambda + register_type: holding + address: 0x9014 + register_count: 2 + response_size: 4 + lambda: |- + return "Modified: " + x; diff --git a/tests/components/network/test-ipv6.host.yaml b/tests/components/network/test-ipv6.host.yaml new file mode 100644 index 0000000000..d9eeab89ea --- /dev/null +++ b/tests/components/network/test-ipv6.host.yaml @@ -0,0 +1,2 @@ +network: + enable_ipv6: true diff --git a/tests/components/nrf52/test-bootloader.nrf52-xiao-ble.yaml b/tests/components/nrf52/test-bootloader.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..022ab9c753 --- /dev/null +++ b/tests/components/nrf52/test-bootloader.nrf52-xiao-ble.yaml @@ -0,0 +1,3 @@ +nrf52: + # it is not correct bootloader for the board + bootloader: adafruit_nrf52_sd140_v6 diff --git a/tests/components/nrf52/test.nrf52-mcumgr.yaml b/tests/components/nrf52/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index c2dc2f0c29..de01fa3602 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -48,6 +48,11 @@ on_drayton: - logger.log: format: "on_drayton: %u %u %u" args: ["x.address", "x.channel", "x.command"] +on_dyson: + then: + - logger.log: + format: "on_dyson: %u %u" + args: ["x.code", "x.index"] on_gobox: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 58127d1ab4..e9593cc97c 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -6,6 +6,13 @@ button: remote_transmitter.transmit_beo4: source: 0x01 command: 0x0C + - platform: template + name: Dyson fan up + id: dyson_fan_up + on_press: + remote_transmitter.transmit_dyson: + code: 0x1215 + index: 0x0 - platform: template name: JVC Off id: living_room_lights_on diff --git a/tests/components/remote_transmitter/esp32-common.yaml b/tests/components/remote_transmitter/esp32-common.yaml index 8b26c45149..79fd47ae21 100644 --- a/tests/components/remote_transmitter/esp32-common.yaml +++ b/tests/components/remote_transmitter/esp32-common.yaml @@ -2,6 +2,7 @@ remote_transmitter: - id: xmitr pin: ${pin} carrier_duty_percent: 50% + non_blocking: true clock_resolution: ${clock_resolution} rmt_symbols: ${rmt_symbols} diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index 50fa4a5990..52991d595c 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -14,10 +14,10 @@ display: binary_sensor: - platform: sdl id: key_up - key: SDLK_a + key: SDLK_UP - platform: sdl id: key_down - key: SDLK_d + key: SDLK_DOWN - platform: sdl id: key_enter - key: SDLK_s + key: SDLK_RETURN diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index b873af5207..f101eea942 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -9,6 +9,13 @@ esphome: id: template_sens state: !lambda "return 42.0;" + # Test C++ API: set_template() with stateless lambda (no captures) + # NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break. + - lambda: |- + id(template_sens).set_template([]() -> esphome::optional { + return 123.0f; + }); + - datetime.date.set: id: test_date date: @@ -215,6 +222,7 @@ cover: number: - platform: template + id: template_number name: "Template number" optimistic: true min_value: 0 diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index 5634c5c6f6..9744a48409 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -19,3 +19,41 @@ uart: packet_transport: - platform: uart + +switch: + # Test uart switch with single state (array) + - platform: uart + name: "UART Switch Single Array" + uart_id: uart_uart + data: [0x01, 0x02, 0x03] + # Test uart switch with single state (string) + - platform: uart + name: "UART Switch Single String" + uart_id: uart_uart + data: "ON" + # Test uart switch with turn_on/turn_off (arrays) + - platform: uart + name: "UART Switch Dual Array" + uart_id: uart_uart + data: + turn_on: [0xA0, 0xA1, 0xA2] + turn_off: [0xB0, 0xB1, 0xB2] + # Test uart switch with turn_on/turn_off (strings) + - platform: uart + name: "UART Switch Dual String" + uart_id: uart_uart + data: + turn_on: "TURN_ON" + turn_off: "TURN_OFF" + +button: + # Test uart button with array data + - platform: uart + name: "UART Button Array" + uart_id: uart_uart + data: [0xFF, 0xEE, 0xDD] + # Test uart button with string data + - platform: uart + name: "UART Button String" + uart_id: uart_uart + data: "BUTTON_PRESS" diff --git a/tests/components/uart/test.esp8266-ard.yaml b/tests/components/uart/test.esp8266-ard.yaml index 09178f1663..566038ee3e 100644 --- a/tests/components/uart/test.esp8266-ard.yaml +++ b/tests/components/uart/test.esp8266-ard.yaml @@ -13,3 +13,21 @@ uart: rx_buffer_size: 512 parity: EVEN stop_bits: 2 + +switch: + - platform: uart + name: "UART Switch Array" + uart_id: uart_uart + data: [0x01, 0x02, 0x03] + - platform: uart + name: "UART Switch Dual" + uart_id: uart_uart + data: + turn_on: [0xA0, 0xA1] + turn_off: [0xB0, 0xB1] + +button: + - platform: uart + name: "UART Button" + uart_id: uart_uart + data: [0xFF, 0xEE] diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 46ad6291f9..474c3f5c8d 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -1,3 +1,6 @@ +usb_host: + max_transfer_requests: 32 + usb_uart: - id: uart_0 type: cdc_acm diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml new file mode 100644 index 0000000000..b1885af266 --- /dev/null +++ b/tests/components/web_server_idf/common.yaml @@ -0,0 +1,29 @@ +esphome: + name: test-web-server-idf + +esp32: + board: esp32dev + framework: + type: esp-idf + +network: + +# Add some entities to test SSE event formatting +sensor: + - platform: template + name: "Test Sensor" + id: test_sensor + update_interval: 60s + lambda: "return 42.5;" + +binary_sensor: + - platform: template + name: "Test Binary Sensor" + id: test_binary_sensor + lambda: "return true;" + +switch: + - platform: template + name: "Test Switch" + id: test_switch + optimistic: true diff --git a/tests/components/web_server_idf/test.esp32-idf.yaml b/tests/components/web_server_idf/test.esp32-idf.yaml new file mode 100644 index 0000000000..c3b85178ef --- /dev/null +++ b/tests/components/web_server_idf/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +<<: !include common.yaml + +web_server: diff --git a/tests/integration/fixtures/action_concurrent_reentry.yaml b/tests/integration/fixtures/action_concurrent_reentry.yaml new file mode 100644 index 0000000000..68d36d1510 --- /dev/null +++ b/tests/integration/fixtures/action_concurrent_reentry.yaml @@ -0,0 +1,105 @@ +esphome: + name: action-concurrent-reentry + on_boot: + - priority: -100 + then: + - repeat: + count: 5 + then: + - lambda: id(handler_wait_until)->execute(id(global_counter)); + - lambda: id(handler_repeat)->execute(id(global_counter)); + - lambda: id(handler_while)->execute(id(global_counter)); + - lambda: id(handler_script_wait)->execute(id(global_counter)); + - delay: 50ms + - lambda: id(global_counter)++; + - delay: 50ms + +host: + +api: + +globals: + - id: global_counter + type: int + +script: + - id: handler_wait_until + + mode: parallel + + parameters: + arg: int + + then: + - wait_until: + condition: + lambda: return id(global_counter) == 5; + + - logger.log: + format: "AFTER wait_until ARG %d" + args: + - arg + + - id: handler_script_wait + + mode: parallel + + parameters: + arg: int + + then: + - script.wait: handler_wait_until + + - logger.log: + format: "AFTER script.wait ARG %d" + args: + - arg + + - id: handler_repeat + + mode: parallel + + parameters: + arg: int + + then: + - repeat: + count: 3 + then: + - logger.log: + format: "IN repeat %d ARG %d" + args: + - iteration + - arg + - delay: 100ms + + - logger.log: + format: "AFTER repeat ARG %d" + args: + - arg + + - id: handler_while + + mode: parallel + + parameters: + arg: int + + then: + - while: + condition: + lambda: return id(global_counter) != 5; + then: + - logger.log: + format: "IN while ARG %d" + args: + - arg + - delay: 100ms + + - logger.log: + format: "AFTER while ARG %d" + args: + - arg + +logger: + level: DEBUG diff --git a/tests/integration/fixtures/automation_wait_actions.yaml b/tests/integration/fixtures/automation_wait_actions.yaml new file mode 100644 index 0000000000..65a61be14f --- /dev/null +++ b/tests/integration/fixtures/automation_wait_actions.yaml @@ -0,0 +1,130 @@ +esphome: + name: test-automation-wait-actions + +host: + +api: + actions: + # Test 1: Trigger wait_until automation 5 times rapidly + - action: test_wait_until + then: + - logger.log: "=== TEST 1: Triggering wait_until automation 5 times ===" + # Publish 5 different values to trigger the on_value automation 5 times + - sensor.template.publish: + id: wait_until_sensor + state: 1 + - sensor.template.publish: + id: wait_until_sensor + state: 2 + - sensor.template.publish: + id: wait_until_sensor + state: 3 + - sensor.template.publish: + id: wait_until_sensor + state: 4 + - sensor.template.publish: + id: wait_until_sensor + state: 5 + # Wait then satisfy the condition so all 5 waiting actions complete + - delay: 100ms + - globals.set: + id: test_flag + value: 'true' + + # Test 2: Trigger script.wait automation 5 times rapidly + - action: test_script_wait + then: + - logger.log: "=== TEST 2: Triggering script.wait automation 5 times ===" + # Start a long-running script + - script.execute: blocking_script + # Publish 5 different values to trigger the on_value automation 5 times + - sensor.template.publish: + id: script_wait_sensor + state: 1 + - sensor.template.publish: + id: script_wait_sensor + state: 2 + - sensor.template.publish: + id: script_wait_sensor + state: 3 + - sensor.template.publish: + id: script_wait_sensor + state: 4 + - sensor.template.publish: + id: script_wait_sensor + state: 5 + + # Test 3: Trigger wait_until timeout automation 5 times rapidly + - action: test_wait_timeout + then: + - logger.log: "=== TEST 3: Triggering timeout automation 5 times ===" + # Publish 5 different values (condition will never be true, all will timeout) + - sensor.template.publish: + id: timeout_sensor + state: 1 + - sensor.template.publish: + id: timeout_sensor + state: 2 + - sensor.template.publish: + id: timeout_sensor + state: 3 + - sensor.template.publish: + id: timeout_sensor + state: 4 + - sensor.template.publish: + id: timeout_sensor + state: 5 + +logger: + level: DEBUG + +globals: + - id: test_flag + type: bool + restore_value: false + initial_value: 'false' + + - id: timeout_flag + type: bool + restore_value: false + initial_value: 'false' + +# Sensors with wait_until/script.wait in their on_value automations +sensor: + # Test 1: on_value automation with wait_until + - platform: template + id: wait_until_sensor + on_value: + # This wait_until will be hit 5 times before any complete + - wait_until: + condition: + lambda: return id(test_flag); + - logger.log: "wait_until automation completed" + + # Test 2: on_value automation with script.wait + - platform: template + id: script_wait_sensor + on_value: + # This script.wait will be hit 5 times before any complete + - script.wait: blocking_script + - logger.log: "script.wait automation completed" + + # Test 3: on_value automation with wait_until timeout + - platform: template + id: timeout_sensor + on_value: + # This wait_until will be hit 5 times, all will timeout + - wait_until: + condition: + lambda: return id(timeout_flag); + timeout: 200ms + - logger.log: "timeout automation completed" + +script: + # Blocking script for script.wait test + - id: blocking_script + mode: single + then: + - logger.log: "Blocking script: START" + - delay: 200ms + - logger.log: "Blocking script: END" diff --git a/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml b/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml new file mode 100644 index 0000000000..bf4ef9eafd --- /dev/null +++ b/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml @@ -0,0 +1,40 @@ +esphome: + name: climate-custom-modes-test +host: +api: +logger: + +sensor: + - platform: template + id: thermostat_sensor + lambda: "return 22.0;" + +climate: + - platform: thermostat + id: test_thermostat + name: Test Thermostat Custom Modes + sensor: thermostat_sensor + preset: + - name: Away + default_target_temperature_low: 16°C + default_target_temperature_high: 20°C + - name: Eco Plus + default_target_temperature_low: 18°C + default_target_temperature_high: 22°C + - name: Super Saver + default_target_temperature_low: 20°C + default_target_temperature_high: 24°C + - name: Vacation Mode + default_target_temperature_low: 15°C + default_target_temperature_high: 18°C + idle_action: + - logger.log: idle_action + cool_action: + - logger.log: cool_action + heat_action: + - logger.log: heat_action + min_cooling_off_time: 10s + min_cooling_run_time: 10s + min_heating_off_time: 10s + min_heating_run_time: 10s + min_idle_time: 10s diff --git a/tests/integration/fixtures/continuation_actions.yaml b/tests/integration/fixtures/continuation_actions.yaml new file mode 100644 index 0000000000..bdfe149cb7 --- /dev/null +++ b/tests/integration/fixtures/continuation_actions.yaml @@ -0,0 +1,174 @@ +esphome: + name: test-continuation-actions + +host: + +api: + actions: + # Test 1: IfAction with ContinuationAction (then/else branches) + - action: test_if_action + variables: + condition: bool + value: int + then: + - logger.log: + format: "Test if: condition=%s, value=%d" + args: ['YESNO(condition)', 'value'] + - if: + condition: + lambda: 'return condition;' + then: + - logger.log: + format: "if-then executed: value=%d" + args: ['value'] + else: + - logger.log: + format: "if-else executed: value=%d" + args: ['value'] + - logger.log: "if completed" + + # Test 2: Nested IfAction (multiple ContinuationAction instances) + - action: test_nested_if + variables: + outer: bool + inner: bool + then: + - logger.log: + format: "Test nested if: outer=%s, inner=%s" + args: ['YESNO(outer)', 'YESNO(inner)'] + - if: + condition: + lambda: 'return outer;' + then: + - if: + condition: + lambda: 'return inner;' + then: + - logger.log: "nested-both-true" + else: + - logger.log: "nested-outer-true-inner-false" + else: + - logger.log: "nested-outer-false" + - logger.log: "nested if completed" + + # Test 3: WhileAction with WhileLoopContinuation + - action: test_while_action + variables: + max_count: int + then: + - logger.log: + format: "Test while: max_count=%d" + args: ['max_count'] + - globals.set: + id: continuation_test_counter + value: !lambda 'return 0;' + - while: + condition: + lambda: 'return id(continuation_test_counter) < max_count;' + then: + - logger.log: + format: "while-iteration-%d" + args: ['id(continuation_test_counter)'] + - globals.set: + id: continuation_test_counter + value: !lambda 'return id(continuation_test_counter) + 1;' + - logger.log: "while completed" + + # Test 4: RepeatAction with RepeatLoopContinuation + - action: test_repeat_action + variables: + count: int + then: + - logger.log: + format: "Test repeat: count=%d" + args: ['count'] + - repeat: + count: !lambda 'return count;' + then: + - logger.log: + format: "repeat-iteration-%d" + args: ['iteration'] + - logger.log: "repeat completed" + + # Test 5: Combined continuations (if + while + repeat) + - action: test_combined + variables: + do_loop: bool + loop_count: int + then: + - logger.log: + format: "Test combined: do_loop=%s, loop_count=%d" + args: ['YESNO(do_loop)', 'loop_count'] + - if: + condition: + lambda: 'return do_loop;' + then: + - repeat: + count: !lambda 'return loop_count;' + then: + - globals.set: + id: continuation_test_counter + value: !lambda 'return iteration;' + - while: + condition: + lambda: 'return id(continuation_test_counter) > 0;' + then: + - logger.log: + format: "combined-repeat%d-while%d" + args: ['iteration', 'id(continuation_test_counter)'] + - globals.set: + id: continuation_test_counter + value: !lambda 'return id(continuation_test_counter) - 1;' + else: + - logger.log: "combined-skipped" + - logger.log: "combined completed" + + # Test 6: Rapid triggers to verify memory efficiency + - action: test_rapid_if + then: + - logger.log: "=== Rapid if test start ===" + - sensor.template.publish: + id: rapid_sensor + state: 1 + - sensor.template.publish: + id: rapid_sensor + state: 2 + - sensor.template.publish: + id: rapid_sensor + state: 3 + - sensor.template.publish: + id: rapid_sensor + state: 4 + - sensor.template.publish: + id: rapid_sensor + state: 5 + - logger.log: "=== Rapid if test published 5 values ===" + +logger: + level: DEBUG + +globals: + - id: continuation_test_counter + type: int + restore_value: false + initial_value: '0' + +# Sensor to test rapid automation triggers with if/else (ContinuationAction) +sensor: + - platform: template + id: rapid_sensor + on_value: + - if: + condition: + lambda: 'return x > 2;' + then: + - logger.log: + format: "rapid-if-then: value=%d" + args: ['(int)x'] + else: + - logger.log: + format: "rapid-if-else: value=%d" + args: ['(int)x'] + - logger.log: + format: "rapid-if-completed: value=%d" + args: ['(int)x'] diff --git a/tests/integration/fixtures/host_mode_empty_string_options.yaml b/tests/integration/fixtures/host_mode_empty_string_options.yaml index ab8e6cd005..a170511c46 100644 --- a/tests/integration/fixtures/host_mode_empty_string_options.yaml +++ b/tests/integration/fixtures/host_mode_empty_string_options.yaml @@ -41,6 +41,17 @@ select: - "" # Empty string at the end initial_option: "Choice X" + - platform: template + name: "Select Initial Option Test" + id: select_initial_option_test + optimistic: true + options: + - "First" + - "Second" + - "Third" + - "Fourth" + initial_option: "Third" # Test non-default initial option + # Add a sensor to ensure we have other entities in the list sensor: - platform: template diff --git a/tests/integration/fixtures/runtime_stats.yaml b/tests/integration/fixtures/runtime_stats.yaml index aad1c275fb..fd34cdb939 100644 --- a/tests/integration/fixtures/runtime_stats.yaml +++ b/tests/integration/fixtures/runtime_stats.yaml @@ -32,6 +32,7 @@ switch: name: "Test Switch" id: test_switch optimistic: true + lambda: return false; interval: - interval: 0.5s diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 58d6d2790f..6434a41ddf 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -44,6 +44,7 @@ class InitialStateHelper: helper = InitialStateHelper(entities) client.subscribe_states(helper.on_state_wrapper(user_callback)) await helper.wait_for_initial_states() + # Access initial states via helper.initial_states[key] """ def __init__(self, entities: list[EntityInfo]) -> None: @@ -63,6 +64,8 @@ class InitialStateHelper: self._entities_by_id = { (entity.device_id, entity.key): entity for entity in entities } + # Store initial states by key for test access + self.initial_states: dict[int, EntityState] = {} # Log all entities _LOGGER.debug( @@ -127,6 +130,9 @@ class InitialStateHelper: # If this entity is waiting for initial state if entity_id in self._wait_initial_states: + # Store the initial state for test access + self.initial_states[state.key] = state + # Remove from waiting set self._wait_initial_states.discard(entity_id) diff --git a/tests/integration/test_action_concurrent_reentry.py b/tests/integration/test_action_concurrent_reentry.py new file mode 100644 index 0000000000..aa5801ca2b --- /dev/null +++ b/tests/integration/test_action_concurrent_reentry.py @@ -0,0 +1,92 @@ +"""Integration test for API conditional memory optimization with triggers and services.""" + +from __future__ import annotations + +import asyncio +import collections +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_action_concurrent_reentry( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """ + This test runs a script in parallel with varying arguments and verifies if + each script keeps its original argument throughout its execution + """ + test_complete = asyncio.Event() + expected = {0, 1, 2, 3, 4} + + # Patterns to match in logs + after_wait_until_pattern = re.compile(r"AFTER wait_until ARG (\d+)") + after_script_wait_pattern = re.compile(r"AFTER script\.wait ARG (\d+)") + after_repeat_pattern = re.compile(r"AFTER repeat ARG (\d+)") + in_repeat_pattern = re.compile(r"IN repeat (\d+) ARG (\d+)") + after_while_pattern = re.compile(r"AFTER while ARG (\d+)") + in_while_pattern = re.compile(r"IN while ARG (\d+)") + + after_wait_until_args = [] + after_script_wait_args = [] + after_while_args = [] + in_while_args = [] + after_repeat_args = [] + in_repeat_args = collections.defaultdict(list) + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if test_complete.is_set(): + return + + if mo := after_wait_until_pattern.search(line): + after_wait_until_args.append(int(mo.group(1))) + elif mo := after_script_wait_pattern.search(line): + after_script_wait_args.append(int(mo.group(1))) + elif mo := in_while_pattern.search(line): + in_while_args.append(int(mo.group(1))) + elif mo := after_while_pattern.search(line): + after_while_args.append(int(mo.group(1))) + elif mo := in_repeat_pattern.search(line): + in_repeat_args[int(mo.group(1))].append(int(mo.group(2))) + elif mo := after_repeat_pattern.search(line): + after_repeat_args.append(int(mo.group(1))) + if len(after_repeat_args) == len(expected): + test_complete.set() + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "action-concurrent-reentry" + + # Wait for tests to complete with timeout + try: + await asyncio.wait_for(test_complete.wait(), timeout=8.0) + except TimeoutError: + pytest.fail("test timed out") + + # order may change, but all args must be present + for args in in_repeat_args.values(): + assert set(args) == expected + assert set(in_repeat_args.keys()) == {0, 1, 2} + assert set(after_wait_until_args) == expected, after_wait_until_args + assert set(after_script_wait_args) == expected, after_script_wait_args + assert set(after_repeat_args) == expected, after_repeat_args + assert set(after_while_args) == expected, after_while_args + assert dict(collections.Counter(in_while_args)) == { + 0: 5, + 1: 4, + 2: 3, + 3: 2, + 4: 1, + }, in_while_args diff --git a/tests/integration/test_automation_wait_actions.py b/tests/integration/test_automation_wait_actions.py new file mode 100644 index 0000000000..adcb8ba487 --- /dev/null +++ b/tests/integration/test_automation_wait_actions.py @@ -0,0 +1,104 @@ +"""Test concurrent execution of wait_until and script.wait in direct automation actions.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_automation_wait_actions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """ + Test that wait_until and script.wait correctly handle concurrent executions + when automation actions (not scripts) are triggered multiple times rapidly. + + This tests sensor.on_value automations being triggered 5 times before any complete. + """ + loop = asyncio.get_running_loop() + + # Track completion counts + test_results = { + "wait_until": 0, + "script_wait": 0, + "wait_until_timeout": 0, + } + + # Patterns for log messages + wait_until_complete = re.compile(r"wait_until automation completed") + script_wait_complete = re.compile(r"script\.wait automation completed") + timeout_complete = re.compile(r"timeout automation completed") + + # Test completion futures + test1_complete = loop.create_future() + test2_complete = loop.create_future() + test3_complete = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for completion messages.""" + # Test 1: wait_until concurrent execution + if wait_until_complete.search(line): + test_results["wait_until"] += 1 + if test_results["wait_until"] == 5 and not test1_complete.done(): + test1_complete.set_result(True) + + # Test 2: script.wait concurrent execution + if script_wait_complete.search(line): + test_results["script_wait"] += 1 + if test_results["script_wait"] == 5 and not test2_complete.done(): + test2_complete.set_result(True) + + # Test 3: wait_until with timeout + if timeout_complete.search(line): + test_results["wait_until_timeout"] += 1 + if test_results["wait_until_timeout"] == 5 and not test3_complete.done(): + test3_complete.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get services + _, services = await client.list_entities_services() + + # Test 1: wait_until in automation - trigger 5 times rapidly + test_service = next((s for s in services if s.name == "test_wait_until"), None) + assert test_service is not None, "test_wait_until service not found" + client.execute_service(test_service, {}) + await asyncio.wait_for(test1_complete, timeout=3.0) + + # Verify Test 1: All 5 triggers should complete + assert test_results["wait_until"] == 5, ( + f"Test 1: Expected 5 wait_until completions, got {test_results['wait_until']}" + ) + + # Test 2: script.wait in automation - trigger 5 times rapidly + test_service = next((s for s in services if s.name == "test_script_wait"), None) + assert test_service is not None, "test_script_wait service not found" + client.execute_service(test_service, {}) + await asyncio.wait_for(test2_complete, timeout=3.0) + + # Verify Test 2: All 5 triggers should complete + assert test_results["script_wait"] == 5, ( + f"Test 2: Expected 5 script.wait completions, got {test_results['script_wait']}" + ) + + # Test 3: wait_until with timeout in automation - trigger 5 times rapidly + test_service = next( + (s for s in services if s.name == "test_wait_timeout"), None + ) + assert test_service is not None, "test_wait_timeout service not found" + client.execute_service(test_service, {}) + await asyncio.wait_for(test3_complete, timeout=3.0) + + # Verify Test 3: All 5 triggers should timeout and complete + assert test_results["wait_until_timeout"] == 5, ( + f"Test 3: Expected 5 timeout completions, got {test_results['wait_until_timeout']}" + ) diff --git a/tests/integration/test_climate_custom_modes.py b/tests/integration/test_climate_custom_modes.py new file mode 100644 index 0000000000..ce34959d88 --- /dev/null +++ b/tests/integration/test_climate_custom_modes.py @@ -0,0 +1,42 @@ +"""Integration test for climate custom presets.""" + +from __future__ import annotations + +from aioesphomeapi import ClimateInfo, ClimatePreset +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_climate_custom_fan_modes_and_presets( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that custom presets are properly exposed via API.""" + async with run_compiled(yaml_config), api_client_connected() as client: + # Get entities and services + entities, services = await client.list_entities_services() + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, "Expected exactly 1 climate entity" + + test_climate = climate_infos[0] + + # Verify enum presets are exposed (from preset: config map) + assert ClimatePreset.AWAY in test_climate.supported_presets, ( + "Expected AWAY in enum presets" + ) + + # Verify custom string presets are exposed (non-standard preset names from preset map) + custom_presets = test_climate.supported_custom_presets + assert len(custom_presets) == 3, ( + f"Expected 3 custom presets, got {len(custom_presets)}: {custom_presets}" + ) + assert "Eco Plus" in custom_presets, "Expected 'Eco Plus' in custom presets" + assert "Super Saver" in custom_presets, ( + "Expected 'Super Saver' in custom presets" + ) + assert "Vacation Mode" in custom_presets, ( + "Expected 'Vacation Mode' in custom presets" + ) diff --git a/tests/integration/test_continuation_actions.py b/tests/integration/test_continuation_actions.py new file mode 100644 index 0000000000..1069ee7581 --- /dev/null +++ b/tests/integration/test_continuation_actions.py @@ -0,0 +1,235 @@ +"""Test continuation actions (ContinuationAction, WhileLoopContinuation, RepeatLoopContinuation).""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_continuation_actions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """ + Test that continuation actions work correctly for if/while/repeat. + + These continuation classes replace LambdaAction with simple parent pointers, + saving 32-36 bytes per instance and eliminating std::function overhead. + """ + loop = asyncio.get_running_loop() + + # Track test completions + test_results = { + "if_then": False, + "if_else": False, + "if_complete": False, + "nested_both_true": False, + "nested_outer_true_inner_false": False, + "nested_outer_false": False, + "nested_complete": False, + "while_iterations": 0, + "while_complete": False, + "repeat_iterations": 0, + "repeat_complete": False, + "combined_iterations": 0, + "combined_complete": False, + "rapid_then": 0, + "rapid_else": 0, + "rapid_complete": 0, + } + + # Patterns for log messages + if_then_pattern = re.compile(r"if-then executed: value=(\d+)") + if_else_pattern = re.compile(r"if-else executed: value=(\d+)") + if_complete_pattern = re.compile(r"if completed") + nested_both_true_pattern = re.compile(r"nested-both-true") + nested_outer_true_inner_false_pattern = re.compile(r"nested-outer-true-inner-false") + nested_outer_false_pattern = re.compile(r"nested-outer-false") + nested_complete_pattern = re.compile(r"nested if completed") + while_iteration_pattern = re.compile(r"while-iteration-(\d+)") + while_complete_pattern = re.compile(r"while completed") + repeat_iteration_pattern = re.compile(r"repeat-iteration-(\d+)") + repeat_complete_pattern = re.compile(r"repeat completed") + combined_pattern = re.compile(r"combined-repeat(\d+)-while(\d+)") + combined_complete_pattern = re.compile(r"combined completed") + rapid_then_pattern = re.compile(r"rapid-if-then: value=(\d+)") + rapid_else_pattern = re.compile(r"rapid-if-else: value=(\d+)") + rapid_complete_pattern = re.compile(r"rapid-if-completed: value=(\d+)") + + # Test completion futures + test1_complete = loop.create_future() # if action + test2_complete = loop.create_future() # nested if + test3_complete = loop.create_future() # while + test4_complete = loop.create_future() # repeat + test5_complete = loop.create_future() # combined + test6_complete = loop.create_future() # rapid + + def check_output(line: str) -> None: + """Check log output for test messages.""" + # Test 1: IfAction + if if_then_pattern.search(line): + test_results["if_then"] = True + if if_else_pattern.search(line): + test_results["if_else"] = True + if if_complete_pattern.search(line): + test_results["if_complete"] = True + if not test1_complete.done(): + test1_complete.set_result(True) + + # Test 2: Nested IfAction + if nested_both_true_pattern.search(line): + test_results["nested_both_true"] = True + if nested_outer_true_inner_false_pattern.search(line): + test_results["nested_outer_true_inner_false"] = True + if nested_outer_false_pattern.search(line): + test_results["nested_outer_false"] = True + if nested_complete_pattern.search(line): + test_results["nested_complete"] = True + if not test2_complete.done(): + test2_complete.set_result(True) + + # Test 3: WhileAction + if match := while_iteration_pattern.search(line): + test_results["while_iterations"] = max( + test_results["while_iterations"], int(match.group(1)) + 1 + ) + if while_complete_pattern.search(line): + test_results["while_complete"] = True + if not test3_complete.done(): + test3_complete.set_result(True) + + # Test 4: RepeatAction + if match := repeat_iteration_pattern.search(line): + test_results["repeat_iterations"] = max( + test_results["repeat_iterations"], int(match.group(1)) + 1 + ) + if repeat_complete_pattern.search(line): + test_results["repeat_complete"] = True + if not test4_complete.done(): + test4_complete.set_result(True) + + # Test 5: Combined + if combined_pattern.search(line): + test_results["combined_iterations"] += 1 + if combined_complete_pattern.search(line): + test_results["combined_complete"] = True + if not test5_complete.done(): + test5_complete.set_result(True) + + # Test 6: Rapid triggers + if rapid_then_pattern.search(line): + test_results["rapid_then"] += 1 + if rapid_else_pattern.search(line): + test_results["rapid_else"] += 1 + if rapid_complete_pattern.search(line): + test_results["rapid_complete"] += 1 + if test_results["rapid_complete"] == 5 and not test6_complete.done(): + test6_complete.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get services + _, services = await client.list_entities_services() + + # Test 1: IfAction with then branch + test_service = next((s for s in services if s.name == "test_if_action"), None) + assert test_service is not None, "test_if_action service not found" + client.execute_service(test_service, {"condition": True, "value": 42}) + await asyncio.wait_for(test1_complete, timeout=2.0) + assert test_results["if_then"], "IfAction then branch not executed" + assert test_results["if_complete"], "IfAction did not complete" + + # Test 1b: IfAction with else branch + test1_complete = loop.create_future() + test_results["if_complete"] = False + client.execute_service(test_service, {"condition": False, "value": 99}) + await asyncio.wait_for(test1_complete, timeout=2.0) + assert test_results["if_else"], "IfAction else branch not executed" + assert test_results["if_complete"], "IfAction did not complete" + + # Test 2: Nested IfAction - test all branches + test_service = next((s for s in services if s.name == "test_nested_if"), None) + assert test_service is not None, "test_nested_if service not found" + + # Both true + client.execute_service(test_service, {"outer": True, "inner": True}) + await asyncio.wait_for(test2_complete, timeout=2.0) + assert test_results["nested_both_true"], "Nested both true not executed" + + # Outer true, inner false + test2_complete = loop.create_future() + test_results["nested_complete"] = False + client.execute_service(test_service, {"outer": True, "inner": False}) + await asyncio.wait_for(test2_complete, timeout=2.0) + assert test_results["nested_outer_true_inner_false"], ( + "Nested outer true inner false not executed" + ) + + # Outer false + test2_complete = loop.create_future() + test_results["nested_complete"] = False + client.execute_service(test_service, {"outer": False, "inner": True}) + await asyncio.wait_for(test2_complete, timeout=2.0) + assert test_results["nested_outer_false"], "Nested outer false not executed" + + # Test 3: WhileAction + test_service = next( + (s for s in services if s.name == "test_while_action"), None + ) + assert test_service is not None, "test_while_action service not found" + client.execute_service(test_service, {"max_count": 3}) + await asyncio.wait_for(test3_complete, timeout=2.0) + assert test_results["while_iterations"] == 3, ( + f"WhileAction expected 3 iterations, got {test_results['while_iterations']}" + ) + assert test_results["while_complete"], "WhileAction did not complete" + + # Test 4: RepeatAction + test_service = next( + (s for s in services if s.name == "test_repeat_action"), None + ) + assert test_service is not None, "test_repeat_action service not found" + client.execute_service(test_service, {"count": 5}) + await asyncio.wait_for(test4_complete, timeout=2.0) + assert test_results["repeat_iterations"] == 5, ( + f"RepeatAction expected 5 iterations, got {test_results['repeat_iterations']}" + ) + assert test_results["repeat_complete"], "RepeatAction did not complete" + + # Test 5: Combined (if + repeat + while) + test_service = next((s for s in services if s.name == "test_combined"), None) + assert test_service is not None, "test_combined service not found" + client.execute_service(test_service, {"do_loop": True, "loop_count": 2}) + await asyncio.wait_for(test5_complete, timeout=2.0) + # Should execute: repeat 2 times, each iteration does while from iteration down to 0 + # iteration 0: while 0 times = 0 + # iteration 1: while 1 time = 1 + # Total: 1 combined log + assert test_results["combined_iterations"] >= 1, ( + f"Combined expected >=1 iterations, got {test_results['combined_iterations']}" + ) + assert test_results["combined_complete"], "Combined did not complete" + + # Test 6: Rapid triggers (tests memory efficiency of ContinuationAction) + test_service = next((s for s in services if s.name == "test_rapid_if"), None) + assert test_service is not None, "test_rapid_if service not found" + client.execute_service(test_service, {}) + await asyncio.wait_for(test6_complete, timeout=2.0) + # Values 1, 2 should hit else (<=2), values 3, 4, 5 should hit then (>2) + assert test_results["rapid_else"] == 2, ( + f"Rapid test expected 2 else, got {test_results['rapid_else']}" + ) + assert test_results["rapid_then"] == 3, ( + f"Rapid test expected 3 then, got {test_results['rapid_then']}" + ) + assert test_results["rapid_complete"] == 5, ( + f"Rapid test expected 5 completions, got {test_results['rapid_complete']}" + ) diff --git a/tests/integration/test_host_mode_climate_basic_state.py b/tests/integration/test_host_mode_climate_basic_state.py index 4697342a99..7d871ed5a8 100644 --- a/tests/integration/test_host_mode_climate_basic_state.py +++ b/tests/integration/test_host_mode_climate_basic_state.py @@ -2,12 +2,11 @@ from __future__ import annotations -import asyncio - import aioesphomeapi -from aioesphomeapi import ClimateAction, ClimateMode, ClimatePreset, EntityState +from aioesphomeapi import ClimateAction, ClimateInfo, ClimateMode, ClimatePreset import pytest +from .state_utils import InitialStateHelper from .types import APIClientConnectedFactory, RunCompiledFunction @@ -18,26 +17,27 @@ async def test_host_mode_climate_basic_state( api_client_connected: APIClientConnectedFactory, ) -> None: """Test basic climate state reporting.""" - loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: - states: dict[int, EntityState] = {} - climate_future: asyncio.Future[EntityState] = loop.create_future() + # Get entities and set up state synchronization + entities, services = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) >= 1, "Expected at least 1 climate entity" - def on_state(state: EntityState) -> None: - states[state.key] = state - if ( - isinstance(state, aioesphomeapi.ClimateState) - and not climate_future.done() - ): - climate_future.set_result(state) - - client.subscribe_states(on_state) + # Subscribe with the wrapper (no-op callback since we just want initial states) + client.subscribe_states(initial_state_helper.on_state_wrapper(lambda _: None)) + # Wait for all initial states to be broadcast try: - climate_state = await asyncio.wait_for(climate_future, timeout=5.0) + await initial_state_helper.wait_for_initial_states() except TimeoutError: - pytest.fail("Climate state not received within 5 seconds") + pytest.fail("Timeout waiting for initial states") + # Get the climate entity and its initial state + test_climate = climate_infos[0] + climate_state = initial_state_helper.initial_states.get(test_climate.key) + + assert climate_state is not None, "Climate initial state not found" assert isinstance(climate_state, aioesphomeapi.ClimateState) assert climate_state.mode == ClimateMode.OFF assert climate_state.action == ClimateAction.OFF diff --git a/tests/integration/test_host_mode_empty_string_options.py b/tests/integration/test_host_mode_empty_string_options.py index 242db2d40f..1180ce75fc 100644 --- a/tests/integration/test_host_mode_empty_string_options.py +++ b/tests/integration/test_host_mode_empty_string_options.py @@ -36,8 +36,8 @@ async def test_host_mode_empty_string_options( # Find our select entities select_entities = [e for e in entity_info if isinstance(e, SelectInfo)] - assert len(select_entities) == 3, ( - f"Expected 3 select entities, got {len(select_entities)}" + assert len(select_entities) == 4, ( + f"Expected 4 select entities, got {len(select_entities)}" ) # Verify each select entity by name and check their options @@ -71,6 +71,15 @@ async def test_host_mode_empty_string_options( assert empty_last.options[2] == "Choice Z" assert empty_last.options[3] == "" # Empty string at end + # Check "Select Initial Option Test" - verify non-default initial option + assert "Select Initial Option Test" in selects_by_name + initial_option_test = selects_by_name["Select Initial Option Test"] + assert len(initial_option_test.options) == 4 + assert initial_option_test.options[0] == "First" + assert initial_option_test.options[1] == "Second" + assert initial_option_test.options[2] == "Third" + assert initial_option_test.options[3] == "Fourth" + # If we got here without protobuf decoding errors, the fix is working # The bug would have caused "Invalid protobuf message" errors with trailing bytes @@ -78,7 +87,12 @@ async def test_host_mode_empty_string_options( # This ensures empty strings work properly in state messages too states: dict[int, EntityState] = {} states_received_future: asyncio.Future[None] = loop.create_future() - expected_select_keys = {empty_first.key, empty_middle.key, empty_last.key} + expected_select_keys = { + empty_first.key, + empty_middle.key, + empty_last.key, + initial_option_test.key, + } received_select_keys = set() def on_state(state: EntityState) -> None: @@ -109,6 +123,14 @@ async def test_host_mode_empty_string_options( assert empty_first.key in states assert empty_middle.key in states assert empty_last.key in states + assert initial_option_test.key in states + + # Verify the initial option is set correctly to "Third" (not the default "First") + initial_state = states[initial_option_test.key] + assert initial_state.state == "Third", ( + f"Expected initial state 'Third' but got '{initial_state.state}' - " + f"initial_option not correctly applied" + ) # The main test is that we got here without protobuf errors # The select entities with empty string options were properly encoded diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index c9ccf53252..e73c134151 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -152,6 +152,14 @@ def test_main_all_tests_should_run( assert output["memory_impact"]["should_run"] == "false" assert output["cpp_unit_tests_run_all"] is False assert output["cpp_unit_tests_components"] == ["wifi", "api", "sensor"] + # component_test_batches should be present and be a list of space-separated strings + assert "component_test_batches" in output + assert isinstance(output["component_test_batches"], list) + # Each batch should be a space-separated string of component names + for batch in output["component_test_batches"]: + assert isinstance(batch, str) + # Should contain at least one component (no empty batches) + assert len(batch) > 0 def test_main_no_tests_should_run( @@ -209,6 +217,9 @@ def test_main_no_tests_should_run( assert output["memory_impact"]["should_run"] == "false" assert output["cpp_unit_tests_run_all"] is False assert output["cpp_unit_tests_components"] == [] + # component_test_batches should be empty list + assert "component_test_batches" in output + assert output["component_test_batches"] == [] def test_main_with_branch_argument( @@ -849,39 +860,47 @@ def test_detect_memory_impact_config_no_components_with_tests(tmp_path: Path) -> assert result["should_run"] == "false" -def test_detect_memory_impact_config_skips_base_bus_components(tmp_path: Path) -> None: - """Test that base bus components (i2c, spi, uart) are skipped.""" +def test_detect_memory_impact_config_includes_base_bus_components( + tmp_path: Path, +) -> None: + """Test that base bus components (i2c, spi, uart) are included when directly changed. + + Base bus components should be analyzed for memory impact when they are directly + changed, even though they are often used as dependencies. This ensures that + optimizations to base components (like using move semantics or initializer_list) + are properly measured. + """ # Create test directory structure tests_dir = tmp_path / "tests" / "components" - # i2c component (should be skipped as it's a base bus component) - i2c_dir = tests_dir / "i2c" - i2c_dir.mkdir(parents=True) - (i2c_dir / "test.esp32-idf.yaml").write_text("test: i2c") + # uart component (base bus component that should be included) + uart_dir = tests_dir / "uart" + uart_dir.mkdir(parents=True) + (uart_dir / "test.esp32-idf.yaml").write_text("test: uart") - # wifi component (should not be skipped) + # wifi component (regular component) wifi_dir = tests_dir / "wifi" wifi_dir.mkdir(parents=True) (wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi") - # Mock changed_files to return both i2c and wifi + # Mock changed_files to return both uart and wifi 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/components/i2c/i2c.cpp", + "esphome/components/uart/automation.h", # Header file with inline code "esphome/components/wifi/wifi.cpp", ] determine_jobs._component_has_tests.cache_clear() result = determine_jobs.detect_memory_impact_config() - # Should only include wifi, not i2c + # Should include both uart and wifi assert result["should_run"] == "true" - assert result["components"] == ["wifi"] - assert "i2c" not in result["components"] + assert set(result["components"]) == {"uart", "wifi"} + assert result["platform"] == "esp32-idf" # Common platform def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 63f1f0e600..1046512a14 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -1065,3 +1065,39 @@ def test_parse_list_components_output(output: str, expected: list[str]) -> None: """Test parse_list_components_output function.""" result = helpers.parse_list_components_output(output) assert result == expected + + +@pytest.mark.parametrize( + ("file_path", "expected_component"), + [ + # Component files + ("esphome/components/wifi/wifi.cpp", "wifi"), + ("esphome/components/uart/uart.h", "uart"), + ("esphome/components/api/api_server.cpp", "api"), + ("esphome/components/sensor/sensor.cpp", "sensor"), + # Test files + ("tests/components/uart/test.esp32-idf.yaml", "uart"), + ("tests/components/wifi/test.esp8266-ard.yaml", "wifi"), + ("tests/components/sensor/test.esp32-idf.yaml", "sensor"), + ("tests/components/api/test_api.cpp", "api"), + ("tests/components/uart/common.h", "uart"), + # Non-component files + ("esphome/core/component.cpp", None), + ("esphome/core/helpers.h", None), + ("tests/integration/test_api.py", None), + ("tests/unit_tests/test_helpers.py", None), + ("README.md", None), + ("script/helpers.py", None), + # Edge cases + ("esphome/components/", None), # No component name + ("tests/components/", None), # No component name + ("esphome/components", None), # No trailing slash + ("tests/components", None), # No trailing slash + ], +) +def test_get_component_from_path( + file_path: str, expected_component: str | None +) -> None: + """Test extraction of component names from file paths.""" + result = helpers.get_component_from_path(file_path) + assert result == expected_component diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index a1e4627dc9..90b2f5edba 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -261,6 +261,17 @@ def test_device_duplicate_id( assert "ID duplicate_device redefined!" in captured.out +def test_substitution_with_id( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that a ids coming from substitutions do not cause false positive ID redefinition.""" + load_config_from_fixture( + yaml_file, "id_collision_with_substitution.yaml", FIXTURES_DIR + ) + captured = capsys.readouterr() + assert "ID some_switch_id redefined!" not in captured.out + + def test_add_platform_defines_priority() -> None: """Test that _add_platform_defines runs after globals. diff --git a/tests/unit_tests/fixtures/core/config/id_collision_with_substitution.yaml b/tests/unit_tests/fixtures/core/config/id_collision_with_substitution.yaml new file mode 100644 index 0000000000..840d9ac925 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/id_collision_with_substitution.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +host: + +substitutions: + support_switches: + - platform: gpio + id: some_switch_id + pin: 12 + +switch: $support_switches diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml index 795a788f62..6f3bae1ac4 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml @@ -33,3 +33,4 @@ test_list: {{{ "x", "79"}, { "y", "82"}}} - '{{{"AA"}}}' - '"HELLO"' + - '{ 79, 82 }' diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml index 722e116d36..306119b753 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml @@ -34,3 +34,4 @@ test_list: {{{ "x", "${ position.x }"}, { "y", "${ position.y }"}}} - ${ '{{{"AA"}}}' } - ${ '"HELLO"' } + - '{ ${position.x}, ${position.y} }' diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2928c5c83a..104cdc2b7a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -3,6 +3,7 @@ import string from hypothesis import example, given from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest +import voluptuous as vol from esphome import config_validation from esphome.components.esp32.const import ( @@ -301,8 +302,6 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) ], ) def test_require_framework_version(framework, platform, message): - import voluptuous as vol - from esphome.const import ( KEY_CORE, KEY_FRAMEWORK_VERSION, @@ -377,3 +376,129 @@ def test_require_framework_version(framework, platform, message): config_validation.require_framework_version( extra_message="test 5", )("test") + + +def test_only_with_single_component_loaded() -> None: + """Test OnlyWith with single component when component is loaded.""" + CORE.loaded_integrations = {"mqtt"} + + schema = config_validation.Schema( + { + config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + } + ) + + result = schema({}) + assert result.get("mqtt_id") == "test_mqtt" + + +def test_only_with_single_component_not_loaded() -> None: + """Test OnlyWith with single component when component is not loaded.""" + CORE.loaded_integrations = set() + + schema = config_validation.Schema( + { + config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + } + ) + + result = schema({}) + assert "mqtt_id" not in result + + +def test_only_with_list_all_components_loaded() -> None: + """Test OnlyWith with list when all components are loaded.""" + CORE.loaded_integrations = {"zigbee", "nrf52"} + + schema = config_validation.Schema( + { + config_validation.OnlyWith( + "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" + ): str, + } + ) + + result = schema({}) + assert result.get("zigbee_id") == "test_zigbee" + + +def test_only_with_list_partial_components_loaded() -> None: + """Test OnlyWith with list when only some components are loaded.""" + CORE.loaded_integrations = {"zigbee"} # Only zigbee, not nrf52 + + schema = config_validation.Schema( + { + config_validation.OnlyWith( + "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" + ): str, + } + ) + + result = schema({}) + assert "zigbee_id" not in result + + +def test_only_with_list_no_components_loaded() -> None: + """Test OnlyWith with list when no components are loaded.""" + CORE.loaded_integrations = set() + + schema = config_validation.Schema( + { + config_validation.OnlyWith( + "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" + ): str, + } + ) + + result = schema({}) + assert "zigbee_id" not in result + + +def test_only_with_list_multiple_components() -> None: + """Test OnlyWith with list requiring three components.""" + CORE.loaded_integrations = {"comp1", "comp2", "comp3"} + + schema = config_validation.Schema( + { + config_validation.OnlyWith( + "test_id", ["comp1", "comp2", "comp3"], default="test_value" + ): str, + } + ) + + result = schema({}) + assert result.get("test_id") == "test_value" + + # Test with one missing + CORE.loaded_integrations = {"comp1", "comp2"} + result = schema({}) + assert "test_id" not in result + + +def test_only_with_empty_list() -> None: + """Test OnlyWith with empty list (edge case).""" + CORE.loaded_integrations = set() + + schema = config_validation.Schema( + { + config_validation.OnlyWith("test_id", [], default="test_value"): str, + } + ) + + # all([]) returns True, so default should be applied + result = schema({}) + assert result.get("test_id") == "test_value" + + +def test_only_with_user_value_overrides_default() -> None: + """Test OnlyWith respects user-provided values over defaults.""" + CORE.loaded_integrations = {"mqtt"} + + schema = config_validation.Schema( + { + config_validation.OnlyWith("mqtt_id", "mqtt", default="default_id"): str, + } + ) + + result = schema({"mqtt_id": "custom_id"}) + assert result.get("mqtt_id") == "custom_id" diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 95633ca0c6..2c9f760c8e 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -173,6 +173,61 @@ class TestLambdaExpression: "}" ) + def test_str__stateless_no_return(self): + """Test stateless lambda (empty capture) generates correctly""" + target = cg.LambdaExpression( + ('ESP_LOGD("main", "Test message");',), + (), # No parameters + "", # Empty capture (stateless) + ) + + actual = str(target) + + assert actual == ('[]() {\n ESP_LOGD("main", "Test message");\n}') + + def test_str__stateless_with_return(self): + """Test stateless lambda with return type generates correctly""" + target = cg.LambdaExpression( + ("return global_value > 0;",), + (), # No parameters + "", # Empty capture (stateless) + bool, # Return type + ) + + actual = str(target) + + assert actual == ("[]() -> bool {\n return global_value > 0;\n}") + + def test_str__stateless_with_params(self): + """Test stateless lambda with parameters generates correctly""" + target = cg.LambdaExpression( + ("return foo + bar;",), + ((int, "foo"), (float, "bar")), + "", # Empty capture (stateless) + float, + ) + + actual = str(target) + + assert actual == ( + "[](int32_t foo, float bar) -> float {\n return foo + bar;\n}" + ) + + def test_str__with_capture(self): + """Test lambda with capture generates correctly""" + target = cg.LambdaExpression( + ("return captured_var + x;",), + ((int, "x"),), + "captured_var", # Has capture (not stateless) + int, + ) + + actual = str(target) + + assert actual == ( + "[captured_var](int32_t x) -> int32_t {\n return captured_var + x;\n}" + ) + class TestLiterals: @pytest.mark.parametrize( diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 87ed901ecb..47b945e0eb 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -454,9 +454,27 @@ def test_resolve_ip_address_mixed_list() -> None: # Mix of IP and hostname - should use async resolver result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) + assert len(result) == 2 + assert result[0][4][0] == "192.168.1.100" + assert result[1][4][0] == "192.168.1.200" + MockResolver.assert_called_once_with(["test.local"], 6053) + mock_resolver.resolve.assert_called_once() + + +def test_resolve_ip_address_mixed_list_fail() -> None: + """Test resolving a mix of IPs and hostnames with resolve failed.""" + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.side_effect = EsphomeError( + "Error resolving IP address: [test.local]" + ) + + # Mix of IP and hostname - should use async resolver + result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) + assert len(result) == 1 - assert result[0][4][0] == "192.168.1.200" - MockResolver.assert_called_once_with(["192.168.1.100", "test.local"], 6053) + assert result[0][4][0] == "192.168.1.100" + MockResolver.assert_called_once_with(["test.local"], 6053) mock_resolver.resolve.assert_called_once() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 9119c88502..9e5f399381 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -744,7 +744,7 @@ def test_choose_upload_log_host_ota_local_all_options() -> None: check_default=None, purpose=Purpose.UPLOADING, ) - assert result == ["MQTTIP", "test.local"] + assert result == ["MQTTIP"] @pytest.mark.usefixtures("mock_serial_ports") @@ -794,7 +794,7 @@ def test_choose_upload_log_host_ota_local_all_options_logging() -> None: check_default=None, purpose=Purpose.LOGGING, ) - assert result == ["MQTTIP", "MQTT", "test.local"] + assert result == ["MQTTIP", "MQTT"] @pytest.mark.usefixtures("mock_no_mqtt_logging") @@ -1564,7 +1564,7 @@ def test_has_resolvable_address() -> None: setup_core( config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" ) - assert has_resolvable_address() is True + assert has_resolvable_address() is False # Test with mDNS disabled and regular DNS hostname (resolvable) setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address="device.example.com") diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index beb1ebc73e..7d50b44506 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -1,6 +1,7 @@ import glob import logging from pathlib import Path +from typing import Any from esphome import config as config_module, yaml_util from esphome.components import substitutions @@ -60,6 +61,29 @@ def write_yaml(path: Path, data: dict) -> None: path.write_text(yaml_util.dump(data), encoding="utf-8") +def verify_database(value: Any, path: str = "") -> str | None: + if isinstance(value, list): + for i, v in enumerate(value): + result = verify_database(v, f"{path}[{i}]") + if result is not None: + return result + return None + if isinstance(value, dict): + for k, v in value.items(): + key_result = verify_database(k, f"{path}/{k}") + if key_result is not None: + return key_result + value_result = verify_database(v, f"{path}/{k}") + if value_result is not None: + return value_result + return None + if isinstance(value, str): + if not isinstance(value, yaml_util.ESPHomeDataBase): + return f"{path}: {value!r} is not ESPHomeDataBase" + return None + return None + + def test_substitutions_fixtures(fixture_path): base_dir = fixture_path / "substitutions" sources = sorted(glob.glob(str(base_dir / "*.input.yaml"))) @@ -83,6 +107,9 @@ def test_substitutions_fixtures(fixture_path): substitutions.do_substitution_pass(config, None) resolve_extend_remove(config) + verify_database_result = verify_database(config) + if verify_database_result is not None: + raise AssertionError(verify_database_result) # Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE if expected_path.is_file():