From 2255c68377304c81ab75da9fda8b79cbcf5c756c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 28 Feb 2026 06:40:55 +1100 Subject: [PATCH 01/13] [esp32] Enable `execute_from_psram` for P4 (#14329) --- esphome/components/esp32/__init__.py | 13 +++-- .../config/execute_from_psram_disabled.yaml | 10 ++++ .../esp32/config/execute_from_psram_p4.yaml | 11 ++++ .../esp32/config/execute_from_psram_s3.yaml | 12 ++++ tests/component_tests/esp32/test_esp32.py | 55 ++++++++++++++++++- 5 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/esp32/config/execute_from_psram_disabled.yaml create mode 100644 tests/component_tests/esp32/config/execute_from_psram_p4.yaml create mode 100644 tests/component_tests/esp32/config/execute_from_psram_s3.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a34747a183..998913ecec 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -890,10 +890,10 @@ def final_validate(config): ) ) if advanced[CONF_EXECUTE_FROM_PSRAM]: - if config[CONF_VARIANT] != VARIANT_ESP32S3: + if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}: errs.append( cv.Invalid( - f"'{CONF_EXECUTE_FROM_PSRAM}' is only supported on {VARIANT_ESP32S3} variant", + f"'{CONF_EXECUTE_FROM_PSRAM}' is not available on this esp32 variant", path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_EXECUTE_FROM_PSRAM], ) ) @@ -1627,8 +1627,13 @@ async def to_code(config): _configure_lwip_max_sockets(conf) if advanced[CONF_EXECUTE_FROM_PSRAM]: - add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) - add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) + if variant == VARIANT_ESP32S3: + add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) + add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) + elif variant == VARIANT_ESP32P4: + add_idf_sdkconfig_option("CONFIG_SPIRAM_XIP_FROM_PSRAM", True) + else: + raise ValueError("Unhandled ESP32 variant") # Apply LWIP core locking for better socket performance # This is already enabled by default in Arduino framework, where it provides diff --git a/tests/component_tests/esp32/config/execute_from_psram_disabled.yaml b/tests/component_tests/esp32/config/execute_from_psram_disabled.yaml new file mode 100644 index 0000000000..b52255c5ad --- /dev/null +++ b/tests/component_tests/esp32/config/execute_from_psram_disabled.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal diff --git a/tests/component_tests/esp32/config/execute_from_psram_p4.yaml b/tests/component_tests/esp32/config/execute_from_psram_p4.yaml new file mode 100644 index 0000000000..e9070a5ae1 --- /dev/null +++ b/tests/component_tests/esp32/config/execute_from_psram_p4.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + variant: esp32p4 + framework: + type: esp-idf + advanced: + execute_from_psram: true + +psram: diff --git a/tests/component_tests/esp32/config/execute_from_psram_s3.yaml b/tests/component_tests/esp32/config/execute_from_psram_s3.yaml new file mode 100644 index 0000000000..aec020ddff --- /dev/null +++ b/tests/component_tests/esp32/config/execute_from_psram_s3.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + execute_from_psram: true + +psram: + mode: octal diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 68bd3a5965..bd4f9828ce 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2,13 +2,17 @@ Test ESP32 configuration """ +from collections.abc import Callable +from pathlib import Path from typing import Any import pytest from esphome.components.esp32 import VARIANTS +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, PlatformFramework +from esphome.core import CORE from tests.component_tests.types import SetCoreConfigCallable @@ -70,7 +74,7 @@ def test_esp32_config( "advanced": {"execute_from_psram": True}, }, }, - r"'execute_from_psram' is only supported on ESP32S3 variant @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]", + r"'execute_from_psram' is not available on this esp32 variant @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]", id="execute_from_psram_invalid_for_variant_config", ), pytest.param( @@ -82,7 +86,18 @@ def test_esp32_config( }, }, r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]", - id="execute_from_psram_requires_psram_config", + id="execute_from_psram_requires_psram_s3_config", + ), + pytest.param( + { + "variant": "esp32p4", + "framework": { + "type": "esp-idf", + "advanced": {"execute_from_psram": True}, + }, + }, + r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]", + id="execute_from_psram_requires_psram_p4_config", ), pytest.param( { @@ -108,3 +123,39 @@ def test_esp32_configuration_errors( with pytest.raises(cv.Invalid, match=error_match): FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) + + +def test_execute_from_psram_s3_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that execute_from_psram on ESP32-S3 sets the correct sdkconfig options.""" + generate_main(component_config_path("execute_from_psram_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_SPIRAM_FETCH_INSTRUCTIONS") is True + assert sdkconfig.get("CONFIG_SPIRAM_RODATA") is True + assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig + + +def test_execute_from_psram_p4_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that execute_from_psram on ESP32-P4 sets the correct sdkconfig options.""" + generate_main(component_config_path("execute_from_psram_p4.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_SPIRAM_XIP_FROM_PSRAM") is True + assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig + assert "CONFIG_SPIRAM_RODATA" not in sdkconfig + + +def test_execute_from_psram_disabled_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that without execute_from_psram, no XIP sdkconfig options are set.""" + generate_main(component_config_path("execute_from_psram_disabled.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig + assert "CONFIG_SPIRAM_RODATA" not in sdkconfig + assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig From 32133e2f464ec93c968d44cf97e9e689ac81f1ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:42:20 -0500 Subject: [PATCH 02/13] Bump ruff from 0.15.3 to 0.15.4 (#14357) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 88a38ffa99..6b2617b656 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.3 # also change in .pre-commit-config.yaml when updating +ruff==0.15.4 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From edd63e3d2d82bfc6585c391ebdae25b0313c91e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:43:10 -0500 Subject: [PATCH 03/13] Bump actions/download-artifact from 7.0.0 to 8.0.0 (#14327) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b752701920..4c9e8c58bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -945,13 +945,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0cca4dcaf6..17a2616dff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,7 +171,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download digests - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: pattern: digests-* path: /tmp/digests From 63e757807ee2a522f493bc65dd38ced7f3d601dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 13:01:09 -0700 Subject: [PATCH 04/13] [zephyr] Use native k_uptime_get() for millis_64() (#14350) Co-authored-by: Claude Opus 4.6 --- esphome/components/zephyr/core.cpp | 5 ++--- esphome/core/scheduler.cpp | 10 +++++----- esphome/core/scheduler.h | 10 +++++----- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index 7f5d6d44fa..f0772a4422 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -4,7 +4,6 @@ #include #include #include -#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/defines.h" @@ -17,8 +16,8 @@ static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); #endif void yield() { ::k_yield(); } -uint32_t millis() { return k_ticks_to_ms_floor32(k_uptime_ticks()); } -uint64_t millis_64() { return App.scheduler.millis_64_impl_(millis()); } +uint32_t millis() { return static_cast(millis_64()); } +uint64_t millis_64() { return static_cast(k_uptime_get()); } uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); } void delayMicroseconds(uint32_t us) { ::k_usleep(us); } void delay(uint32_t ms) { ::k_msleep(ms); } diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 97735c4876..73b6a6c9b3 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -28,7 +28,7 @@ static constexpr size_t MAX_POOL_SIZE = 5; // Set to 5 to match the pool size - when we have as many cancelled items as our // pool can hold, it's time to clean up and recycle them. static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5; -#if !defined(USE_ESP32) && !defined(USE_HOST) +#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; #endif @@ -475,12 +475,12 @@ void HOT Scheduler::call(uint32_t now) { if (now_64 - last_print > 2000) { last_print = now_64; std::vector old_items; -#if !defined(USE_ESP32) && !defined(USE_HOST) && defined(ESPHOME_THREAD_MULTI_ATOMICS) +#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) && defined(ESPHOME_THREAD_MULTI_ATOMICS) const auto last_dbg = this->last_millis_.load(std::memory_order_relaxed); const auto major_dbg = this->millis_major_.load(std::memory_order_relaxed); ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), this->scheduler_item_pool_.size(), now_64, major_dbg, last_dbg); -#elif !defined(USE_ESP32) && !defined(USE_HOST) +#elif !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), this->scheduler_item_pool_.size(), now_64, this->millis_major_, this->last_millis_); #else @@ -714,7 +714,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type return total_cancelled > 0; } -#if !defined(USE_ESP32) && !defined(USE_HOST) +#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) uint64_t Scheduler::millis_64_impl_(uint32_t now) { // THREAD SAFETY NOTE: // This function has three implementations, based on the precompiler flags @@ -872,7 +872,7 @@ uint64_t Scheduler::millis_64_impl_(uint32_t now) { "No platform threading model defined. One of ESPHOME_THREAD_SINGLE, ESPHOME_THREAD_MULTI_NO_ATOMICS, or ESPHOME_THREAD_MULTI_ATOMICS must be defined." #endif } -#endif // !USE_ESP32 && !USE_HOST +#endif // !USE_ESP32 && !USE_HOST && !USE_ZEPHYR bool HOT Scheduler::SchedulerItem::cmp(const SchedulerItemPtr &a, const SchedulerItemPtr &b) { // High bits are almost always equal (change only on 32-bit rollover ~49 days) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 9a62ac1634..282f4c66ef 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -287,7 +287,7 @@ class Scheduler { // On ESP32, ignores now and uses esp_timer_get_time() directly (native 64-bit). // On non-ESP32, extends now to 64-bit using rollover tracking. uint64_t millis_64_from_(uint32_t now) { -#if defined(USE_ESP32) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) (void) now; return millis_64(); #else @@ -295,7 +295,7 @@ class Scheduler { #endif } -#if !defined(USE_ESP32) && !defined(USE_HOST) +#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) // On platforms without native 64-bit time, millis_64() HAL function delegates to this // method which tracks 32-bit millis() rollover using millis_major_ and last_millis_. friend uint64_t millis_64(); @@ -567,8 +567,8 @@ class Scheduler { // to synchronize between tasks (see https://github.com/esphome/backlog/issues/52) std::vector scheduler_item_pool_; -#if !defined(USE_ESP32) && !defined(USE_HOST) - // On platforms with native 64-bit time (ESP32, Host), no rollover tracking needed. +#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) + // On platforms with native 64-bit time (ESP32, Host, Zephyr), no rollover tracking needed. // On other platforms, these fields track 32-bit millis() rollover for millis_64_impl_(). #ifdef ESPHOME_THREAD_MULTI_ATOMICS /* @@ -598,7 +598,7 @@ class Scheduler { #else /* not ESPHOME_THREAD_MULTI_ATOMICS */ uint16_t millis_major_{0}; #endif /* else ESPHOME_THREAD_MULTI_ATOMICS */ -#endif /* !USE_ESP32 && !USE_HOST */ +#endif /* !USE_ESP32 && !USE_HOST && !USE_ZEPHYR */ }; } // namespace esphome From 52af4bced0fdbe0c89a21aa8bb826422467f1936 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 13:01:23 -0700 Subject: [PATCH 05/13] [component] Devirtualize call_dump_config (#14355) --- esphome/components/mqtt/mqtt_component.cpp | 6 ------ esphome/components/mqtt/mqtt_component.h | 2 -- esphome/core/application.cpp | 2 +- esphome/core/component.cpp | 2 +- esphome/core/component.h | 2 +- 5 files changed, 3 insertions(+), 11 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 09570106df..f49069960b 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -405,12 +405,6 @@ void MQTTComponent::process_resend() { this->schedule_resend_state(); } } -void MQTTComponent::call_dump_config() { - if (this->is_internal()) - return; - - this->dump_config(); -} void MQTTComponent::schedule_resend_state() { this->resend_state_ = true; } bool MQTTComponent::is_connected_() const { return global_mqtt_client->is_connected(); } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 853712940a..0ffe6341d3 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -98,8 +98,6 @@ class MQTTComponent : public Component { /// Override setup_ so that we can call send_discovery() when needed. void call_setup() override; - void call_dump_config() override; - /// Send discovery info the Home Assistant, override this. virtual void send_discovery(JsonObject root, SendDiscoveryConfig &config) = 0; diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index e27c2cdfc6..b1ece86701 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -256,7 +256,7 @@ void Application::process_dump_config_() { #endif } - this->components_[this->dump_config_at_]->call_dump_config(); + this->components_[this->dump_config_at_]->call_dump_config_(); this->dump_config_at_++; } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index b4a19a0776..e14fae5e08 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -211,7 +211,7 @@ bool Component::cancel_retry(uint32_t id) { void Component::call_loop_() { this->loop(); } void Component::call_setup() { this->setup(); } -void Component::call_dump_config() { +void Component::call_dump_config_() { this->dump_config(); if (this->is_failed()) { // Look up error message from global vector diff --git a/esphome/core/component.h b/esphome/core/component.h index 7ea9fdf3b3..2620e8eb2a 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -291,7 +291,7 @@ class Component { void call_loop_(); virtual void call_setup(); - virtual void call_dump_config(); + void call_dump_config_(); /// Helper to set component state (clears state bits and sets new state) void set_component_state_(uint8_t state); From f6755aabae2fad7ea5c082571502baf281c6d8b8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:18:07 -0500 Subject: [PATCH 06/13] [ci] Add PR title format check (#14345) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/auto-label-pr/detectors.js | 42 +++++------- .github/scripts/detect-tags.js | 66 +++++++++++++++++++ .github/workflows/pr-title-check.yml | 76 ++++++++++++++++++++++ 3 files changed, 157 insertions(+), 27 deletions(-) create mode 100644 .github/scripts/detect-tags.js create mode 100644 .github/workflows/pr-title-check.yml diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index a45a84f219..80d8847bc1 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -1,5 +1,12 @@ const fs = require('fs'); const { DOCS_PR_PATTERNS } = require('./constants'); +const { + COMPONENT_REGEX, + detectComponents, + hasCoreChanges, + hasDashboardChanges, + hasGitHubActionsChanges, +} = require('../detect-tags'); // Strategy: Merge branch detection async function detectMergeBranch(context) { @@ -20,15 +27,13 @@ async function detectMergeBranch(context) { // Strategy: Component and platform labeling async function detectComponentPlatforms(changedFiles, apiData) { const labels = new Set(); - const componentRegex = /^esphome\/components\/([^\/]+)\//; const targetPlatformRegex = new RegExp(`^esphome\/components\/(${apiData.targetPlatforms.join('|')})/`); - for (const file of changedFiles) { - const componentMatch = file.match(componentRegex); - if (componentMatch) { - labels.add(`component: ${componentMatch[1]}`); - } + for (const comp of detectComponents(changedFiles)) { + labels.add(`component: ${comp}`); + } + for (const file of changedFiles) { const platformMatch = file.match(targetPlatformRegex); if (platformMatch) { labels.add(`platform: ${platformMatch[1]}`); @@ -90,15 +95,9 @@ async function detectNewPlatforms(prFiles, apiData) { // Strategy: Core files detection async function detectCoreChanges(changedFiles) { const labels = new Set(); - const coreFiles = changedFiles.filter(file => - file.startsWith('esphome/core/') || - (file.startsWith('esphome/') && file.split('/').length === 2) - ); - - if (coreFiles.length > 0) { + if (hasCoreChanges(changedFiles)) { labels.add('core'); } - return labels; } @@ -131,29 +130,18 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange // Strategy: Dashboard changes async function detectDashboardChanges(changedFiles) { const labels = new Set(); - const dashboardFiles = changedFiles.filter(file => - file.startsWith('esphome/dashboard/') || - file.startsWith('esphome/components/dashboard_import/') - ); - - if (dashboardFiles.length > 0) { + if (hasDashboardChanges(changedFiles)) { labels.add('dashboard'); } - return labels; } // Strategy: GitHub Actions changes async function detectGitHubActionsChanges(changedFiles) { const labels = new Set(); - const githubActionsFiles = changedFiles.filter(file => - file.startsWith('.github/workflows/') - ); - - if (githubActionsFiles.length > 0) { + if (hasGitHubActionsChanges(changedFiles)) { labels.add('github-actions'); } - return labels; } @@ -259,7 +247,7 @@ async function detectDeprecatedComponents(github, context, changedFiles) { const { owner, repo } = context.repo; // Compile regex once for better performance - const componentFileRegex = /^esphome\/components\/([^\/]+)\//; + const componentFileRegex = COMPONENT_REGEX; // Get files that are modified or added in components directory const componentFiles = changedFiles.filter(file => componentFileRegex.test(file)); diff --git a/.github/scripts/detect-tags.js b/.github/scripts/detect-tags.js new file mode 100644 index 0000000000..3933776c61 --- /dev/null +++ b/.github/scripts/detect-tags.js @@ -0,0 +1,66 @@ +/** + * Shared tag detection from changed file paths. + * Used by pr-title-check and auto-label-pr workflows. + */ + +const COMPONENT_REGEX = /^esphome\/components\/([^\/]+)\//; + +/** + * Detect component names from changed files. + * @param {string[]} changedFiles - List of changed file paths + * @returns {Set} Set of component names + */ +function detectComponents(changedFiles) { + const components = new Set(); + for (const file of changedFiles) { + const match = file.match(COMPONENT_REGEX); + if (match) { + components.add(match[1]); + } + } + return components; +} + +/** + * Detect if core files were changed. + * Core files are in esphome/core/ or top-level esphome/ directory. + * @param {string[]} changedFiles - List of changed file paths + * @returns {boolean} + */ +function hasCoreChanges(changedFiles) { + return changedFiles.some(file => + file.startsWith('esphome/core/') || + (file.startsWith('esphome/') && file.split('/').length === 2) + ); +} + +/** + * Detect if dashboard files were changed. + * @param {string[]} changedFiles - List of changed file paths + * @returns {boolean} + */ +function hasDashboardChanges(changedFiles) { + return changedFiles.some(file => + file.startsWith('esphome/dashboard/') || + file.startsWith('esphome/components/dashboard_import/') + ); +} + +/** + * Detect if GitHub Actions files were changed. + * @param {string[]} changedFiles - List of changed file paths + * @returns {boolean} + */ +function hasGitHubActionsChanges(changedFiles) { + return changedFiles.some(file => + file.startsWith('.github/workflows/') + ); +} + +module.exports = { + COMPONENT_REGEX, + detectComponents, + hasCoreChanges, + hasDashboardChanges, + hasGitHubActionsChanges, +}; diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml new file mode 100644 index 0000000000..f23c2c870e --- /dev/null +++ b/.github/workflows/pr-title-check.yml @@ -0,0 +1,76 @@ +name: PR Title Check + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + check: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { + detectComponents, + hasCoreChanges, + hasDashboardChanges, + hasGitHubActionsChanges, + } = require('./.github/scripts/detect-tags.js'); + + const title = context.payload.pull_request.title; + + // Block titles starting with "word:" or "word(scope):" patterns + const commitStylePattern = /^\w+(\(.*?\))?[!]?\s*:/; + if (commitStylePattern.test(title)) { + core.setFailed( + `PR title should not start with a "prefix:" style format.\n` + + `Please use the format: [component] Brief description\n` + + `Example: [pn532] Add health checking and auto-reset` + ); + return; + } + + // Get changed files to detect tags + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + const filenames = files.map(f => f.filename); + + // Detect tags from changed files using shared logic + const tags = new Set(); + + for (const comp of detectComponents(filenames)) { + tags.add(comp); + } + if (hasCoreChanges(filenames)) tags.add('core'); + if (hasDashboardChanges(filenames)) tags.add('dashboard'); + if (hasGitHubActionsChanges(filenames)) tags.add('ci'); + + if (tags.size === 0) { + return; + } + + // Check title starts with [tag] prefix + const bracketPattern = /^\[\w+\]/; + if (!bracketPattern.test(title)) { + const suggestion = [...tags].map(c => `[${c}]`).join(''); + // Skip if the suggested prefix would be too long for a readable title + if (suggestion.length > 40) { + return; + } + core.setFailed( + `PR modifies: ${[...tags].join(', ')}\n` + + `Title must start with a [tag] prefix.\n` + + `Suggested: ${suggestion} ` + ); + } From 280f874edc36ce5127d005a526c5f4537d453f70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 14:18:02 -0700 Subject: [PATCH 07/13] [rp2040] Use native time_us_64() for millis_64() (#14356) Co-authored-by: Claude Opus 4.6 --- esphome/components/rp2040/core.cpp | 12 ++++++------ esphome/core/scheduler.cpp | 11 ++++++----- esphome/core/scheduler.h | 14 +++++++------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 52c6f1185c..8b86de4be1 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -3,19 +3,19 @@ #include "core.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include "hardware/timer.h" #include "hardware/watchdog.h" namespace esphome { void HOT yield() { ::yield(); } -uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -uint64_t millis_64() { return App.scheduler.millis_64_impl_(::millis()); } +uint64_t millis_64() { return time_us_64() / 1000ULL; } +uint32_t HOT millis() { return static_cast(millis_64()); } void HOT delay(uint32_t ms) { ::delay(ms); } -uint32_t IRAM_ATTR HOT micros() { return ::micros(); } -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } +uint32_t HOT micros() { return ::micros(); } +void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { watchdog_reboot(0, 0, 10); while (1) { @@ -34,7 +34,7 @@ void HOT arch_feed_wdt() { watchdog_update(); } uint8_t progmem_read_byte(const uint8_t *addr) { return pgm_read_byte(addr); // NOLINT } -uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return ulMainGetRunTimeCounterValue(); } +uint32_t HOT arch_get_cpu_cycle_count() { return ulMainGetRunTimeCounterValue(); } uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } } // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 73b6a6c9b3..2c10e7e2da 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -28,7 +28,7 @@ static constexpr size_t MAX_POOL_SIZE = 5; // Set to 5 to match the pool size - when we have as many cancelled items as our // pool can hold, it's time to clean up and recycle them. static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5; -#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) +#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) && !defined(USE_RP2040) // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; #endif @@ -475,12 +475,13 @@ void HOT Scheduler::call(uint32_t now) { if (now_64 - last_print > 2000) { last_print = now_64; std::vector old_items; -#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) && defined(ESPHOME_THREAD_MULTI_ATOMICS) +#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) && !defined(USE_RP2040) && \ + defined(ESPHOME_THREAD_MULTI_ATOMICS) const auto last_dbg = this->last_millis_.load(std::memory_order_relaxed); const auto major_dbg = this->millis_major_.load(std::memory_order_relaxed); ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), this->scheduler_item_pool_.size(), now_64, major_dbg, last_dbg); -#elif !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) +#elif !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) && !defined(USE_RP2040) ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), this->scheduler_item_pool_.size(), now_64, this->millis_major_, this->last_millis_); #else @@ -714,7 +715,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type return total_cancelled > 0; } -#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) +#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) && !defined(USE_RP2040) uint64_t Scheduler::millis_64_impl_(uint32_t now) { // THREAD SAFETY NOTE: // This function has three implementations, based on the precompiler flags @@ -872,7 +873,7 @@ uint64_t Scheduler::millis_64_impl_(uint32_t now) { "No platform threading model defined. One of ESPHOME_THREAD_SINGLE, ESPHOME_THREAD_MULTI_NO_ATOMICS, or ESPHOME_THREAD_MULTI_ATOMICS must be defined." #endif } -#endif // !USE_ESP32 && !USE_HOST && !USE_ZEPHYR +#endif // !USE_ESP32 && !USE_HOST && !USE_ZEPHYR && !USE_RP2040 bool HOT Scheduler::SchedulerItem::cmp(const SchedulerItemPtr &a, const SchedulerItemPtr &b) { // High bits are almost always equal (change only on 32-bit rollover ~49 days) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 282f4c66ef..d52cf5147d 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -284,10 +284,10 @@ class Scheduler { bool cancel_retry_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); // Extend a 32-bit millis() value to 64-bit. Use when the caller already has a fresh now. - // On ESP32, ignores now and uses esp_timer_get_time() directly (native 64-bit). - // On non-ESP32, extends now to 64-bit using rollover tracking. + // On ESP32, Host, Zephyr, and RP2040, ignores now and uses the native 64-bit time source via millis_64(). + // On other platforms, extends now to 64-bit using rollover tracking. uint64_t millis_64_from_(uint32_t now) { -#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) || defined(USE_RP2040) (void) now; return millis_64(); #else @@ -295,7 +295,7 @@ class Scheduler { #endif } -#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) +#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) && !defined(USE_RP2040) // On platforms without native 64-bit time, millis_64() HAL function delegates to this // method which tracks 32-bit millis() rollover using millis_major_ and last_millis_. friend uint64_t millis_64(); @@ -567,8 +567,8 @@ class Scheduler { // to synchronize between tasks (see https://github.com/esphome/backlog/issues/52) std::vector scheduler_item_pool_; -#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) - // On platforms with native 64-bit time (ESP32, Host, Zephyr), no rollover tracking needed. +#if !defined(USE_ESP32) && !defined(USE_HOST) && !defined(USE_ZEPHYR) && !defined(USE_RP2040) + // On platforms with native 64-bit time (ESP32, Host, Zephyr, RP2040), no rollover tracking needed. // On other platforms, these fields track 32-bit millis() rollover for millis_64_impl_(). #ifdef ESPHOME_THREAD_MULTI_ATOMICS /* @@ -598,7 +598,7 @@ class Scheduler { #else /* not ESPHOME_THREAD_MULTI_ATOMICS */ uint16_t millis_major_{0}; #endif /* else ESPHOME_THREAD_MULTI_ATOMICS */ -#endif /* !USE_ESP32 && !USE_HOST && !USE_ZEPHYR */ +#endif /* !USE_ESP32 && !USE_HOST && !USE_ZEPHYR && !USE_RP2040 */ }; } // namespace esphome From bb567827a1e2f67a362d0a385868e438df8b71aa Mon Sep 17 00:00:00 2001 From: Laura Wratten Date: Sat, 28 Feb 2026 08:23:32 +1100 Subject: [PATCH 08/13] [sht3xd] Allow sensors that don't support serial number read (#14224) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/sht3xd/sht3xd.cpp | 43 ++++++++-------------------- esphome/components/sht3xd/sht3xd.h | 9 ------ 2 files changed, 12 insertions(+), 40 deletions(-) diff --git a/esphome/components/sht3xd/sht3xd.cpp b/esphome/components/sht3xd/sht3xd.cpp index bd3dec6fb8..8050a2d5f9 100644 --- a/esphome/components/sht3xd/sht3xd.cpp +++ b/esphome/components/sht3xd/sht3xd.cpp @@ -12,6 +12,8 @@ static const char *const TAG = "sht3xd"; // To ensure compatibility, reading serial number using the register with clock stretching register enabled // (used originally in this component) is tried first and if that fails the alternate register address // with clock stretching disabled is read. +// If both fail (e.g. some clones don't support the command), we continue so temp/humidity still work. +// Second attempt uses 10ms delay for boards that need more time before read (max permitted by ESPHome guidelines). static const uint16_t SHT3XD_COMMAND_READ_SERIAL_NUMBER_CLOCK_STRETCHING = 0x3780; static const uint16_t SHT3XD_COMMAND_READ_SERIAL_NUMBER = 0x3682; @@ -25,49 +27,28 @@ static const uint16_t SHT3XD_COMMAND_POLLING_H = 0x2400; static const uint16_t SHT3XD_COMMAND_FETCH_DATA = 0xE000; void SHT3XDComponent::setup() { - uint16_t raw_serial_number[2]; + uint16_t raw_serial_number[2]{0}; if (!this->get_register(SHT3XD_COMMAND_READ_SERIAL_NUMBER_CLOCK_STRETCHING, raw_serial_number, 2)) { - this->error_code_ = READ_SERIAL_STRETCHED_FAILED; - if (!this->get_register(SHT3XD_COMMAND_READ_SERIAL_NUMBER, raw_serial_number, 2)) { - this->error_code_ = READ_SERIAL_FAILED; - this->mark_failed(); - return; + if (!this->get_register(SHT3XD_COMMAND_READ_SERIAL_NUMBER, raw_serial_number, 2, 10)) { + ESP_LOGW(TAG, "Serial number read failed, continuing without it (clone or non-standard sensor)"); } } - this->serial_number_ = (uint32_t(raw_serial_number[0]) << 16) | uint32_t(raw_serial_number[1]); - if (!this->write_command(heater_enabled_ ? SHT3XD_COMMAND_HEATER_ENABLE : SHT3XD_COMMAND_HEATER_DISABLE)) { - this->error_code_ = WRITE_HEATER_MODE_FAILED; - this->mark_failed(); + if (!this->write_command(this->heater_enabled_ ? SHT3XD_COMMAND_HEATER_ENABLE : SHT3XD_COMMAND_HEATER_DISABLE)) { + this->mark_failed(LOG_STR("Failed to set heater mode")); return; } } void SHT3XDComponent::dump_config() { - ESP_LOGCONFIG(TAG, "SHT3xD:"); - switch (this->error_code_) { - case READ_SERIAL_FAILED: - ESP_LOGD(TAG, " Error reading serial number"); - break; - case WRITE_HEATER_MODE_FAILED: - ESP_LOGD(TAG, " Error writing heater mode"); - break; - default: - break; - } - if (this->is_failed()) { - ESP_LOGE(TAG, " Communication with SHT3xD failed!"); - return; - } - ESP_LOGD(TAG, - " Serial Number: 0x%08" PRIX32 "\n" - " Heater Enabled: %s", - this->serial_number_, TRUEFALSE(this->heater_enabled_)); - + ESP_LOGCONFIG(TAG, + "SHT3xD:\n" + " Serial Number: 0x%08" PRIX32 "\n" + " Heater Enabled: %s", + this->serial_number_, TRUEFALSE(this->heater_enabled_)); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } diff --git a/esphome/components/sht3xd/sht3xd.h b/esphome/components/sht3xd/sht3xd.h index 43f1a4d8e2..54514d6de7 100644 --- a/esphome/components/sht3xd/sht3xd.h +++ b/esphome/components/sht3xd/sht3xd.h @@ -4,8 +4,6 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" -#include - namespace esphome { namespace sht3xd { @@ -21,13 +19,6 @@ class SHT3XDComponent : public PollingComponent, public sensirion_common::Sensir void set_heater_enabled(bool heater_enabled) { heater_enabled_ = heater_enabled; } protected: - enum ErrorCode { - NONE = 0, - READ_SERIAL_STRETCHED_FAILED, - READ_SERIAL_FAILED, - WRITE_HEATER_MODE_FAILED, - } error_code_{NONE}; - sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; bool heater_enabled_{true}; From 5e3857abf7dded7d731cdd5663b23ca0edb6491f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:25:36 -0500 Subject: [PATCH 09/13] Bump click from 8.1.7 to 8.3.1 (#11955) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 95e3710f9e..f111e05a9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ tzdata>=2021.1 # from time pyserial==3.5 platformio==6.1.19 esptool==5.2.0 -click==8.1.7 +click==8.3.1 esphome-dashboard==20260210.0 aioesphomeapi==44.2.0 zeroconf==0.148.0 From b9d70dcda2d5ff6bad34c6c72ae12f5c9b9cd6e6 Mon Sep 17 00:00:00 2001 From: Martin Ebner <185941678+mebner86@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:11:28 +0530 Subject: [PATCH 10/13] [sen6x] Add SEN6x sensor support (#12553) Co-authored-by: Martin Ebner Co-authored-by: Tobias Stanzel Co-authored-by: Big Mike Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/sen6x/__init__.py | 0 esphome/components/sen6x/sen6x.cpp | 376 +++++++++++++++++++ esphome/components/sen6x/sen6x.h | 43 +++ esphome/components/sen6x/sensor.py | 149 ++++++++ tests/components/sen6x/common.yaml | 37 ++ tests/components/sen6x/test.esp32-idf.yaml | 4 + tests/components/sen6x/test.esp8266-ard.yaml | 4 + tests/components/sen6x/test.rp2040-ard.yaml | 4 + 9 files changed, 618 insertions(+) create mode 100644 esphome/components/sen6x/__init__.py create mode 100644 esphome/components/sen6x/sen6x.cpp create mode 100644 esphome/components/sen6x/sen6x.h create mode 100644 esphome/components/sen6x/sensor.py create mode 100644 tests/components/sen6x/common.yaml create mode 100644 tests/components/sen6x/test.esp32-idf.yaml create mode 100644 tests/components/sen6x/test.esp8266-ard.yaml create mode 100644 tests/components/sen6x/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 6728e76bba..4c97b7f99d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -429,6 +429,7 @@ esphome/components/select/* @esphome/core esphome/components/sen0321/* @notjj esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras +esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/sfa30/* @ghsensdev diff --git a/esphome/components/sen6x/__init__.py b/esphome/components/sen6x/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp new file mode 100644 index 0000000000..baaadd6463 --- /dev/null +++ b/esphome/components/sen6x/sen6x.cpp @@ -0,0 +1,376 @@ +#include "sen6x.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include +#include +#include + +namespace esphome::sen6x { + +static const char *const TAG = "sen6x"; + +static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; +static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; +static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; +static constexpr uint16_t SEN6X_CMD_GET_SERIAL_NUMBER = 0xD033; + +static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT = 0x0300; // SEN66 only! +static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN62 = 0x04A3; +static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN63C = 0x0471; +static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN65 = 0x0446; +static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; +static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; + +static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; +static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; + +static inline void set_read_command_and_words(SEN6XComponent::Sen6xType type, uint16_t &read_cmd, uint8_t &read_words) { + read_cmd = SEN6X_CMD_READ_MEASUREMENT; + read_words = 9; + switch (type) { + case SEN6XComponent::SEN62: + read_cmd = SEN6X_CMD_READ_MEASUREMENT_SEN62; + read_words = 6; + break; + case SEN6XComponent::SEN63C: + read_cmd = SEN6X_CMD_READ_MEASUREMENT_SEN63C; + read_words = 7; + break; + case SEN6XComponent::SEN65: + read_cmd = SEN6X_CMD_READ_MEASUREMENT_SEN65; + read_words = 8; + break; + case SEN6XComponent::SEN66: + read_cmd = SEN6X_CMD_READ_MEASUREMENT; + read_words = 9; + break; + case SEN6XComponent::SEN68: + read_cmd = SEN6X_CMD_READ_MEASUREMENT_SEN68; + read_words = 9; + break; + case SEN6XComponent::SEN69C: + read_cmd = SEN6X_CMD_READ_MEASUREMENT_SEN69C; + read_words = 10; + break; + default: + break; + } +} + +void SEN6XComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up sen6x..."); + + // the sensor needs 100 ms to enter the idle state + this->set_timeout(100, [this]() { + // Reset the sensor to ensure a clean state regardless of prior commands or power issues + if (!this->write_command(SEN6X_CMD_RESET)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // After reset the sensor needs 100 ms to become ready + this->set_timeout(100, [this]() { + // Step 1: Read serial number (~25ms with I2C delay) + uint16_t raw_serial_number[16]; + if (!this->get_register(SEN6X_CMD_GET_SERIAL_NUMBER, raw_serial_number, 16, 20)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->serial_number_ = SEN6XComponent::sensirion_convert_to_string_in_place(raw_serial_number, 16); + ESP_LOGI(TAG, "Serial number: %s", this->serial_number_.c_str()); + + // Step 2: Read product name in next loop iteration + this->set_timeout(0, [this]() { + uint16_t raw_product_name[16]; + if (!this->get_register(SEN6X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + this->product_name_ = SEN6XComponent::sensirion_convert_to_string_in_place(raw_product_name, 16); + + Sen6xType inferred_type = this->infer_type_from_product_name_(this->product_name_); + if (this->sen6x_type_ == UNKNOWN) { + this->sen6x_type_ = inferred_type; + if (inferred_type == UNKNOWN) { + ESP_LOGE(TAG, "Unknown product '%s'", this->product_name_.c_str()); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Type inferred from product: %s", this->product_name_.c_str()); + } else if (this->sen6x_type_ != inferred_type && inferred_type != UNKNOWN) { + ESP_LOGW(TAG, "Configured type (used) mismatches product '%s'", this->product_name_.c_str()); + } + ESP_LOGI(TAG, "Product: %s", this->product_name_.c_str()); + + // Validate configured sensors against detected type and disable unsupported ones + const bool has_voc_nox = (this->sen6x_type_ == SEN65 || this->sen6x_type_ == SEN66 || + this->sen6x_type_ == SEN68 || this->sen6x_type_ == SEN69C); + const bool has_co2 = (this->sen6x_type_ == SEN63C || this->sen6x_type_ == SEN66 || this->sen6x_type_ == SEN69C); + const bool has_hcho = (this->sen6x_type_ == SEN68 || this->sen6x_type_ == SEN69C); + if (this->voc_sensor_ && !has_voc_nox) { + ESP_LOGE(TAG, "VOC requires SEN65, SEN66, SEN68, or SEN69C"); + this->voc_sensor_ = nullptr; + } + if (this->nox_sensor_ && !has_voc_nox) { + ESP_LOGE(TAG, "NOx requires SEN65, SEN66, SEN68, or SEN69C"); + this->nox_sensor_ = nullptr; + } + if (this->co2_sensor_ && !has_co2) { + ESP_LOGE(TAG, "CO2 requires SEN63C, SEN66, or SEN69C"); + this->co2_sensor_ = nullptr; + } + if (this->hcho_sensor_ && !has_hcho) { + ESP_LOGE(TAG, "Formaldehyde requires SEN68 or SEN69C"); + this->hcho_sensor_ = nullptr; + } + + // Step 3: Read firmware version and start measurements in next loop iteration + this->set_timeout(0, [this]() { + uint16_t raw_firmware_version = 0; + if (!this->get_register(SEN6X_CMD_GET_FIRMWARE_VERSION, raw_firmware_version, 20)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->firmware_version_major_ = (raw_firmware_version >> 8) & 0xFF; + this->firmware_version_minor_ = raw_firmware_version & 0xFF; + ESP_LOGI(TAG, "Firmware: %u.%u", this->firmware_version_major_, this->firmware_version_minor_); + + if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + this->set_timeout(60000, [this]() { this->startup_complete_ = true; }); + this->initialized_ = true; + ESP_LOGD(TAG, "Initialized"); + }); + }); + }); + }); +} + +void SEN6XComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "sen6x:\n" + " Product: %s\n" + " Serial: %s\n" + " Firmware: %u.%u\n" + " Address: 0x%02X", + this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, + this->firmware_version_minor_, this->address_); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); + LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); + LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_); + LOG_SENSOR(" ", "PM 10.0", this->pm_10_0_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "VOC", this->voc_sensor_); + LOG_SENSOR(" ", "NOx", this->nox_sensor_); + LOG_SENSOR(" ", "HCHO", this->hcho_sensor_); + LOG_SENSOR(" ", "CO2", this->co2_sensor_); +} + +void SEN6XComponent::update() { + if (!this->initialized_) { + return; + } + + uint16_t read_cmd; + uint8_t read_words; + set_read_command_and_words(this->sen6x_type_, read_cmd, read_words); + + const uint8_t poll_retries = 24; + auto poll_ready = std::make_shared>(); + *poll_ready = [this, poll_ready, read_cmd, read_words](uint8_t retries_left) { + const uint8_t attempt = static_cast(poll_retries - retries_left + 1); + ESP_LOGV(TAG, "Data ready polling attempt %u", attempt); + + if (!this->write_command(SEN6X_CMD_GET_DATA_READY_STATUS)) { + this->status_set_warning(); + ESP_LOGD(TAG, "write data ready status error (%d)", this->last_error_); + return; + } + + this->set_timeout(20, [this, poll_ready, retries_left, read_cmd, read_words]() { + uint16_t raw_read_status; + if (!this->read_data(&raw_read_status, 1)) { + this->status_set_warning(); + ESP_LOGD(TAG, "read data ready status error (%d)", this->last_error_); + return; + } + + if ((raw_read_status & 0x0001) == 0) { + if (retries_left == 0) { + this->status_set_warning(); + ESP_LOGD(TAG, "Data not ready"); + return; + } + this->set_timeout(50, [poll_ready, retries_left]() { (*poll_ready)(retries_left - 1); }); + return; + } + + if (!this->write_command(read_cmd)) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read measurement failed (%d)", this->last_error_); + return; + } + + this->set_timeout(20, [this, read_words]() { + uint16_t measurements[10]; + + if (!this->read_data(measurements, read_words)) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_); + return; + } + int8_t voc_index = -1; + int8_t nox_index = -1; + int8_t hcho_index = -1; + int8_t co2_index = -1; + bool co2_uint16 = false; + switch (this->sen6x_type_) { + case SEN62: + break; + case SEN63C: + co2_index = 6; + break; + case SEN65: + voc_index = 6; + nox_index = 7; + break; + case SEN66: + voc_index = 6; + nox_index = 7; + co2_index = 8; + co2_uint16 = true; + break; + case SEN68: + voc_index = 6; + nox_index = 7; + hcho_index = 8; + break; + case SEN69C: + voc_index = 6; + nox_index = 7; + hcho_index = 8; + co2_index = 9; + break; + default: + break; + } + + float pm_1_0 = measurements[0] / 10.0f; + if (measurements[0] == 0xFFFF) + pm_1_0 = NAN; + float pm_2_5 = measurements[1] / 10.0f; + if (measurements[1] == 0xFFFF) + pm_2_5 = NAN; + float pm_4_0 = measurements[2] / 10.0f; + if (measurements[2] == 0xFFFF) + pm_4_0 = NAN; + float pm_10_0 = measurements[3] / 10.0f; + if (measurements[3] == 0xFFFF) + pm_10_0 = NAN; + float humidity = static_cast(measurements[4]) / 100.0f; + if (measurements[4] == 0x7FFF) + humidity = NAN; + float temperature = static_cast(measurements[5]) / 200.0f; + if (measurements[5] == 0x7FFF) + temperature = NAN; + + float voc = NAN; + float nox = NAN; + float hcho = NAN; + float co2 = NAN; + + if (voc_index >= 0) { + voc = static_cast(measurements[voc_index]) / 10.0f; + if (measurements[voc_index] == 0x7FFF) + voc = NAN; + } + if (nox_index >= 0) { + nox = static_cast(measurements[nox_index]) / 10.0f; + if (measurements[nox_index] == 0x7FFF) + nox = NAN; + } + + if (hcho_index >= 0) { + const uint16_t hcho_raw = measurements[hcho_index]; + hcho = hcho_raw / 10.0f; + if (hcho_raw == 0xFFFF) + hcho = NAN; + } + + if (co2_index >= 0) { + if (co2_uint16) { + const uint16_t co2_raw = measurements[co2_index]; + co2 = static_cast(co2_raw); + if (co2_raw == 0xFFFF) + co2 = NAN; + } else { + const int16_t co2_raw = static_cast(measurements[co2_index]); + co2 = static_cast(co2_raw); + if (co2_raw == 0x7FFF) + co2 = NAN; + } + } + + if (!this->startup_complete_) { + ESP_LOGD(TAG, "Startup delay, ignoring values"); + this->status_clear_warning(); + return; + } + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5); + if (this->pm_4_0_sensor_ != nullptr) + this->pm_4_0_sensor_->publish_state(pm_4_0); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(humidity); + if (this->voc_sensor_ != nullptr) + this->voc_sensor_->publish_state(voc); + if (this->nox_sensor_ != nullptr) + this->nox_sensor_->publish_state(nox); + if (this->hcho_sensor_ != nullptr) + this->hcho_sensor_->publish_state(hcho); + if (this->co2_sensor_ != nullptr) + this->co2_sensor_->publish_state(co2); + + this->status_clear_warning(); + }); + }); + }; + + (*poll_ready)(poll_retries); +} + +SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) { + if (product_name == "SEN62") + return SEN62; + if (product_name == "SEN63C") + return SEN63C; + if (product_name == "SEN65") + return SEN65; + if (product_name == "SEN66") + return SEN66; + if (product_name == "SEN68") + return SEN68; + if (product_name == "SEN69C") + return SEN69C; + return UNKNOWN; +} + +} // namespace esphome::sen6x diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h new file mode 100644 index 0000000000..01e89dce1b --- /dev/null +++ b/esphome/components/sen6x/sen6x.h @@ -0,0 +1,43 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/sensirion_common/i2c_sensirion.h" + +namespace esphome::sen6x { + +class SEN6XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { + SUB_SENSOR(pm_1_0) + SUB_SENSOR(pm_2_5) + SUB_SENSOR(pm_4_0) + SUB_SENSOR(pm_10_0) + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(voc) + SUB_SENSOR(nox) + SUB_SENSOR(co2) + SUB_SENSOR(hcho) + + public: + float get_setup_priority() const override { return setup_priority::DATA; } + void setup() override; + void dump_config() override; + void update() override; + + enum Sen6xType { SEN62, SEN63C, SEN65, SEN66, SEN68, SEN69C, UNKNOWN }; + + void set_type(const std::string &type) { sen6x_type_ = infer_type_from_product_name_(type); } + + protected: + Sen6xType infer_type_from_product_name_(const std::string &product_name); + + bool initialized_{false}; + std::string product_name_; + Sen6xType sen6x_type_{UNKNOWN}; + std::string serial_number_; + uint8_t firmware_version_major_{0}; + uint8_t firmware_version_minor_{0}; + bool startup_complete_{false}; +}; + +} // namespace esphome::sen6x diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py new file mode 100644 index 0000000000..071478e719 --- /dev/null +++ b/esphome/components/sen6x/sensor.py @@ -0,0 +1,149 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensirion_common, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_CO2, + CONF_FORMALDEHYDE, + CONF_HUMIDITY, + CONF_ID, + CONF_NOX, + CONF_PM_1_0, + CONF_PM_2_5, + CONF_PM_4_0, + CONF_PM_10_0, + CONF_TEMPERATURE, + CONF_TYPE, + CONF_VOC, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_TEMPERATURE, + ICON_CHEMICAL_WEAPON, + ICON_MOLECULE_CO2, + ICON_RADIATOR, + ICON_THERMOMETER, + ICON_WATER_PERCENT, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_MICROGRAMS_PER_CUBIC_METER, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, +) + +CODEOWNERS = ["@martgras", "@mebner86", "@mikelawrence", "@tuct"] +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["sensirion_common"] + +sen6x_ns = cg.esphome_ns.namespace("sen6x") +SEN6XComponent = sen6x_ns.class_( + "SEN6XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(SEN6XComponent), + cv.Optional(CONF_TYPE): cv.one_of( + "SEN62", "SEN63C", "SEN65", "SEN66", "SEN68", "SEN69C", upper=True + ), + cv.Optional(CONF_PM_1_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + device_class=DEVICE_CLASS_PM1, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PM_2_5): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + device_class=DEVICE_CLASS_PM25, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PM_4_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PM_10_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + device_class=DEVICE_CLASS_PM10, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_WATER_PERCENT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_VOC): sensor.sensor_schema( + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_NOX): sensor.sensor_schema( + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_FORMALDEHYDE): sensor.sensor_schema( + unit_of_measurement="ppb", + icon=ICON_RADIATOR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x6B)) +) + +SENSOR_MAP = { + CONF_PM_1_0: "set_pm_1_0_sensor", + CONF_PM_2_5: "set_pm_2_5_sensor", + CONF_PM_4_0: "set_pm_4_0_sensor", + CONF_PM_10_0: "set_pm_10_0_sensor", + CONF_TEMPERATURE: "set_temperature_sensor", + CONF_HUMIDITY: "set_humidity_sensor", + CONF_VOC: "set_voc_sensor", + CONF_NOX: "set_nox_sensor", + CONF_CO2: "set_co2_sensor", + CONF_FORMALDEHYDE: "set_hcho_sensor", +} + + +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 CONF_TYPE in config: + cg.add(var.set_type(config[CONF_TYPE])) + + for key, func_name in SENSOR_MAP.items(): + if cfg := config.get(key): + sens = await sensor.new_sensor(cfg) + cg.add(getattr(var, func_name)(sens)) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml new file mode 100644 index 0000000000..fdb8a485e2 --- /dev/null +++ b/tests/components/sen6x/common.yaml @@ -0,0 +1,37 @@ +sensor: + # Test with explicit type parameter + - platform: sen6x + id: sen6x_sensor + type: SEN69C + i2c_id: i2c_bus + temperature: + name: Temperature + accuracy_decimals: 1 + humidity: + name: Humidity + accuracy_decimals: 0 + pm_1_0: + name: PM <1µm Weight concentration + id: pm_1_0 + accuracy_decimals: 1 + pm_2_5: + name: PM <2.5µm Weight concentration + id: pm_2_5 + accuracy_decimals: 1 + pm_4_0: + name: PM <4µm Weight concentration + id: pm_4_0 + accuracy_decimals: 1 + pm_10_0: + name: PM <10µm Weight concentration + id: pm_10_0 + accuracy_decimals: 1 + nox: + name: NOx + voc: + name: VOC + co2: + name: Carbon Dioxide + formaldehyde: + name: Formaldehyde + address: 0x6B diff --git a/tests/components/sen6x/test.esp32-idf.yaml b/tests/components/sen6x/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/sen6x/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/sen6x/test.esp8266-ard.yaml b/tests/components/sen6x/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/sen6x/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/sen6x/test.rp2040-ard.yaml b/tests/components/sen6x/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/sen6x/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From 5c56b99742a12cfde9cbeb0f91641db96cd132d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 16:19:11 -0700 Subject: [PATCH 11/13] [ci] Fix C++ unit tests missing time component dependency (#14364) --- script/cpp_unit_test.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index 78b65092ae..02b133060a 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -98,8 +98,12 @@ def run_tests(selected_components: list[str]) -> int: components = sorted(components) - # Obtain possible dependencies for the requested components: - components_with_dependencies = sorted(get_all_dependencies(set(components))) + # Obtain possible dependencies for the requested components. + # Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag, + # which causes core/time.h to include components/time/posix_tz.h. + components_with_dependencies = sorted( + get_all_dependencies(set(components) | {"time"}) + ) # Build a list of include folders, one folder per component containing tests. # A special replacement main.cpp is located in /tests/components/main.cpp From 298ee7b92e4d0a1ad17fcd3995a689deadcb890f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:08:42 -0500 Subject: [PATCH 12/13] [gps] Fix codegen deadlock when automations reference sibling sensors (#14365) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/gps/__init__.py | 44 ++++++++++++------------------ tests/components/gps/common.yaml | 7 +++++ 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index 2135189bd5..045a5a6c84 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -98,33 +98,25 @@ async def to_code(config): await cg.register_component(var, config) await uart.register_uart_device(var, config) - if latitude_config := config.get(CONF_LATITUDE): - sens = await sensor.new_sensor(latitude_config) - cg.add(var.set_latitude_sensor(sens)) + # Pre-create all sensor variables so automations that reference + # sibling sensors don't deadlock waiting for unregistered IDs. + sensors = [ + (cg.new_Pvariable(conf[CONF_ID]), conf, setter) + for key, setter in ( + (CONF_LATITUDE, "set_latitude_sensor"), + (CONF_LONGITUDE, "set_longitude_sensor"), + (CONF_SPEED, "set_speed_sensor"), + (CONF_COURSE, "set_course_sensor"), + (CONF_ALTITUDE, "set_altitude_sensor"), + (CONF_SATELLITES, "set_satellites_sensor"), + (CONF_HDOP, "set_hdop_sensor"), + ) + if (conf := config.get(key)) + ] - if longitude_config := config.get(CONF_LONGITUDE): - sens = await sensor.new_sensor(longitude_config) - cg.add(var.set_longitude_sensor(sens)) - - if speed_config := config.get(CONF_SPEED): - sens = await sensor.new_sensor(speed_config) - cg.add(var.set_speed_sensor(sens)) - - if course_config := config.get(CONF_COURSE): - sens = await sensor.new_sensor(course_config) - cg.add(var.set_course_sensor(sens)) - - if altitude_config := config.get(CONF_ALTITUDE): - sens = await sensor.new_sensor(altitude_config) - cg.add(var.set_altitude_sensor(sens)) - - if satellites_config := config.get(CONF_SATELLITES): - sens = await sensor.new_sensor(satellites_config) - cg.add(var.set_satellites_sensor(sens)) - - if hdop_config := config.get(CONF_HDOP): - sens = await sensor.new_sensor(hdop_config) - cg.add(var.set_hdop_sensor(sens)) + for sens, conf, setter in sensors: + await sensor.register_sensor(sens, conf) + cg.add(getattr(var, setter)(sens)) # https://platformio.org/lib/show/1655/TinyGPSPlus # Using fork of TinyGPSPlus patched to build on ESP-IDF diff --git a/tests/components/gps/common.yaml b/tests/components/gps/common.yaml index a99e3ef7e0..568f2cc7c7 100644 --- a/tests/components/gps/common.yaml +++ b/tests/components/gps/common.yaml @@ -1,8 +1,15 @@ gps: latitude: name: "Latitude" + id: gps_lat + on_value: + then: + - logger.log: + format: "%.6f, %.6f" + args: [id(gps_lat).state, id(gps_long).state] longitude: name: "Longitude" + id: gps_long altitude: name: "Altitude" speed: From d1b481319773a38d4a24cae0a4e7c86ea0ab1908 Mon Sep 17 00:00:00 2001 From: Ryan Wagoner <8441200+rwagoner@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:20:13 -0500 Subject: [PATCH 13/13] [web_server] Add climate preset, fan mode, and humidity support (#14061) --- esphome/components/web_server/web_server.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 16674321c9..4824e33dcd 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1490,6 +1490,7 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url parse_string_param_(request, ESPHOME_F("mode"), call, &decltype(call)::set_mode); parse_string_param_(request, ESPHOME_F("fan_mode"), call, &decltype(call)::set_fan_mode); parse_string_param_(request, ESPHOME_F("swing_mode"), call, &decltype(call)::set_swing_mode); + parse_string_param_(request, ESPHOME_F("preset"), call, &decltype(call)::set_preset); // Parse temperature parameters // static_cast needed to disambiguate overloaded setters (float vs optional) @@ -1530,7 +1531,7 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json JsonArray opt = root[ESPHOME_F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); - if (!traits.get_supported_custom_fan_modes().empty()) { + if (traits.get_supports_fan_modes()) { JsonArray opt = root[ESPHOME_F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); @@ -1546,12 +1547,12 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json for (auto swing_mode : traits.get_supported_swing_modes()) opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); } - if (traits.get_supports_presets() && obj->preset.has_value()) { + if (traits.get_supports_presets()) { JsonArray opt = root[ESPHOME_F("presets")].to(); 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->has_custom_preset()) { + if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) opt.add(custom_preset); @@ -1592,6 +1593,11 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json ? "NA" : (value_accuracy_to_buf(temp_buf, obj->current_temperature, current_accuracy), temp_buf); } + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) { + root[ESPHOME_F("current_humidity")] = std::isnan(obj->current_humidity) + ? "NA" + : (value_accuracy_to_buf(temp_buf, obj->current_humidity, 0), temp_buf); + } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { root[ESPHOME_F("target_temperature_low")] =