diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index bb85ccd681..1d76c18be8 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -70,6 +70,7 @@ async function isStackedPr(github, context) { async function detectMergeBranch(github, context) { const labels = new Set(); const baseRef = context.payload.pull_request.base.ref; + const defaultBranch = context.payload.repository.default_branch; if (baseRef === 'release') { labels.add('merging-to-release'); @@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) { } else if (await isStackedPr(github, context)) { // GitHub manages the merge order for a stack, so these are not blocked. labels.add('stacked-pr'); - } else if (baseRef !== 'dev') { + } else if (baseRef !== defaultBranch) { // A chain built by hand: it must not merge until its base branch does. labels.add('chained-pr'); } diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index f30ceff8c1..be239e2f1b 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; // Builds a fresh context for detectMergeBranch tests instead of mutating the // shared CONTEXT fixture above (which other describe blocks rely on). -function makeMergeContext(baseRef, { stack } = {}) { +function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) { const pull_request = { number: 1, base: { ref: baseRef } }; if (stack !== undefined) { pull_request.stack = stack; } return { repo: { owner: 'esphome', repo: 'esphome' }, - payload: { pull_request } + payload: { pull_request, repository: { default_branch: defaultBranch } } }; } @@ -136,6 +136,21 @@ describe('detectMergeBranch', () => { assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); assert.equal(state.calls, 1); }); + + it('base ref matches default branch adds no labels', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('other', { defaultBranch: 'other' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), []); + }); + + it('base ref dev when the default branch is main adds chained-pr', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('dev', { defaultBranch: 'main' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + }); + }); // --------------------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e695bb46b..026c2ba27a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,7 @@ jobs: . venv/bin/activate script/ci-custom.py script/build_codeowners.py --check + script/build_alias_registry.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2-boards.py --check @@ -444,8 +445,12 @@ jobs: - common - determine-jobs if: >- - (github.event_name == 'push' && github.ref_name == 'dev') || - (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') + github.repository == 'esphome/esphome' && ( + (github.event_name == 'push' && github.ref_name == 'dev') || + (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') + ) + # CodSpeed benchmarks require a CodSpeed account linked to the repository to run + # (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself. steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/docker/Dockerfile b/docker/Dockerfile index d7ae2cd4ec..2f23b2f690 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 RUN \ platformio settings set enable_telemetry No \ diff --git a/esphome/__main__.py b/esphome/__main__.py index 1262a4525e..c1e05d2ea7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2732,7 +2732,8 @@ def run_esphome(argv): conf_path.name, ) - if config is None: + cache_missed = config is None + if cache_missed: from esphome.config import read_config config = read_config( @@ -2741,26 +2742,25 @@ def run_esphome(argv): # Snapshot only needed by `esphome config --no-defaults`. snapshot_user_config=getattr(args, "no_defaults", False), ) - # Refresh the cache so the next upload/logs hits the fast path - # instead of re-running read_config. Skip when the storage - # sidecar is absent (no compile has run): the cache would - # never be loaded back, so writing secrets to disk is wasted. - if cache_eligible and config is not None: - from esphome.compiled_config import save_compiled_config - from esphome.storage_json import ext_storage_path - - if ext_storage_path(conf_path.name).exists(): - save_compiled_config(config) - if config is None: - return 2 + if config is None: + return 2 CORE.config = config # Fallback for platforms whose validators didn't set the toolchain # (only the esp32 component reads esp32.framework.toolchain). All - # other platforms only support PlatformIO today. + # other platforms only support PlatformIO today. Must run before the + # cache refresh below so its sidecar records the same toolchain a + # compile would. if CORE.toolchain is None: CORE.toolchain = Toolchain.PLATFORMIO + # Refresh the cache so the next upload/logs hits the fast path + # instead of re-running read_config. + if cache_eligible and cache_missed: + from esphome.compiled_config import save_compiled_config_and_sidecar + + save_compiled_config_and_sidecar(config) + if args.command not in POST_CONFIG_ACTIONS: safe_print(f"Unknown command {args.command}") return 1 diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 303af99e66..be03eea965 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -18,9 +18,9 @@ from pathlib import Path from typing import Any from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, Lambda +from esphome.core import CORE, EsphomeError, Lambda from esphome.helpers import write_file -from esphome.storage_json import StorageJSON, ext_storage_path +from esphome.storage_json import StorageJSON, ext_storage_path, storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,71 @@ def save_compiled_config(config: ConfigType) -> None: # non-basic dict key), so every upload/logs pays the slow path. _LOGGER.warning("Cannot cache the validated config: %s", err) except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - _LOGGER.debug("Skipping compiled config cache write: %s", err) + # Likely persistent (permissions, full disk): every upload/logs + # pays the slow path until it clears, so surface it. + _LOGGER.warning("Skipping compiled config cache write: %s", err) + + +def save_compiled_config_and_sidecar(config: ConfigType) -> None: + """Refresh the cache from the upload/logs fallback (CORE.config must be set). + + The cache is only written when a complete sidecar is on disk: + load_compiled_config can't use it otherwise, and it holds resolved + secrets. + """ + if _refresh_sidecar(): + save_compiled_config(config) + + +def _refresh_sidecar() -> bool: + """Ensure a complete sidecar is on disk; True when one is. + + Writes one (without claiming a build) when missing or wizard-only. + Failures are non-fatal; the next upload/logs pays the slow path again. + """ + try: + path = storage_path() + try: + old = StorageJSON.load_strict(path) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # Present but unreadable: it may hold a real build's metadata, + # and a fresh rewrite would also stop the next compile from + # cleaning a possibly incoherent build tree. + _LOGGER.warning( + "Not caching: storage sidecar %s is unreadable (%s)", path, err + ) + return False + if old is not None and old.can_apply_to_core(): + # Compile-written; nothing to refresh. + return True + if CORE.build_path is not None and CORE.build_path.exists(): + # An unvalidated build tree: its absent or mismatched sidecar + # is what makes the next compile wipe it, so don't vouch for + # a build this run never saw. + _LOGGER.warning( + "Not caching: build tree %s has no matching sidecar; " + "'esphome compile' will settle it", + CORE.build_path, + ) + return False + new = StorageJSON.from_esphome_core(CORE, old, claim_build=False) + if not new.can_apply_to_core(): + _LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete") + return False + new.save(path) + return True + except (OSError, EsphomeError) as err: + # write_file wraps OSError into EsphomeError. Persistent + # (unwritable storage dir), so surface that every upload/logs + # pays the slow path. + _LOGGER.warning("Could not refresh the storage sidecar: %s", err) + except Exception: # noqa: BLE001 # pylint: disable=broad-except + # A structural bug; keep the traceback so it isn't mistaken + # for the I/O failure above. + _LOGGER.warning( + "Unexpected error refreshing the storage sidecar", exc_info=True + ) + return False def load_compiled_config(conf_path: Path) -> ConfigType | None: @@ -98,11 +162,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) - if storage is None: - return None - # apply_to_core assumes a real compile wrote the sidecar; wizard-only - # sidecars leave both of these unset and can't drive upload/logs. - if not storage.core_platform and not storage.target_platform: + if storage is None or not storage.can_apply_to_core(): + _LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete") return None storage.apply_to_core() return config diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py new file mode 100644 index 0000000000..e701bd98d4 --- /dev/null +++ b/esphome/component_aliases.py @@ -0,0 +1,10 @@ +"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "rp2040": ("rp2", "2027.7.0"), +} diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 16c86aee18..5ca58df10e 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -3,7 +3,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.common"; +static const char *const TAG = "adc"; const LogString *sampling_mode_to_str(SamplingMode mode) { switch (mode) { diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index a761b37749..a0f7a1ed08 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -6,7 +6,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.esp32"; +static const char *const TAG = "adc"; adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr}; diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index e4f2f82f08..77a192e025 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC) namespace esphome::adc { -static const char *const TAG = "adc.esp8266"; +static const char *const TAG = "adc"; void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index d9b9f50be1..dfa545b395 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -5,7 +5,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.libretiny"; +static const char *const TAG = "adc"; void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 8652a46029..ce665e8501 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -17,7 +17,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.rp2"; +static const char *const TAG = "adc"; // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp index c3632b00e2..bf45059740 100644 --- a/esphome/components/adc/adc_sensor_zephyr.cpp +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -7,7 +7,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.zephyr"; +static const char *const TAG = "adc"; void ADCSensor::setup() { if (!adc_is_ready_dt(this->channel_)) { diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index c43e4cac87..dd1615888c 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -45,7 +45,7 @@ CODEOWNERS = ["@esphome/core"] # Keep in sync with platformio.ini and esphome/idf_component.yml. # LIBSODIUM_VERSION must match the version noise-c pins in its idf_component.yml. -NOISE_C_VERSION = "0.1.15" +NOISE_C_VERSION = "0.1.18" LIBSODIUM_VERSION = "1.10021.2" diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d05f98d03b..2eb8c21c73 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -160,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr sock, APIServer *pa #else #error "No frame helper defined" #endif -#ifdef USE_CAMERA - if (camera::Camera::instance() != nullptr) { - this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()}; - } -#endif } void APIConnection::start() { @@ -448,7 +443,7 @@ void APIConnection::on_disconnect_response() { uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif @@ -459,7 +454,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { // Set common fields that are shared by all entity types - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); if (entity->has_own_name()) { msg.name = entity->get_name(); @@ -1140,6 +1135,7 @@ void APIConnection::try_send_camera_image_() { if (!this->image_reader_) return; + const auto *cam = camera::Camera::instance(); // Send as many chunks as possible without blocking while (this->image_reader_->available()) { if (!this->helper_->can_write_without_blocking()) @@ -1149,11 +1145,11 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_entity_key(); + msg.key = cam->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES - msg.device_id = camera::Camera::instance()->get_device_id(); + msg.device_id = cam->get_device_id(); #endif if (!this->send_message(msg)) { @@ -1169,15 +1165,19 @@ void APIConnection::try_send_camera_image_() { void APIConnection::set_camera_state(std::shared_ptr image) { if (!this->flags_.state_subscription) return; - if (!this->image_reader_) + if (this->image_reader_ && this->image_reader_->available()) return; - if (this->image_reader_->available()) + if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE)) return; - if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) { - this->image_reader_->set_image(std::move(image)); - // Try to send immediately to reduce latency - this->try_send_camera_image_(); + if (!this->image_reader_) { + // Created on the first image this connection will send, so connections + // that never receive one never pay for a reader. Only a registered + // camera's listener can reach this, so instance() is non-null here. + this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()}; } + this->image_reader_->set_image(std::move(image)); + // Try to send immediately to reduce latency + this->try_send_camera_image_(); } uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *camera = static_cast(entity); diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 225bac51a6..09e3ca2b9e 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -591,18 +591,21 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { */ APIError APINoiseFrameHelper::init_handshake_() { int err; - memset(&nid_, 0, sizeof(nid_)); - // const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; - // err = noise_protocol_name_to_id(&nid_, proto, strlen(proto)); - nid_.pattern_id = NOISE_PATTERN_NN; - nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY; - nid_.dh_id = NOISE_DH_CURVE25519; - nid_.prefix_id = NOISE_PREFIX_STANDARD; - nid_.hybrid_id = NOISE_DH_NONE; - nid_.hash_id = NOISE_HASH_SHA256; - nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0; + // Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack: + // noise_handshakestate_new_by_id copies it, so a member would waste + // 104 bytes per connection, and a static const would sit in RAM on + // ESP8266 (.rodata is DRAM there). + const NoiseProtocolId nid = { + .prefix_id = NOISE_PREFIX_STANDARD, + .pattern_id = NOISE_PATTERN_NN, + .modifier_ids = {NOISE_MODIFIER_PSK0}, + .dh_id = NOISE_DH_CURVE25519, + .cipher_id = NOISE_CIPHER_CHACHAPOLY, + .hash_id = NOISE_HASH_SHA256, + .hybrid_id = NOISE_DH_NONE, + }; - err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER); + err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER); APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index b0ba9fd01c..46bd366672 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -63,9 +63,6 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Buffer for noise handshake prologue (released after handshake) APIBuffer prologue_; - // NoiseProtocolId (size depends on implementation) - NoiseProtocolId nid_; - // Group small types together // Fixed-size header buffer for noise protocol: // 1 byte for indicator + 2 bytes for message size (16-bit value, not varint) diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 23f3d06184..b58464a1f6 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build on this component and contain no SDK calls of their own. Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time, -not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken -BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only -for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail -with a clear #error. +(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in +to_code; unknown families are capability-checked at compile time via +`__has_include("app_ble.h")`, a header only on the BLE 5.x include path +(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build +fails with a clear #error. No framework patch is needed: the LibreTiny beken-72xx builder already compiles and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x; @@ -21,9 +21,16 @@ import logging import esphome.codegen as cg from esphome.components import libretiny -from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231Q, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, +) import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import EsphomeError from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] @@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema( request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT") +def _unsupported_family_message(family: str) -> str | None: + if family in (FAMILY_BK7231T, FAMILY_BK7251): + return ( + f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 " + "stack; a BLE 5.x SoC such as BK7231N or BK7238 is required" + ) + if family == FAMILY_BK7231Q: + return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + return None + + +def _final_validate(config: ConfigType) -> ConfigType: + # Warn only: a hard error here would break the validate-only CI fixtures, + # which run on a BLE 4.2 board. The hard error is raised at codegen. + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + _LOGGER.warning("%s (this configuration cannot compile)", msg) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + raise EsphomeError(msg) + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp index bd4e51d9b7..f17f21c06b 100644 --- a/esphome/components/bk72xx_ble/bdk_scan.cpp +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -10,7 +10,7 @@ #ifdef USE_BK72XX_BLE // Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). -#if !defined(CLANG_TIDY) && __has_include("ble_api.h") +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h") extern "C" { #include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, @@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { } // namespace esphome::bk72xx_ble -#endif // !CLANG_TIDY && ble_api.h +#endif // !CLANG_TIDY && ble_api.h && app_ble.h #endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index d40f08d111..52401114e6 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -34,22 +34,26 @@ // --------------------------------------------------------------------------- // SDK-capability gate (not a chip allowlist). -// This component drives the Beken BLE *5.x* controller via its public API, -// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the -// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the -// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header -// itself so any BLE-5.x Beken chip — present or future — is supported without a -// hard-coded list, and a non-5.x build fails here with a clear message instead -// of a cryptic "ble_api.h: No such file or directory". +// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be +// the probe: it ships for every SoC (driver/include) and merely switches on +// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the +// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports +// any BLE-5.x chip — present or future — without a hard-coded list, and a +// non-5.x build fails here with a clear message instead of a cryptic +// "app_ble.h: No such file or directory". // --------------------------------------------------------------------------- #if defined(CLANG_TIDY) // The clang-tidy environment does not carry the full Beken BDK BLE 5.x API // (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing // accurate to analyze the SDK calls against — skip the file under analysis. #define BK72XX_BLE_NO_SDK -#elif !__has_include("ble_api.h") +#elif !__has_include("ble_api.h") || !__has_include("app_ble.h") +// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2 +// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by +// one and bury this message. +#define BK72XX_BLE_NO_SDK #error \ - "bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." + "bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." #endif #ifndef BK72XX_BLE_NO_SDK diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 4da7d48882..15a8b08139 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -37,7 +37,7 @@ from esphome.const import ( CONF_INTERVAL, KEY_TARGET_PLATFORM, ) -from esphome.core import CORE, ID, KEY_CORE +from esphome.core import CORE, ID, KEY_CORE, TimePeriod from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] @@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: return config +# The historical scan window default shared by the trackers that do not pin +# their own; also the fallback for esp32's conditional default. +DEFAULT_SCAN_WINDOW = "30ms" + + def scan_parameters_schema( interval_default: str, *, - window_default: str = "30ms", + window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). The `active` option (default on) is - unconditional: active scanning is part of the tracker contract — every - current proxy client assumes it, so a passive-only tracker must not share - this schema. + LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg + callable evaluated per validation when the user omits the key (esp32 uses + this to record that the window was defaulted, so a later validation step + can adjust it once sibling keys are resolved). The `active` option + (default on) is unconditional: active scanning is part of the tracker + contract — every current proxy client assumes it, so a passive-only + tracker must not share this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 076c77b18e..15f854239d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -20,7 +20,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection.bluedroid"; +static const char *const TAG = "bluetooth_connection"; using ble_device_base::FAST_CONN_TIMEOUT; using ble_device_base::FAST_MAX_CONN_INTERVAL; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 855c895196..16a89dcfdd 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -15,7 +15,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection.rp2"; +static const char *const TAG = "bluetooth_connection"; using ble_device_base::ESPBTUUID; using ble_device_base::GATT_ERR_NOT_CONNECTED; diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index bf80b42e54..433361d298 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -103,7 +103,8 @@ struct CameraImageSpec { /** Abstract camera base class. Collaborates with API. * 1) API server starts and registers as a listener (add_listener) * to receive new images from the camera. - * 2) New API client connects and creates a new image reader (create_image_reader). + * 2) API connection creates an image reader (create_image_reader) when it receives + * the first image it will send. * 3) API connection receives protobuf CameraImageRequest and calls request_image. * 3.a) API connection receives protobuf CameraImageRequest and calls start_stream. * 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners. diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 44878274d6..3ba89d2838 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -22,6 +22,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_NOX_INDEX = "nox_index" @@ -35,6 +36,7 @@ CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SHA256 = "sha256" +CONF_SLOT = "slot" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 73e0331c76..2c97dc3211 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -5,7 +5,7 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep.bk72xx"; +static const char *const TAG = "deep_sleep"; #ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 9125c43f0c..2120abe5f7 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -3,6 +3,7 @@ import re from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import CONF_LABEL from esphome.components.number import Number from esphome.components.select import Select from esphome.components.switch import Switch @@ -30,7 +31,6 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base") CONF_ROTARY = "rotary" CONF_JOYSTICK = "joystick" -CONF_LABEL = "label" CONF_MENU = "menu" CONF_BACK = "back" CONF_SELECT = "select" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index da10b33e80..666cf94f46 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -570,6 +570,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Factory format (Previously Modern)", diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 1b054dcc49..b61dad7386 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -360,17 +360,6 @@ static bool has_fault_addr() { return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; } -// Append both cores' backtrace addresses to buf; returns the new position. -static int append_all_backtraces(char *buf, int size, int pos) { - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, - s_raw_crash_data.reg_frame_count); -#if SOC_CPU_CORES_NUM > 1 - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, - s_raw_crash_data.other_reg_frame_count); -#endif - return pos; -} - // The record was captured by a different firmware build (it survives soft // resets, including the OTA reboot), so symbolizing its addresses against the // current ELF would produce misleading symbols. Print them with lowercase @@ -443,11 +432,23 @@ void crash_handler_log() { } #endif - // Build addr2line hint with all captured addresses for easy copy-paste + // Build addr2line hints for easy copy-paste. One line per core: the two + // backtraces are separate stacks, and a combined list decodes as one + // impossible call chain (and can overflow the buffer, dropping addresses). + static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf"; char hint[256]; - int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - append_all_backtraces(hint, sizeof(hint), pos); + int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); ESP_LOGE(TAG, "%s", hint); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, + s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); + ESP_LOGE(TAG, "%s", hint); + } +#endif } } // namespace esphome::esp32 diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 16501ef3b2..e2d79173ff 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -648,6 +648,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm + case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init + case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init return; default: diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 634b8c3bef..28c8c7fcf1 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +from dataclasses import dataclass import logging from esphome import automation @@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, + idf_version, request_bluetooth, request_software_coexistence, ) @@ -35,10 +38,12 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType +DOMAIN = "esp32_ble_tracker" + AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] @@ -125,10 +130,71 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config +# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far +# longer than the configured window (espressif/esp-idf#18931). Before the fix, +# the default 30 ms window in a 320 ms interval effectively scanned at a much +# higher duty cycle than requested; with the fix, that same default only +# listens 9.4 % of the time and misses most advertisements when wifi shares +# the radio. Espressif recommends setting the window equal to the interval in +# that case: the coexistence arbiter still shares the radio with wifi, and +# BLE uses the airtime wifi does not claim. +IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5) + + +@dataclass +class TrackerData: + """Per-run validation state, namespaced under DOMAIN in CORE.data.""" + + scan_window_defaulted: bool = False + + +def _get_data() -> TrackerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TrackerData() + return CORE.data[DOMAIN] + + +def _scan_window_default() -> TimePeriod: + """Schema default for the scan window. + + Records that the user did not set a window, so _raise_defaulted_scan_window + can tell a defaulted 30 ms from an explicit one; the raise itself must wait + for the outer schema because it depends on software_coexistence, a sibling + key not yet resolved here. + """ + _get_data().scan_window_defaulted = True + return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW) + + +def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: + """Raise a defaulted scan window to the interval where that is safe. + + Only when the coexistence arbiter is compiled in (software_coexistence, + present iff wifi is configured and not disabled by the user) and the IDF + honors the window strictly (>= 5.5.5); without the arbiter a full-duty + scan would starve wifi outright, and a user-set window is never touched. + Raising to the interval cannot invalidate the already-validated + parameters, so no re-validation is needed. + """ + if ( + _get_data().scan_window_defaulted + and config.get(CONF_SOFTWARE_COEXISTENCE) + and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION + ): + params = config[CONF_SCAN_PARAMETERS] + # Copy so the config dump shows a plain value instead of a YAML + # anchor/alias pair pointing at the interval. + params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) + return config + + # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") +# The window default is conditional (see _scan_window_default above). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "320ms", window_default=_scan_window_default +) # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. @@ -183,6 +249,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_max_connections_deprecated, + _raise_defaulted_scan_window, ) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index b15ae53711..d3432fb461 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -3,7 +3,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -16,8 +16,10 @@ from esphome.const import ( CONF_VARIANT, ) from esphome.cpp_generator import add_define +from esphome.types import ConfigType CODEOWNERS = ["@swoboda1337"] +DEPENDENCIES = ["esp32"] # esp32_ble raises the task watchdog around the remote BT controller bring-up AUTO_LOAD = ["watchdog"] @@ -33,7 +35,6 @@ CONF_DATA_READY_PIN = "data_ready_pin" CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high" CONF_HANDSHAKE_PIN = "handshake_pin" CONF_SDIO_FREQUENCY = "sdio_frequency" -CONF_SLOT = "slot" CONF_SPI_MODE = "spi_mode" # Shared fields for both transport modes @@ -125,6 +126,22 @@ CONFIG_SCHEMA = cv.typed_schema( ) +def _final_validate(config: ConfigType) -> ConfigType: + # The esp_hosted releases compatible with older ESP-IDF versions crash at + # boot with a heap double free in the SDIO RX path (fixed in esp_hosted + # 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time. + if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0): + raise cv.Invalid( + f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. " + "Remove the framework version from your configuration to use the " + "recommended version, or pin a version at or above 5.3." + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def _configure_sdio(config): slot = config[CONF_SLOT] esp32.add_idf_sdkconfig_option( @@ -252,18 +269,14 @@ async def to_code(config): if config[CONF_USE_PSRAM]: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) - # Library versions + # Library versions; this component set requires ESP-IDF 5.3 or newer, + # which is enforced at validation time. idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" - if idf_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") - esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") - esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") - else: - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") - esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") + esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") + esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 1f7159919d..2161a902cb 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -113,6 +113,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Standard format", diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 8bdd536ffb..f3c77baaae 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -355,7 +355,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.9.0.", + "Removal scheduled for 2026.11.0.", config[CONF_CLK_MODE], mode, pin, diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 84333e7169..43ab2e5b53 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -16,7 +16,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.arduino"; +static const char *const TAG = "http_request"; #ifdef USE_ESP8266 // ESP8266 Arduino core (WiFiClientSecureBearSSL.cpp) returns -1000 on OOM static constexpr int ESP8266_SSL_ERR_OOM = -1000; diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 85c6e8b3c7..cf231e20bd 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -14,7 +14,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.host"; +static const char *const TAG = "http_request"; std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, const std::string &body, diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index a437540241..ddff954950 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -16,7 +16,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.idf"; +static const char *const TAG = "http_request"; static constexpr uint32_t ERROR_DURATION_MS = 1000; void HttpRequestIDF::dump_config() { diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index cc036b12c3..39a6aec774 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -9,7 +9,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.arduino"; +static const char *const TAG = "i2c"; // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 4aca4f0fae..7ca9537e2d 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -12,7 +12,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.idf"; +static const char *const TAG = "i2c"; // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; diff --git a/esphome/components/i2c/i2c_bus_host.cpp b/esphome/components/i2c/i2c_bus_host.cpp index 17279fda50..303944636b 100644 --- a/esphome/components/i2c/i2c_bus_host.cpp +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -16,7 +16,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.host"; +static const char *const TAG = "i2c"; HostI2CBus::~HostI2CBus() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 1eb9944dcb..ffdd2ba8bb 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -6,7 +6,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.zephyr"; +static const char *const TAG = "i2c"; static const char *get_speed(uint32_t dev_config) { switch (I2C_SPEED_GET(dev_config)) { diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 288b1e5c40..9b97995a96 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -154,8 +154,12 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) { // Forward received IR data to API server #if defined(USE_API) && defined(USE_IR_RF) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp index b7332ee81f..91f47d831f 100644 --- a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -9,7 +9,7 @@ uint32_t temp_single_get_current_temperature(uint32_t *temp_value); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.bk72xx"; +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 64fe3707b1..2c6fda2af4 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -16,7 +16,7 @@ uint8_t temprature_sens_read(); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.esp32"; +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 2e408b3b01..c4ab33b0a5 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -16,7 +16,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.rp2"; +static const char *const TAG = "internal_temperature"; // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp index be72ab6f51..50c597f6f1 100644 --- a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp +++ b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp @@ -8,7 +8,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.zephyr"; +static const char *const TAG = "internal_temperature"; static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index f71bec7e5f..4aa00f8fd4 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) { return result; } -float LD2420Component::get_setup_priority() const { return setup_priority::BUS; } - void LD2420Component::dump_config() { ESP_LOGCONFIG(TAG, "LD2420:\n" diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 977ee2eccc..e13d0271e1 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -105,7 +105,6 @@ class LD2420Component final : public Component, public uart::UARTDevice { void apply_config_action(); void factory_reset_action(); void revert_config_action(); - float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); void handle_cmd_error(uint16_t error); diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index c51af373b3..c56cc48055 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -182,6 +182,9 @@ def get_download_types(storage_json: StorageJSON = None): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [ { "title": "UF2 package (recommended)", diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index b66a904437..acd5a9bdef 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -444,7 +444,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r lv_indev_set_type(this->drv_, LV_INDEV_TYPE_POINTER); lv_indev_set_disp(this->drv_, parent->get_disp()); lv_indev_set_long_press_time(this->drv_, long_press_time); - // long press repeat time TBD + lv_indev_set_long_press_repeat_time(this->drv_, long_press_repeat_time); lv_indev_set_user_data(this->drv_, this); lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) { auto *l = static_cast(lv_indev_get_user_data(d)); diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index 5ac92f2717..54c9819d2b 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -1,3 +1,4 @@ +from esphome.components.const import CONF_LABEL import esphome.config_validation as cv from esphome.const import CONF_TEXT @@ -14,8 +15,6 @@ from ..schemas import TEXT_SCHEMA from ..types import LvText from . import Widget, WidgetType -CONF_LABEL = "label" - class LabelType(WidgetType): def __init__(self): diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 713969ab88..98ca23b60b 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -63,7 +63,6 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -333,68 +332,6 @@ CONFIG_SCHEMA = cv.All( ) -# Platforms whose MQTT components subscribe to an object_id-derived command topic. -# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus -# text, whose MQTT component subscribes a command topic that cannot be overridden. -_COMMAND_TOPIC_PLATFORMS = frozenset( - { - "alarm_control_panel", - "button", - "climate", - "cover", - "datetime", - "fan", - "light", - "lock", - "number", - "select", - "switch", - "text", - "update", - "valve", - } -) - - -# Platforms whose MQTT components derive extra sub-topics (position/command, -# mode/command, speed/command, ...) from the object_id, each with its own config -# key; custom state and command topics cannot exempt them from conflicting. -_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"}) - - -def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool: - """Check whether more than one entity actually uses an object_id-derived topic. - - An empty topic_prefix disables default topics entirely, custom state and - command topics avoid the default topics, and disabling discovery (globally - or per entity) avoids the discovery config topic. - """ - if config[CONF_TOPIC_PREFIX]: - platform = entities[0].platform - if platform in _SUB_TOPIC_PLATFORMS: - return True - if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1: - return True - if ( - platform in _COMMAND_TOPIC_PLATFORMS - and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1 - ): - return True - if not config[CONF_DISCOVERY]: - return False - discovery_entities = sum( - entity.config.get(CONF_DISCOVERY, True) for entity in entities - ) - return discovery_entities > 1 - - -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "mqtt builds default topics and discovery topics from the entity object_id, " - "which is the name converted to ASCII", - conflict_filter=_topics_conflict, -) - - def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 499a330730..09eb5f97dc 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.idf"; +static const char *const TAG = "mqtt"; bool MQTTBackendESP32::initialize_() { mqtt_cfg_.broker.address.hostname = this->host_.c_str(); diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 2f3377d950..f02f32d5ca 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -13,7 +13,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload.arduino"; +static const char *const TAG = "nextion.upload"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index e2d5ae8ad7..c4dc74b5d3 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -16,7 +16,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload.esp32"; +static const char *const TAG = "nextion.upload"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 386fed5412..2d25558254 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -473,6 +473,9 @@ def copy_files() -> None: def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Get the download types for the firmware.""" + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [] UF2_PATH = "zephyr/zephyr.uf2" DFU_PATH = "firmware.zip" diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 4cc99202a7..231c4d2dd2 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -9,7 +9,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_libretiny"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index b35eb38c12..48725b1265 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -11,7 +11,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_rp2"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 6a678fb419..2a6a9e08b1 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -46,7 +46,7 @@ static constexpr size_t MIN_BUFFER_SIZE = 256; namespace esphome::ota { -static const char *const TAG = "ota.esp8266"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 108605e4c9..eb23ad82dd 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -15,7 +15,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index ee503a49e1..89e3f99e1e 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -27,7 +27,7 @@ namespace esphome::ota { namespace { -const char *const TAG = "ota.host"; +const char *const TAG = "ota"; constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB constexpr size_t HEADER_PEEK_SIZE = 64; diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 264218a3df..57b5529350 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -11,7 +11,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; OTAResponseTypes IDFOTABackend::register_and_validate_bootloader_part_() { // Register the bootloader partition diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index a7fc709313..d2b1196de6 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -16,7 +16,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) { return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index b327988d2d..71dcc0eb83 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -31,7 +31,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; // Route the "Signature check: " prefix (and its per-block form) through one // shared format string each, so the prefix is pooled once by the linker instead diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 0a69160fc1..cc1541ce80 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -3,7 +3,6 @@ from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL -from esphome.core.entity_helpers import validate_no_object_id_conflicts from esphome.cpp_types import EntityBase AUTO_LOAD = ["web_server_base"] @@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema( }, ).extend(cv.COMPONENT_SCHEMA) -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id, " - "which is the name converted to ASCII" -) - async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index fe6c6a9cb5..3e0a905737 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -99,8 +99,12 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { // Forward received RF data to API server #if defined(USE_API) && defined(USE_RADIO_FREQUENCY) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 596608a4d0..632ca9763a 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -9,7 +9,7 @@ namespace esphome::remote_receiver { -static const char *const TAG = "remote_receiver.esp32"; +static const char *const TAG = "remote_receiver"; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 0831822d86..0734ca87d3 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() { } if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) { - this->store_.counter = 0; + this->store_.counter = std::clamp(0, this->store_.min_value, this->store_.max_value); } int counter = this->store_.counter; if (this->store_.last_read != counter || this->publish_initial_value_) { diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 87e78003ed..60fcd4f8b0 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -156,6 +156,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "UF2 factory format", diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index 94d6e7cfca..3c6c82b009 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -3,6 +3,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import runtime_image +from esphome.components.const import CONF_SLOT from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata import esphome.config_validation as cv from esphome.const import ( @@ -45,7 +46,6 @@ MAX_IMAGE_DIMENSION = 32767 MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60) MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60) -CONF_SLOT = "slot" CONF_CURRENT_IMAGE = "current_image" CONF_TRANSITION_IMAGE = "transition_image" CONF_ON_IMAGE_DISPLAY = "on_image_display" diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 5f7f19769a..0105580d26 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -283,8 +283,11 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; } optional DeltaFilter::new_value(float value) { - // Always yield the first value. - if (std::isnan(this->last_value_)) { + const bool no_value = std::isnan(value); + const bool no_reference = std::isnan(this->last_value_); + if (no_value && no_reference) + return {}; + if (no_value || no_reference) { this->last_value_ = value; return value; } @@ -293,8 +296,7 @@ optional DeltaFilter::new_value(float value) { float min = fabsf(this->min_a0_ + ref * this->min_a1_); float max = fabsf(this->max_a0_ + ref * this->max_a1_); float delta = fabsf(value - ref); - // if there is no reference, e.g. for the first value, just accept this one, - // otherwise accept only if within range. + // accept only if within range if (delta > min && delta <= max) { this->last_value_ = value; return value; diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 4fcec553fa..b80a394eec 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -43,7 +43,7 @@ namespace esphome::socket { // (Ethernet). On ESP8266, it's a no-op. #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT -static const char *const TAG = "socket.lwip"; +static const char *const TAG = "socket"; // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index a3e09d2800..14428bed62 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { #if defined(USE_ARDUINO) && !defined(USE_ESP32) -static const char *const TAG = "spi-esp-arduino"; +static const char *const TAG = "spi"; class SPIDelegateHw : public SPIDelegate { public: SPIDelegateHw(SPIInterface channel, uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 0731078eec..d5d5053117 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { #ifdef USE_ESP32 -static const char *const TAG = "spi-esp-idf"; +static const char *const TAG = "spi"; static const size_t MAX_TRANSFER_SIZE = 4092; // dictated by ESP-IDF API. class SPIDelegateHw : public SPIDelegate { diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index ffe11cf229..af134e6ed4 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -20,14 +20,18 @@ void TemplateText::setup() { // Need std::string for pref_->setup() to fill from flash std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; - uint32_t extra = 0; - extra += this->traits.get_min_length() << 2; - extra += this->traits.get_max_length() << 4; - extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - // TextSaver::setup() picks the key for the platform and migrates old data once - uint32_t key = this->preference_key_base_() + extra; - uint32_t old_key = this->old_preference_key_base_() + extra; - this->pref_->setup(key, old_key, value); + // For future hash migration: use migrate_entity_preference_() with: + // old_key = get_preference_hash() + extra + // new_key = get_preference_hash_v2() + extra + // See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash(); +#pragma GCC diagnostic pop + key += this->traits.get_min_length() << 2; + key += this->traits.get_max_length() << 4; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + this->pref_->setup(key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index beeea4396a..229a61d9b8 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -14,9 +14,7 @@ class TemplateTextSaverBase { public: virtual bool save(const std::string &value) { return true; } - /// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once. - /// See: https://github.com/esphome/backlog/issues/85 - virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {} + virtual void setup(uint32_t id, std::string &value) {} protected: ESPPreferenceObject pref_; @@ -47,16 +45,11 @@ template class TextSaver : public TemplateTextSaverBase { // Make the preference object. Fill the provided location with the saved data // If it is available, else leave it alone - void setup(uint32_t id, uint32_t old_id, std::string &value) override { - char temp[SZ + 1]; -#ifdef USE_PREFERENCE_KEY_LOOKUP + void setup(uint32_t id, std::string &value) override { this->pref_ = global_preferences->make_preference(id); - bool hasdata = migrate_preference(this->pref_, reinterpret_cast(temp), SZ + 1, old_id, id); -#else - // Slot-based backends keep the old key; it is only a validity tag on a positional slot - this->pref_ = global_preferences->make_preference(old_id); + + char temp[SZ + 1]; bool hasdata = this->pref_.load(&temp); -#endif if (hasdata) { size_t len = static_cast(temp[0]); diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index fc1509f737..2f8b4dbd11 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -14,7 +14,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_esp8266"; +static const char *const TAG = "uart"; bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) uint32_t ESP8266UartComponent::get_config() { diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 93e43e0372..a61339feb4 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -21,7 +21,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.idf"; +static const char *const TAG = "uart"; /// Check if a pin number matches one of the default UART0 GPIO pins. /// These pins may have residual IOMUX state from the ROM bootloader that diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 5bb7a49726..63b5631564 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -98,7 +98,7 @@ speed_t get_baud(int baud) { namespace esphome::uart { -static const char *const TAG = "uart.host"; +static const char *const TAG = "uart"; HostUartComponent::~HostUartComponent() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index fbf0c20ded..4eacd980db 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -16,7 +16,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.lt"; +static const char *const TAG = "uart"; static const char *const UART_TYPE[] = { "hardware", diff --git a/esphome/components/uart/uart_component_rp2.cpp b/esphome/components/uart/uart_component_rp2.cpp index 9cc3009a22..ffb9bc0f2d 100644 --- a/esphome/components/uart/uart_component_rp2.cpp +++ b/esphome/components/uart/uart_component_rp2.cpp @@ -13,7 +13,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_rp2"; +static const char *const TAG = "uart"; uint16_t RP2UartComponent::get_config() { uint16_t config = 0; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 719a276bf9..acaa94b13c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -136,10 +136,21 @@ bool WiFiComponent::wifi_apply_power_save_() { https://github.com/d-a-v/Arduino/blob/0e7d21e17144cfc5f53c016191daca8723e89ee8/libraries/ESP8266WiFi/src/ESP8266WiFiSTA.cpp#L251 */ #undef netif_set_addr // need to call lwIP-v1.4 netif_set_addr() +#undef netif_set_down // need to call lwIP-v1.4 netif_set_down() extern "C" { struct netif *eagle_lwip_getif(int netif_index); void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t *netmask, const ip4_addr_t *gw); +void netif_set_down(struct netif *netif); }; + +// The SDK can free its WiFi connection node before taking the STA netif down, letting lwIP +// timers (e.g. IGMP reports armed by mDNS) transmit into the dead driver and crash in +// cnx_node_search; taking the netif down first makes the glue drop such frames (#18308). +static void sta_netif_down() { + struct netif *iface = eagle_lwip_getif(STATION_IF); + if (iface != nullptr) + netif_set_down(iface); +} #endif bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { @@ -523,6 +534,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); } global_wifi_component->error_from_callback_ = true; +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif #ifdef USE_WIFI_CONNECT_STATE_LISTENERS global_wifi_component->pending_.disconnect = true; #endif @@ -536,6 +550,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif wifi_station_disconnect(); global_wifi_component->error_from_callback_ = true; } @@ -719,8 +736,12 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { bool WiFiComponent::wifi_disconnect_() { bool ret = true; // Only call disconnect if interface is up - if (wifi_get_opmode() & WIFI_STA) + if (wifi_get_opmode() & WIFI_STA) { +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif ret = wifi_station_disconnect(); + } station_config conf{}; memset(&conf, 0, sizeof(conf)); ETS_UART_INTR_DISABLE(); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 482995e2c5..cd094306f4 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -307,6 +307,11 @@ void ZigbeeComponent::setup() { return; } #endif + +#ifdef CONFIG_ZB_ZCZR + ezb_bdb_set_router_rejoin_required(true); +#endif + ezb_aps_secur_enable_distributed_security(false); ezb_nwk_set_min_join_lqi(32); if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 8e63c09e67..ade45e8cc3 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -285,7 +285,7 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.3", + ref="2.0.4", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0eebf12e66..f455c7b8bf 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -99,6 +99,10 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) + +# Deprecated re-export for external components; remove before 2027.2.0 +# pylint: disable-next=unused-import +from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base diff --git a/esphome/core/application.h b/esphome/core/application.h index a18a6b31c8..a12cdc4ac8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -120,8 +120,8 @@ class Application { // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ void register_##singular(type *obj) { this->plural##_.push_back(obj); } \ - void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \ - obj->configure_entity_(name, entity_key, entity_fields); \ + void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \ + obj->configure_entity_(name, object_id_hash, entity_fields); \ this->plural##_.push_back(obj); \ } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ @@ -329,7 +329,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \ + if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ (include_internal || !obj->is_internal())) \ return obj; \ } \ @@ -340,7 +340,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \ + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \ return obj; \ } \ return nullptr; \ diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 328de05302..fc6ac503b5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,7 +8,7 @@ namespace esphome { static const char *const TAG = "entity_base"; -void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) { +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32 } } this->flags_.has_own_name = false; - // Dynamic name - must calculate key at runtime - this->calc_entity_key_(); + // Dynamic name - must calculate hash at runtime + this->calc_object_id_(); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed key if provided - if (entity_key != 0) { - this->entity_key_ = entity_key; + // Static name - use pre-computed hash if provided + if (object_id_hash != 0) { + this->object_id_hash_ = object_id_hash; } else { - this->calc_entity_key_(); + this->calc_object_id_(); } } // Unpack entity string table indices and flags from entity_fields. @@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Calculate the entity key directly from the raw name (no transformations) -void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); } - -// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility. -// Named entities historically used the hash pre-computed by Python code generation, which -// sanitized per UTF-8 code point; entities without their own name computed the hash at -// runtime per byte. See https://github.com/esphome/backlog/issues/85 -uint32_t EntityBase::calc_old_object_id_hash_() const { - return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name); +// Calculate Object ID Hash directly from name using snake_case + sanitize +void EntityBase::calc_object_id_() { + this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { @@ -173,22 +167,16 @@ StringRef EntityBase::get_object_id_to(std::span buf) c } ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { - // The old key hashed the sanitized object_id, so multiple entity names could collide on - // one key and overwrite each other's stored preferences; the new key hashes the raw name. - // See: https://github.com/esphome/backlog/issues/85 - uint32_t old_key = this->old_preference_key_base_() ^ version; -#ifdef USE_PREFERENCE_KEY_LOOKUP - uint32_t new_key = this->preference_key_base_() ^ version; - auto pref = global_preferences->make_preference(size, new_key); - // All in-tree entity preferences fit the stack buffer, so migration never hits the heap - SmallBufferWithHeapFallback<64> buffer(size); - migrate_preference(pref, buffer.get(), size, old_key, new_key); - return pref; -#else - // Slot-based backends keep the old key: it is only a validity tag on a positional slot, - // so collisions cannot corrupt data there and keeping it preserves stored state. - return global_preferences->make_preference(size, old_key); -#endif + // The key hashes the sanitized object_id, so multiple entity names can collide on one + // key and overwrite each other's stored preferences ("Living Room" and "living_room", + // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name + // fix this, but they change the entity key API clients track, which the Home Assistant + // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash() ^ version; +#pragma GCC diagnostic pop + return global_preferences->make_preference(size, key); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 7f8e5f2630..5f2e173d8d 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,17 +73,8 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the unique key of this Entity: FNV-1 hash of the raw entity name. - // This is the key sent to API clients and used to route entity state. - uint32_t get_entity_key() const { return this->entity_key_; } - - /// Returns the LEGACY object_id hash, unchanged from previous releases, so existing - /// callers keep getting stable values (for example preference keys). This is no longer - /// the key sent to API clients; that is get_entity_key(). - ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or " - "make_entity_preference() for preference storage. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); } + // Get the unique Object ID of this Entity + uint32_t get_object_id_hash() const { return this->object_id_hash_; } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) @@ -190,23 +181,39 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } - /// Get this entity's device id, or 0 when devices are not compiled in (main device). - uint32_t get_device_id_or_zero() const { -#ifdef USE_DEVICES - return this->get_device_id(); -#else - return 0; -#endif - } - - /// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id. - /// Intentionally keeps the old algorithm so external callers that store preferences under - /// this key keep stable keys; make_entity_preference() migrates to the new raw-name key, - /// this method never will. + /** + * @brief Get a unique hash for storing preferences/settings for this entity. + * + * This method returns a hash that uniquely identifies the entity for the purpose of + * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), + * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness + * across multiple devices that may have entities with the same object_id. + * + * Use this method when storing or retrieving preferences/settings that should be unique + * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies + * the entity regardless of the device it belongs to. + * + * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged + * from previous versions, so existing single-device configurations will continue to work. + * + * @return uint32_t The unique hash for preferences, including device_id if available. + * @deprecated Use make_entity_preference() instead, or preferences won't be migrated. + * See https://github.com/esphome/backlog/issues/85 + */ ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_preference_hash() { return this->old_preference_key_base_(); } + "2026.7.0") + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } /// Create a preference object for storing this entity's state/settings. /// @tparam T The type of data to store (must be trivially copyable) @@ -223,9 +230,9 @@ class EntityBase { // before push_back, so codegen can emit a single combined call per entity. friend class Application; - /// Combined entity setup from codegen: set name, entity key, entity string indices, and flags. + /// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags. /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. - void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields); + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); #ifdef USE_DEVICES // Codegen-only setter — only accessible from setup() via friend declaration. @@ -233,24 +240,13 @@ class EntityBase { #endif /// Non-template helper for make_entity_preference() to avoid code bloat. - /// Migrates preferences from the old sanitized-object_id key to the raw-name key - /// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85 + /// When the preference hash algorithm changes, migration logic goes here. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); - void calc_entity_key_(); - - /// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys. - uint32_t calc_old_object_id_hash_() const; - - /// Preference key base for this entity: raw-name entity key XOR device_id. - uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); } - - /// Legacy preference key base: sanitized-object_id hash XOR device_id. - /// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash. - uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); } + void calc_object_id_(); StringRef name_; - uint32_t entity_key_{}; + uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5060e32a2d..54e2551cb4 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,86 +25,25 @@ from esphome.core.config import ( from esphome.cpp_generator import MockObj, RawStatement, add, get_variable from esphome.cpp_types import App import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash_name, sanitize, snake_case +from esphome.helpers import ( + cpp_string_escape, + fnv1_hash, + fnv1_hash_object_id, + sanitize, + snake_case, +) from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) DOMAIN = "entity_string_pool" -_OBJECT_ID_DOMAIN = "entity_object_ids" - - -@dataclass -class ObjectIdEntity: - """An entity tracked by the sanitized object_id its name resolves to.""" - - name: str - platform: str - config: ConfigType - - -def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]: - """(device_id, platform, sanitized object_id) -> entities resolving to it.""" - return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {}) - - -def validate_no_object_id_conflicts( - reason: str, - conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None, -) -> Callable[[ConfigType], ConfigType]: - """Create a final-validate step that rejects entities with colliding object_ids. - - Entity keys are hashed from the raw name, so names that only differ in characters - lost during sanitizing (for example two UTF-8 names) validate fine in general. - Components that still address entities by the sanitized object_id string must - reject those configs until they are migrated to raw names. - - Args: - reason: One sentence stating what the component builds from the object_id, - e.g. "mqtt builds default topics from the entity object_id" - conflict_filter: Optional predicate receiving the colliding entities and the - component config; return False when the component is not affected - - Returns: - A validator function for use as (or within) FINAL_VALIDATE_SCHEMA - """ - - def validator(config: ConfigType) -> ConfigType: - # Skip in testing_mode, which is used for grouped component testing - if CORE.testing_mode: - return config - conflicts = { - key: entities - for key, entities in _get_object_id_registry().items() - if len(entities) > 1 - and (conflict_filter is None or conflict_filter(entities, config)) - } - if not conflicts: - return config - lines = [f"{reason}, so these entities would conflict:"] - lines.extend( - f" - {platform} entities " - + ", ".join(f"'{e.name}'" for e in entities) - + (f" on device '{device_id}'" if device_id else "") - + f" share the object_id '{object_id}'" - for (device_id, platform, object_id), entities in conflicts.items() - ) - lines.append( - "To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') " - "to distinguish the names" - ) - raise cv.Invalid("\n".join(lines)) - - return validator - - # Private config keys for storing registered string indices _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" -_KEY_ENTITY_KEY = "_entity_key" +_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" # Bit layout for entity_fields in configure_entity_(). # Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h @@ -367,7 +306,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] - entity_key = config[_KEY_ENTITY_KEY] + object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) @@ -387,30 +326,57 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: register_method = config.get(_KEY_REGISTER_METHOD) if register_method is not None: expr = getattr(App, f"register_{register_method}")( - var, entity_name, entity_key, packed + var, entity_name, object_id_hash, packed ) else: - expr = var.configure_entity_(entity_name, entity_key, packed) + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: add(expr) -def get_base_entity_name( +def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None ) -> str: - """Return the base name whose hash becomes this entity's key on the device. + """Calculate the base object ID for an entity that will be set via set_object_id(). - Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp): - entity name, then sub-device name, then friendly name, then the device name. + This function calculates what object_id_c_str_ should be set to in C++. - This is a config-time approximation for duplicate checking: when - name_add_mac_suffix is enabled the device appends the MAC suffix at runtime, - which is unknown here and identical for every entity on the device, so - ignoring it cannot change whether two entities collide with each other. + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() """ - return name or device_name or friendly_name or CORE.name + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif device_name: + # Entity has empty name and is on a sub-device + # C++ EntityBase::set_name() uses device->get_name() when device is set + base_str = device_name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) def setup_entity(var_or_platform, config=None, platform=None): @@ -469,15 +435,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device_(device)) - # Pre-compute entity name and entity key for configure_entity_() + # Pre-compute entity name and object_id hash for configure_entity_() # which is emitted later by finalize_entity_strings(). - # For named entities: pre-compute the key from the raw entity name - # For empty-name entities: pass 0, C++ calculates the key at runtime from - # device name, friendly_name, or app name + # For named entities: pre-compute hash from entity name + # For empty-name entities: pass 0, C++ calculates hash at runtime from + # device name, friendly_name, or app name (bug-for-bug compatibility) entity_name = config[CONF_NAME] - entity_key = fnv1_hash_name(entity_name) if entity_name else 0 + object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name - config[_KEY_ENTITY_KEY] = entity_key + config[_KEY_OBJECT_ID_HASH] = object_id_hash # Store flags for packing into configure_entity_() config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: @@ -590,13 +556,16 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Use the device ID string directly for uniqueness device_id = device_id_obj.id - # Hash the same raw name the device hashes into the entity key at runtime. - # This handles empty names correctly by using device/friendly names. - base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name) - name_hash = fnv1_hash_name(base_name) + # Calculate what object_id will actually be used + # This handles empty names correctly by using device/friendly names + name_key = get_base_entity_object_id( + entity_name, CORE.friendly_name, device_name + ) - # Check for duplicates: two entities on the same device and platform must not - # share an entity key, since the key is what routes state to API clients + # Check for duplicates by the FNV-1 hash of the object_id, which is the entity + # key that routes state to API clients. This rejects names that sanitize to the + # same object_id, and also two different object_ids whose 32-bit hashes collide. + name_hash = fnv1_hash(name_key) unique_key = (device_id, platform, name_hash) if unique_key in CORE.unique_ids: # Get the existing entity metadata @@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" - # Different names can only clash here through a genuine hash collision + # Distinguish names that sanitize to the same object_id from a genuine + # 32-bit hash collision between two different object_ids collision_msg = "" if entity_name != existing_name: - collision_msg = ( - f"\n The names '{entity_name}' and '{existing_name}' produce the" - f"\n same entity key hash ({name_hash:#010x})." - "\n To fix: Rename one of the entities" + existing_object_id = get_base_entity_object_id( + existing_name, CORE.friendly_name, existing_device or None ) + if existing_object_id == name_key: + collision_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + else: + collision_msg = ( + f"\n The object_ids '{name_key}' and '{existing_object_id}'" + f"\n produce the same entity key hash ({name_hash:#010x})." + "\n To fix: Rename one of the entities" + ) # Skip duplicate entity name validation when testing_mode is enabled # This flag is used for grouped component testing @@ -640,19 +621,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy f"{collision_msg}" ) - # Components that still address entities by the sanitized object_id reject - # colliding names in final validation via validate_no_object_id_conflicts(), - # so track every entity by the object_id its name resolves to. Scoped per - # device and platform to match the strictness configs had before entity keys - # moved to raw names: same-named entities on different sub-devices were - # already accepted then, internal entities were already skipped (above), and - # overlaps between platforms that share an MQTT component type (sensor and - # text_sensor both publish under "sensor") were already possible. - object_id = sanitize(snake_case(base_name)) - _get_object_id_registry().setdefault( - (device_id, platform, object_id), [] - ).append(ObjectIdEntity(base_name, platform, config)) - # Store metadata about this entity entity_metadata: EntityMetadata = { "name": entity_name, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d883ce146e..994fa2c26a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -809,19 +809,6 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; -/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *), -/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80. -/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8 -/// encoded bytes of the name. Used to compute entity keys from raw names. -inline uint32_t fnv1_hash_bytes(const char *str, size_t len) { - uint32_t hash = FNV1_OFFSET_BASIS; - for (size_t i = 0; i < len; i++) { - hash *= FNV1_PRIME; - hash ^= static_cast(str[i]); - } - return hash; -} - /// Extend a FNV-1 hash with an integer (hashes each byte). template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { using UnsignedT = std::make_unsigned_t; @@ -1026,20 +1013,12 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s // str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. -/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing -/// devices already have stored; see https://github.com/esphome/backlog/issues/85. -/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character -/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py, -/// which produced the hash for named entities. The per-byte form (default) matches the old -/// runtime hash for entities without their own name. Do not change either behavior. -/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a -/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong; -/// such names skip migration once and fall back to their defaults. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) { +/// This computes object_id hashes directly from names without creating an intermediate buffer. +/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. +/// If you modify this function, update the Python version and tests in both places. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { - if (per_code_point && (static_cast(str[i]) & 0xC0) == 0x80) - continue; // UTF-8 continuation byte, already counted via its lead byte hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 0622376fca..5df0804bdd 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -24,9 +24,10 @@ #endif // Key-lookup preference backends find stored data by key; their platforms add the -// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key -// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for -// every make_preference() call and use the key only as a validity tag on that slot; +// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads +// of stored data by key (the primitive preference key migrations need). Slot-based +// backends (ESP8266, RP2040) instead allocate a storage slot for every +// make_preference() call and use the key only as a validity tag on that slot; // migration is not possible there, and key collisions cannot corrupt data. namespace esphome { @@ -104,10 +105,9 @@ concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool }; // Key-lookup platforms additionally provide load_from_key(), a one-shot read -// of a stored preference by key that migrate_preference() relies on; see the -// key-lookup note at the top of this file. Not part of PreferencesContract, -// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP -// is set. +// of a stored preference by key; see the key-lookup note at the top of this +// file. Not part of PreferencesContract, so it is asserted in preferences.h +// only where USE_PREFERENCE_KEY_LOOKUP is set. template concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { { prefs.load_from_key(type, data, len) } -> std::same_as; diff --git a/esphome/core/preferences.cpp b/esphome/core/preferences.cpp deleted file mode 100644 index 8508647255..0000000000 --- a/esphome/core/preferences.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "esphome/core/preferences.h" -#include "esphome/core/log.h" -#include - -namespace esphome { - -#ifdef USE_PREFERENCE_KEY_LOOKUP -static const char *const TAG = "preferences"; - -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key) { - if (new_pref.load(scratch, size)) - return true; // Current data present - never overwrite newer data with the old copy - // One-shot read by key: no backend is allocated for the old key, so boots with - // nothing to migrate (for example fresh installs) cost no heap - if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size)) - return false; // No data stored under the old key, nothing to migrate - if (!new_pref.save(scratch, size)) { - ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key); - } - return true; -} -#endif // USE_PREFERENCE_KEY_LOOKUP - -} // namespace esphome diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index cfeddebda7..ed23dfae56 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -56,17 +56,5 @@ namespace esphome { static_assert(PreferencesKeyLookupContract, "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " "load_from_key() (esphome/core/preference_backend.h)"); - -/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys -/// differ and new_pref has no data yet. scratch must hold at least size bytes. -/// Returns true when scratch holds the entity's current data (loaded or just migrated). -/// The old entry is intentionally left in place so a firmware downgrade still finds its data. -/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get -/// valid data for this boot, callers that reload from the preference fall back to their -/// defaults, and the migration simply runs again on the next boot. -/// Only available on key-lookup preference backends; slot-based backends keep their old -/// keys instead. See: https://github.com/esphome/backlog/issues/85 -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key); } // namespace esphome #endif // USE_PREFERENCE_KEY_LOOKUP diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 7eaaae5293..73b7a38a35 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -15,6 +15,13 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { // Set the wake-requested flag BEFORE esp_schedule so the consumer is // guaranteed to see it on its next gate check. wake_request_set(); + // Skip the post when a wake was already signalled and not yet consumed by + // wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code, + // which must not be poked per-byte from the software serial RX ISR (see + // esphome#18409). The flag can stay latched while the loop is awake, which + // is intentional; posts are only needed to cut a suspend short. + if (g_main_loop_woke) + return; g_main_loop_woke = true; esp_schedule(); } diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e1688f4170..bb6452acf2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -109,6 +109,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache = _cache().env if version not in env_cache: env_cache[version] = os.environ.copy() + # Do not leak PYTHONPATH into child env + env_cache[version].pop("PYTHONPATH", None) # Use provided IDF framework if available if "IDF_PATH" not in os.environ: diff --git a/esphome/espota2.py b/esphome/espota2.py index fa15c1dda2..61e897f601 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +import contextlib import gzip import hashlib import io @@ -8,7 +9,6 @@ import logging from pathlib import Path import secrets import socket -import sys import time from typing import Any @@ -76,6 +76,14 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 +# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time +# to clean up a half-open connection (its handshake watchdog runs at 20s) before it +# accepts a new one, so wait between attempts instead of failing the upload outright. +# Every resolved address is tried once, and this many extra attempts are shared +# across the addresses on top of that. +EXTRA_UPLOAD_ATTEMPTS = 2 +UPLOAD_RETRY_DELAY = 5.0 + _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) @@ -171,6 +179,23 @@ class OTAError(EsphomeError): pass +class OTANetworkError(OTAError): + """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" + + +def _committed_error(err: OTANetworkError) -> OTAError: + """Wrap a network failure that happened once the device had the full image. + + Past that point the device commits and reboots on its own, so the failure + must not be retried; a re-upload could flash a device that already updated. + """ + return OTAError( + f"{err} (the device may have already committed the update and " + f"be rebooting; check whether it comes back with the new " + f"firmware before uploading again)" + ) + + def recv_decode( sock: socket.socket, amount: int, decode: bool = True ) -> bytes | list[int]: @@ -209,19 +234,22 @@ def receive_exactly( try: data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg} response: {err}") from err + raise OTANetworkError(f"receiving {msg} response: {err}") from err try: check_error(data, expect) except OTAError as err: sock.close() - raise OTAError(f"receiving {msg}: {err}") from err + # type(err) preserves OTANetworkError vs OTAError so callers can tell + # retryable network failures from device-reported errors; subclasses + # must accept a single message argument + raise type(err)(f"receiving {msg}: {err}") from err while len(data) < amount: try: data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg}: {err}") from err + raise OTANetworkError(f"receiving {msg}: {err}") from err return data @@ -237,7 +265,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None # accept-any-response reads (e.g. feature negotiation, auth nonces) would be # silently passed through and surface later as cryptic decode/timeout failures. if not data: - raise OTAError( + raise OTANetworkError( "Device closed connection without responding. " "This may indicate the device ran out of memory, " "a network issue, or the connection was interrupted." @@ -274,7 +302,7 @@ def send_check( sock.sendall(data) except OSError as err: - raise OTAError(f"sending {msg}: {err}") from err + raise OTANetworkError(f"sending {msg}: {err}") from err def perform_ota( @@ -306,7 +334,7 @@ def perform_ota( send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.debug("Device support OTA version: %s", version) + _LOGGER.info("Connection established; device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -417,6 +445,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) + _LOGGER.info("Handshake complete") + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) @@ -449,21 +479,43 @@ def perform_ota( offset = 0 progress = ProgressBar("Uploading") - while True: - chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] - if not chunk: - break - offset += len(chunk) + try: + while True: + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] + if not chunk: + break + offset += len(chunk) + + try: + sock.sendall(chunk) + except OSError as err: + # A send failure can hide an error byte the device reported + # just before dropping the connection; surface that as the + # real, non-retryable cause when it is available + try: + sock.settimeout(1.0) + check_error(recv_decode(sock, 1), None) + except (OSError, OTANetworkError) as probe_err: + _LOGGER.debug( + "No device error behind the send failure: %s", probe_err + ) + raise OTANetworkError(f"sending data: {err}") from err - try: - sock.sendall(chunk) if version >= OTA_VERSION_2_0: - receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) - except OSError as err: - sys.stderr.write("\n") - raise OTAError(f"sending data: {err}") from err + try: + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + except OTANetworkError as err: + if offset < upload_size: + raise + # The device already had the complete image when this ack + # was lost, so it may be committing; do not retry + raise _committed_error(err) from err - progress.update(offset / upload_size) + progress.update(offset / upload_size) + except OTAError: + # Terminate the progress bar line before the error is logged + progress.done() + raise progress.done() # Enable nodelay for last checks @@ -472,11 +524,25 @@ def perform_ota( _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) - send_check(sock, RESPONSE_OK, "end acknowledgement") + # Once the device has the complete image it commits the update and + # reboots on its own; the exact commit point is not observable from + # here, so treat everything past the data phase as non-retryable. A + # re-upload could flash a device that already updated successfully. + try: + receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) + receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) + except OTANetworkError as err: + raise _committed_error(err) from err - _LOGGER.info("OTA successful") + try: + send_check(sock, RESPONSE_OK, "end acknowledgement") + except OTANetworkError as err: + # The device treats a missing end acknowledgement as non-fatal and is + # already rebooting into the new firmware, so the update succeeded + _LOGGER.warning("Failed sending end acknowledgement: %s", err) + _LOGGER.info("OTA successful (end acknowledgement not delivered)") + else: + _LOGGER.info("OTA successful") # Do not connect logs until it is fully on time.sleep(1) @@ -510,8 +576,33 @@ def run_ota_impl_( ) raise OTAError(err) from err - for r in res: - af, socktype, _, _, sa = r + if not res: + _LOGGER.error("No addresses to connect to for %s", remote_host) + return 1, None + + # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries + # are shared across the addresses, cycling through them. Wait before an + # attempt when the previous one actually reached the device, or when + # revisiting an address, so a flaky link can recover and the device can + # clean up a half-open connection (its handshake watchdog runs at 20s); + # moving on to the next address family stays immediate. Known limitation: + # a silent mid-transfer drop with no reset can wedge the device until its + # 90s data timeout, which outlasts this budget; the retries target the + # common failures where the device resets or closes the link promptly. + total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS + last_error = "" + reached_device = False + for attempt in range(total_attempts): + af, socktype, _, _, sa = res[attempt % len(res)] + if reached_device or attempt >= len(res): + _LOGGER.info( + "Retrying in %.0f seconds (attempt %d of %d)...", + UPLOAD_RETRY_DELAY, + attempt + 1, + total_attempts, + ) + time.sleep(UPLOAD_RETRY_DELAY) + reached_device = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -519,23 +610,30 @@ def run_ota_impl_( sock.connect(sa) except OSError as err: sock.close() - _LOGGER.error("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + _LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + last_error = f"connecting to {sa[0]} failed: {err}" continue _LOGGER.info("Connected to %s", sa[0]) - with Path(filename).open("rb") as file_handle: + reached_device = True + with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) + except OTANetworkError as err: + # Transient network failure; retry + last_error = str(err) + _LOGGER.warning("%s", last_error) + continue except OTAError as err: + # Device-reported error (wrong password, wrong flash size, ...); + # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) return 1, None - finally: - sock.close() # Successfully uploaded to sa[0] return 0, sa[0] - _LOGGER.error("Connection failed.") + _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) return 1, None diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 86d5e4eaea..b8a43220ff 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -155,6 +155,8 @@ def run_command( _LOGGER.debug("%s - running ...", cmd_str) run_env = os.environ.copy() + # Do not leak PYTHONPATH + run_env.pop("PYTHONPATH", None) if env: run_env.update(env) diff --git a/esphome/helpers.py b/esphome/helpers.py index 2731109164..9b2a461ccd 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -91,13 +91,8 @@ def fnv1a_32bit_hash(string: str) -> int: def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h - with per_code_point set. This is the OLD entity hash; it computes preference - keys that existing devices already have stored (see - https://github.com/esphome/backlog/issues/85) and is also still used for live - keys derived from config IDs (see the motion component's calibration key). - Note: lower() here is Unicode aware while the C++ reconstruction is not; see - the known limitation note on the C++ function. + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. + If you modify this function, update the C++ version and tests in both places. """ return fnv1_hash(sanitize(snake_case(name))) @@ -105,9 +100,9 @@ def fnv1_hash_object_id(name: str) -> int: def fnv1_hash_name(name: str) -> int: """Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations). - IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h, - which hashes the name bytes as stored on the device. - Used for pre-computing entity keys at code generation time. + 2026.8 beta firmware stored preferences under keys derived from this hash; + a future key migration must reconstruct those keys to recover that data + (see https://github.com/esphome/backlog/issues/85). """ return _fnv1_hash(name.encode("utf-8")) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 821bd41acb..d1f7bfe6ba 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -48,7 +48,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.3 + version: 2.0.4 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: @@ -110,7 +110,7 @@ dependencies: # refuses to build two managed components whose names differ only by # namespace. The Arduino envs get noise-c as a PlatformIO library instead. esphome/noise-c: - version: 0.1.15 + version: 0.1.18 rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0" # Declared even though noise-c depends on it, so that the PlatformIO-library diff --git a/esphome/loader.py b/esphome/loader.py index 7a659aa0a8..f994f0c5eb 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -269,10 +269,9 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: # If `domain` is the legacy name of a renamed component, redirect to the # canonical module so the rest of the loader (and every caller of # `get_component(legacy)`) transparently sees the new component. - alias_map = _get_alias_map() - if domain in alias_map: - canonical = alias_map[domain] - manif = _lookup_module(canonical, exception) + alias_meta = get_alias_metadata().get(domain) + if alias_meta is not None: + manif = _lookup_module(alias_meta.canonical, exception) if manif is not None: _COMPONENT_CACHE[domain] = manif return manif @@ -329,8 +328,10 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # --------------------------------------------------------------------------- # # A component can declare ``ALIASES = ["legacy_name"]`` (and optionally -# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two -# integrations are then wired up automatically: +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run +# ``script/build_alias_registry.py`` to regenerate +# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry +# is stale). Two integrations are then wired up automatically: # # 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) # intercepts ``esphome.components.``/``....`` @@ -344,13 +345,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # dependency checks, schema validation and codegen all see only the # canonical name. # -# Both lookups are populated by ``_build_alias_map``, which **AST-parses** -# every component's ``__init__.py`` rather than importing it. That keeps the -# cost low: scanning ~400 components on disk takes ~5 ms instead of the -# multi-second cost of executing every component's import side-effects. +# Both lookups read the checked-in registry in ``esphome.component_aliases`` +# (generated by ``script/build_alias_registry.py``, verified in CI), so no +# component-directory scan happens at runtime. ``_build_alias_map`` below is +# the generator's scan implementation; it **AST-parses** each component's +# ``__init__.py`` rather than importing it. -_ALIAS_MAP_CACHE: dict[str, str] | None = None _ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None @@ -367,31 +368,17 @@ class AliasMeta: removal_version: str | None -def _ensure_alias_caches() -> None: - """Populate both alias caches from a single directory scan. - - ``_build_alias_map`` returns both maps together, so building them in one - shot avoids scanning every component's ``__init__.py`` twice when a run - needs both the canonical map (loader) and the metadata map (config - pre-pass). - """ - global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE - if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: - _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() - - -def _get_alias_map() -> dict[str, str]: - """Return the legacy-name → canonical-name map, building it lazily.""" - _ensure_alias_caches() - return _ALIAS_MAP_CACHE - - def get_alias_metadata() -> dict[str, AliasMeta]: - """Return the legacy-name → :class:`AliasMeta` map (cached). + """Return the legacy-name → :class:`AliasMeta` map, built lazily from + the generated registry.""" + global _ALIAS_META_CACHE # noqa: PLW0603 + if _ALIAS_META_CACHE is None: + from esphome.component_aliases import COMPONENT_ALIASES - Used by the YAML pre-pass to format a per-alias deprecation warning. - """ - _ensure_alias_caches() + _ALIAS_META_CACHE = { + alias: AliasMeta(canonical=canonical, removal_version=removal_version) + for alias, (canonical, removal_version) in COMPONENT_ALIASES.items() + } return _ALIAS_META_CACHE @@ -537,11 +524,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder): # least three parts, so ``parts[2]`` (the domain) always exists. parts = fullname.split(".") domain = parts[2] - alias_map = _get_alias_map() - if domain not in alias_map: + alias_meta = get_alias_metadata().get(domain) + if alias_meta is None: return None - parts[2] = alias_map[domain] + parts[2] = alias_meta.canonical canonical_fullname = ".".join(parts) try: canonical_module = importlib.import_module(canonical_fullname) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 0e7ffce939..08a4fcff78 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re import shutil +import subprocess import sys from typing import TYPE_CHECKING, Any @@ -234,6 +235,35 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) +def _ccache_usable() -> bool: + """Return True when the ``ccache`` on PATH actually runs. + + ``shutil.which`` proves existence, not runnability: on Windows it also + matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose + target is gone. Wrapping compiles around such a find fails every compile + step with an opaque OS error, so probe once and fall back to compiling + without ccache when the probe fails. + """ + ccache = shutil.which("ccache") + if ccache is None: + return False + try: + subprocess.run( + [ccache, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + _LOGGER.warning( + "Ignoring ccache at %s because it failed to run; compiling without ccache", + ccache, + ) + return False + return True + + def _ccache_env() -> dict[str, str]: """Return ccache settings for PlatformIO builds. @@ -266,7 +296,7 @@ def _ccache_env() -> dict[str, str]: if "ESPHOME_CCACHE_ENABLE" in os.environ: enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") else: - enabled = shutil.which("ccache") is not None + enabled = _ccache_usable() env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} if not enabled: return env diff --git a/esphome/storage_json.py b/esphome/storage_json.py index a90a36b848..9219914529 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -71,8 +71,11 @@ def archive_storage_path() -> Path: def _to_path_if_not_none(value: str | None) -> Path | None: - """Convert a string to Path if it's not None.""" - return Path(value) if value is not None else None + """Convert a string to Path; None and the legacy "None" both map to None. + + Sidecars written before as_dict skipped unset paths hold str(None). + """ + return Path(value) if value is not None and value != "None" else None def _parse_framework_version(framework_version: str) -> Version: @@ -170,8 +173,10 @@ class StorageJSON: "address": self.address, "web_port": self.web_port, "esp_platform": self.target_platform, - "build_path": str(self.build_path), - "firmware_bin_path": str(self.firmware_bin_path), + "build_path": str(self.build_path) if self.build_path else None, + "firmware_bin_path": ( + str(self.firmware_bin_path) if self.firmware_bin_path else None + ), "loaded_integrations": sorted(self.loaded_integrations), "loaded_platforms": sorted(self.loaded_platforms), "no_mdns": self.no_mdns, @@ -189,7 +194,18 @@ class StorageJSON: write_file_if_changed(path, self.to_json()) @staticmethod - def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: + def from_esphome_core( + esph: CoreType, old: StorageJSON | None, *, claim_build: bool = True + ) -> StorageJSON: + """Build a sidecar from post-validation CORE state. + + claim_build=False (the upload/logs fallback, which runs no build) + carries the build-artifact fields (esphome_version, + firmware_bin_path) from *old* instead of asserting this run built + firmware. Validation-derived fields (platform, framework_version, + toolchain, build_path) always stamp; storage_should_clean compares + them against the next compile. + """ hardware = esph.target_platform.upper() framework_version: str | None = None if esph.is_esp32: @@ -204,13 +220,21 @@ class StorageJSON: name=esph.name, friendly_name=esph.friendly_name, comment=esph.comment, - esphome_version=const.__version__, + esphome_version=( + const.__version__ + if claim_build + else (old.esphome_version if old else None) + ), src_version=1, address=esph.address, web_port=esph.web_port, target_platform=hardware, build_path=esph.build_path, - firmware_bin_path=esph.firmware_bin, + firmware_bin_path=( + esph.firmware_bin + if claim_build + else (old.firmware_bin_path if old else None) + ), loaded_integrations=esph.loaded_integrations, loaded_platforms=esph.loaded_platforms, no_mdns=( @@ -302,11 +326,27 @@ class StorageJSON: except Exception: # noqa: BLE001 # pylint: disable=broad-except return None + @staticmethod + def load_strict(path: Path) -> StorageJSON | None: + """Like load, but None only means missing; an unreadable file raises.""" + if not path.is_file(): + return None + return StorageJSON._load_impl(path) + + def can_apply_to_core(self) -> bool: + """True when the sidecar carries everything apply_to_core hands CORE. + + Wizard-written sidecars leave build_path unset (older wizards also + the platform fields) and can't drive upload/logs. + """ + return bool((self.core_platform or self.target_platform) and self.build_path) + def apply_to_core(self) -> None: """Populate CORE with the metadata upload/logs read. Inverse of :meth:`from_esphome_core`. Keep paired -- a new - attribute upload/logs needs has to be captured there too. + attribute upload/logs needs has to be captured there too and + reflected in :meth:`can_apply_to_core`. Validator-only fields (loaded_integrations/platforms, friendly_name) are skipped; the fast path doesn't run validation and CORE.__init__ defaults them. diff --git a/esphome/util.py b/esphome/util.py index 2fc34f3a69..b8ffa048ca 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -390,6 +390,20 @@ def is_dev_esphome_version(): return "dev" in const.__version__ +# Remove before 2027.2.0 +def parse_esphome_version() -> tuple[int, int, int]: + """Deprecated: use esphome.config_validation.require_esphome_version instead.""" + from esphome.core import Version + + _LOGGER.warning( + "parse_esphome_version() is deprecated. Use " + "cv.require_esphome_version to gate on a minimum version. " + "Removed in 2027.2.0" + ) + version = Version.parse(const.__version__) + return version.major, version.minor, version.patch + + # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): diff --git a/platformio.ini b/platformio.ini index f2cdb07760..b122bd6f9b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -78,7 +78,7 @@ lib_deps = ${common.lib_deps} ; api -- on the ESP-IDF framework this comes from the component registry ; instead (see esphome/idf_component.yml), so it is not in [common]. - esphome/noise-c@0.1.15 ; api + esphome/noise-c@0.1.18 ; api SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt @@ -246,7 +246,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.15 ; api + esphome/noise-c@0.1.18 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -643,7 +643,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.15 ; used by api + esphome/noise-c@0.1.18 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/requirements.txt b/requirements.txt index 85a0f55263..61011f2fbd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.1 +aioesphomeapi==45.10.3 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 @@ -23,11 +23,11 @@ pillow==12.3.0 resvg-py==0.3.4 freetype-py==2.5.1 jinja2==3.1.6 -bleak==2.1.1 +bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.1 # native esp-idf toolchain global cache dir +platformdirs==4.11.2 # native esp-idf toolchain global cache dir filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this diff --git a/requirements_test.txt b/requirements_test.txt index 1832ffd433..95ee97437d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.4.12 # also change in .github/workflows/ci.yml when updating +prek==0.4.13 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 diff --git a/script/build_alias_registry.py b/script/build_alias_registry.py new file mode 100755 index 0000000000..e007c075eb --- /dev/null +++ b/script/build_alias_registry.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate esphome/component_aliases.py from component ALIASES declarations. + +Run without arguments to regenerate the registry; ``--check`` (run in CI) +verifies it is up to date. +""" + +import argparse +from pathlib import Path +import sys + +# The root directory of the repo +root = Path(__file__).parent.parent +# Make the repo's esphome package win over any installed copy +sys.path.insert(0, str(root)) + +from esphome.helpers import write_file_if_changed # noqa: E402 +from esphome.loader import _build_alias_map # noqa: E402 + +parser = argparse.ArgumentParser() +parser.add_argument( + "--check", + help="Check if the alias registry is up to date.", + action="store_true", +) +args = parser.parse_args() + +registry_file = root / "esphome" / "component_aliases.py" + +HEADER = '''"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { +''' + +# _build_alias_map scans the real component tree and already rejects +# duplicate and shadowing aliases with an EsphomeError. +_, alias_meta = _build_alias_map() + +lines = [HEADER] +for alias, meta in sorted(alias_meta.items()): + removal = f'"{meta.removal_version}"' if meta.removal_version else "None" + lines.append(f' "{alias}": ("{meta.canonical}", {removal}),\n') +lines.append("}\n") +content = "".join(lines) + +if args.check: + if registry_file.read_text(encoding="utf-8") != content: + print("Component alias registry is not up to date.") + print("Please run `script/build_alias_registry.py`") + sys.exit(1) + print("Component alias registry is up to date") +else: + write_file_if_changed(registry_file, content) + print(f"Wrote {registry_file}") diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index 0908b99595..33ca84d76c 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -20,17 +20,21 @@ from jinja2 import Environment, FileSystemLoader sys.path.insert(0, str(Path(__file__).parent.parent)) # pylint: disable=wrong-import-position +from helpers import run_gh_command # noqa: E402 # Comment marker to identify our memory impact comments COMMENT_MARKER = "" -def run_gh_command(args: list[str], operation: str) -> subprocess.CompletedProcess: - """Run a gh CLI command with error handling. +def run_gh_command_logged( + args: list[str], operation: str, *, retry: bool = True +) -> subprocess.CompletedProcess: + """Run a gh CLI command with retries and error reporting. Args: args: Command arguments (including 'gh') operation: Description of the operation for error messages + retry: Pass False for non-idempotent commands (see run_gh_command) Returns: CompletedProcess result @@ -39,12 +43,7 @@ def run_gh_command(args: list[str], operation: str) -> subprocess.CompletedProce subprocess.CalledProcessError: If command fails (with detailed error output) """ try: - return subprocess.run( - args, - check=True, - capture_output=True, - text=True, - ) + return run_gh_command(args, retry=retry) except subprocess.CalledProcessError as e: print( f"ERROR: {operation} failed with exit code {e.returncode}", file=sys.stderr @@ -472,7 +471,7 @@ def find_existing_comment(pr_number: str) -> str | None: print(f"DEBUG: Looking for existing comment on PR #{pr_number}", file=sys.stderr) # Use gh api to get comments directly - this returns the numeric id field - result = run_gh_command( + result = run_gh_command_logged( [ "gh", "api", @@ -535,7 +534,7 @@ def update_existing_comment(comment_id: str, comment_body: str) -> None: """ print(f"DEBUG: Updating existing comment {comment_id}", file=sys.stderr) print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr) - result = run_gh_command( + result = run_gh_command_logged( [ "gh", "api", @@ -562,9 +561,12 @@ def create_new_comment(pr_number: str, comment_body: str) -> None: """ print(f"DEBUG: Posting new comment on PR #{pr_number}", file=sys.stderr) print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr) - result = run_gh_command( + # Creating a comment is not idempotent: a retry after a dropped response + # could post the same comment twice, so fail on the first error instead. + result = run_gh_command_logged( ["gh", "pr", "comment", pr_number, "--body", comment_body], operation="Create PR comment", + retry=False, ) print(f"DEBUG: Post response: {result.stdout}", file=sys.stderr) diff --git a/script/helpers.py b/script/helpers.py index 7cc001d92f..11549808ff 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -469,6 +469,77 @@ def get_target_branch() -> str | None: return None +# Substrings (matched case-insensitively against gh's stderr) that identify +# transient failures worth retrying: server errors (HTTP 5xx) and dropped or +# failed connections. Permanent failures (bad auth, missing PR, the 300-file +# diff limit) never match so callers see them immediately. Phrases are +# anchored so gh's GraphQL "Could not resolve to a PullRequest" (a missing +# PR) never classifies as a DNS failure. +_TRANSIENT_GH_ERROR_RE = re.compile( + r"http 5\d\d" + r"|timed out|timeout" + r"|connection (?:reset|refused|closed)" + r"|no such host|could not resolve host" + # gh intercepts DNS errors and prints its own "error connecting to + # " text; the Go phrases above are kept as a hedge in case a + # future gh stops swallowing the underlying error + r"|error connecting to" + r"|failed to verify certificate" + # Go reports a server-closed connection as 'Post "": EOF'; the + # quote-and-colon anchor keeps a URL or message body containing the + # letters from matching + r"|unexpected eof" + r'|": eof' + r"|network is unreachable" + r"|temporary failure" +) + +# Same retry policy as git network commands in esphome/git.py: 3 attempts +# with 2s/4s backoff. +_GH_MAX_ATTEMPTS = 3 + + +def run_gh_command( + args: list[str], *, retry: bool = True +) -> subprocess.CompletedProcess[str]: + """Run a gh CLI command, retrying transient network and server failures. + + Args: + args: Full command line, including the leading "gh". + retry: Pass False for commands that are not idempotent (e.g. posting + a comment), where a retry after a dropped response could repeat + a write that already succeeded server-side. + + Returns: + CompletedProcess with captured text output. + + Raises: + subprocess.CalledProcessError: If the command fails with a permanent + error, or is still failing after the retries are exhausted. + """ + attempts = _GH_MAX_ATTEMPTS if retry else 1 + attempt = 0 + while True: + try: + return subprocess.run( + args, check=True, capture_output=True, text=True, close_fds=False + ) + except subprocess.CalledProcessError as err: + attempt += 1 + stderr = err.stderr or "" + if attempt >= attempts or not _TRANSIENT_GH_ERROR_RE.search(stderr.lower()): + raise + delay = 2**attempt + # Only the leading arguments: comment-update calls carry the + # whole multi-KB comment body in the argument list + print( + f"WARNING: {' '.join(args[:3])} failed: {stderr.strip()}; " + f"retrying in {delay}s (attempt {attempt}/{attempts})", + file=sys.stderr, + ) + time.sleep(delay) + + @cache def _get_changed_files_github_actions() -> list[str] | None: """Get changed files in GitHub Actions environment. @@ -542,10 +613,22 @@ def changed_files(branch: str | None = None) -> list[str]: def _get_changed_files_from_command(command: list[str]) -> list[str]: - """Run a git command to get changed files and return them as a list.""" - proc = subprocess.run(command, capture_output=True, text=True, check=False) - if proc.returncode != 0: - raise Exception(f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}") + """Run a git or gh command to get changed files and return them as a list.""" + if command[0] == "gh": + try: + proc = run_gh_command(command) + except subprocess.CalledProcessError as e: + raise Exception( + f"Command failed: {' '.join(command)}\nstderr: {e.stderr}" + ) from e + else: + proc = subprocess.run( + command, capture_output=True, text=True, check=False, close_fds=False + ) + if proc.returncode != 0: + raise Exception( + f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}" + ) changed_files = splitlines_no_ends(proc.stdout) cwd = Path.cwd() diff --git a/tests/unit_tests/components/mqtt/__init__.py b/tests/component_tests/bk72xx_ble/__init__.py similarity index 100% rename from tests/unit_tests/components/mqtt/__init__.py rename to tests/component_tests/bk72xx_ble/__init__.py diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml new file mode 100644 index 0000000000..772ab93c79 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-n + +bk72xx: + board: cb2s + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml new file mode 100644 index 0000000000..17fd15b1b4 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-q + +bk72xx: + board: wa2 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml new file mode 100644 index 0000000000..fec21a6aae --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-t + +bk72xx: + board: generic-bk7231t-qfn32-tuya + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml new file mode 100644 index 0000000000..a3290ab50a --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-7252 + +bk72xx: + board: generic-bk7252 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_family_gate.py b/tests/component_tests/bk72xx_ble/test_family_gate.py new file mode 100644 index 0000000000..da67749bb3 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_family_gate.py @@ -0,0 +1,40 @@ +"""The non-5.x family rejection lives in to_code (config validation must stay +family-agnostic for the validate-only CI fixtures), so codegen is the only +place it can be pinned.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import EsphomeError + + +@pytest.mark.parametrize( + ("config_file", "match"), + [ + ("test_bk7231t.yaml", "BK7231T.*BLE 4.2"), + ("test_bk7252.yaml", "BK7251.*BLE 4.2"), + ("test_bk7231q.yaml", "BK7231Q.*no BLE"), + ], +) +def test_unsupported_family_rejected( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + match: str, + caplog: pytest.LogCaptureFixture, +) -> None: + with pytest.raises(EsphomeError, match=match): + generate_main(component_config_path(config_file)) + # Validation itself must not fail (CI validate fixtures run on a BLE 4.2 + # board), but it warns before codegen raises. + assert "cannot compile" in caplog.text + + +def test_ble5_family_generates( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_bk7231n.yaml")) + assert "bk72xx_ble::BK72xxBLE" in main_cpp diff --git a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml index 4d4dab0198..7912fceed6 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-controller bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble: diff --git a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml index 79e9644006..b813e2702e 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-tracker bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble_tracker: diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index 2549125a43..3774d990d3 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None: def test_esp32_defaults_are_valid() -> None: - """esp32 pins the ESP-IDF reference rate and exposes active (default on).""" + """esp32 pins the ESP-IDF reference rate and exposes active (default on). + + Without wifi loaded, the conditional window default falls back to the + historical 30 ms; the wifi-aware resolution is covered by the + esp32_ble_tracker component tests. + """ config = ESP32_SCHEMA({}) assert to_ble_units(config["interval"]) == 512 assert to_ble_units(config["window"]) == 48 diff --git a/tests/component_tests/esp32_ble_tracker/__init__.py b/tests/component_tests/esp32_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py new file mode 100644 index 0000000000..8a25f488fa --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py @@ -0,0 +1,122 @@ +"""Tests for the esp32_ble_tracker conditional scan window default. + +The scan window default depends on wifi coexistence and the IDF version: +IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the +configured window (espressif/esp-idf#18931), so on fixed versions the +historical 30 ms default would only listen 9.4 % of the time and miss most +advertisements. With the coexistence arbiter compiled in on a fixed IDF, the +window instead defaults to the interval, as Espressif recommends; without the +arbiter a full-duty scan would starve wifi, so the 30 ms default is kept. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_device_base import to_ble_units +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_ble_tracker import ( + CONF_SOFTWARE_COEXISTENCE, + CONFIG_SCHEMA, +) +from esphome.const import CONF_INTERVAL, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + +from ..types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32( + set_core_config: SetCoreConfigCallable, +) -> Callable[..., None]: + """Stage an esp32 build with a given IDF version and wifi presence.""" + + def stage(idf: str, *, wifi: bool) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + if wifi: + # Makes cv.OnlyWith default software_coexistence to True, exactly + # as a real config with wifi: does. + CORE.loaded_integrations.add("wifi") + + return stage + + +def _scan_params(config: ConfigType) -> ConfigType: + return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS] + + +@pytest.mark.parametrize( + ("idf", "config", "expected_units"), + [ + ("5.5.5", {}, 512), # first fixed version, default 320 ms interval + ("6.0.1", {}, 512), # any newer version behaves the same + # Follows a user-set interval. + ("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600), + ], +) +def test_wifi_on_fixed_idf_defaults_window_to_interval( + stage_esp32: Callable[..., None], + idf: str, + config: ConfigType, + expected_units: int, +) -> None: + """With wifi coexistence on a fixed IDF, the window defaults to the interval.""" + stage_esp32(idf, wifi=True) + params = _scan_params(config) + assert params[CONF_WINDOW] == params[CONF_INTERVAL] + assert to_ble_units(params[CONF_WINDOW]) == expected_units + + +@pytest.mark.parametrize( + ("idf", "wifi", "config"), + [ + # Buggy IDF over-scans anyway; keep the 30 ms default. + ("5.5.4", True, {}), + # No wifi (e.g. ethernet) means no radio contention. + ("5.5.5", False, {}), + # Coexistence disabled: no arbiter, so a full-duty scan would starve + # wifi outright. + ("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}), + ], +) +def test_30ms_default_kept( + stage_esp32: Callable[..., None], + idf: str, + wifi: bool, + config: ConfigType, +) -> None: + stage_esp32(idf, wifi=wifi) + assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48 + + +@pytest.mark.parametrize("window", ["60ms", "30ms"]) +def test_explicit_window_is_never_touched( + stage_esp32: Callable[..., None], window: str +) -> None: + """A user-set window wins over the conditional default. + + The explicit 30 ms case matters: it is indistinguishable from the + defaulted value by inspection, so the defaulted flag must separate them. + """ + stage_esp32("5.5.5", wifi=True) + params = _scan_params({"scan_parameters": {"window": window}}) + assert to_ble_units(params[CONF_WINDOW]) == to_ble_units( + cv.positive_time_period(window) + ) + + +def test_short_interval_without_window_still_rejected( + stage_esp32: Callable[..., None], +) -> None: + """The provisional 30 ms default validates against the interval as before.""" + stage_esp32("5.5.5", wifi=True) + with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): + _scan_params({"scan_parameters": {"interval": "20ms"}}) diff --git a/tests/component_tests/esp32_hosted/__init__.py b/tests/component_tests/esp32_hosted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_hosted/test_init.py b/tests/component_tests/esp32_hosted/test_init.py new file mode 100644 index 0000000000..cec81e4e83 --- /dev/null +++ b/tests/component_tests/esp32_hosted/test_init.py @@ -0,0 +1,35 @@ +"""Tests for the esp32_hosted ESP-IDF version gate.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_hosted import _final_validate +from esphome.const import PlatformFramework + +from ..types import SetCoreConfigCallable + + +@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"]) +def test_final_validate_accepts_supported_idf( + set_core_config: SetCoreConfigCallable, idf: str +) -> None: + """ESP-IDF 5.3 and newer passes validation unchanged.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + assert _final_validate({}) == {} + + +@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"]) +def test_final_validate_rejects_old_idf( + set_core_config: SetCoreConfigCallable, idf: str +) -> None: + """ESP-IDF older than 5.3 is rejected with a clear error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"): + _final_validate({}) diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py index 95f6a0321e..7596983ee2 100644 --- a/tests/integration/entity_utils.py +++ b/tests/integration/entity_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case if TYPE_CHECKING: from aioesphomeapi import DeviceInfo, EntityInfo @@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: return device_info.name.endswith(f"-{mac_suffix}") -def _resolve_entity_name( +def _get_name_for_object_id( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> str: - """Resolve the effective name for an entity. + """Get the name used for object_id computation. This is the algorithm that aioesphomeapi will use to determine which - name to use for computing object_id client-side from API data; the same - name is what the device hashes into the entity key. + name to use for computing object_id client-side from API data. Args: entity: The entity to get name for @@ -73,27 +72,27 @@ def compute_entity_object_id( Returns: The computed object_id string """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return compute_object_id(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return compute_object_id(name_for_id) -def compute_entity_key( +def compute_entity_hash( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> int: - """Compute expected entity key for an entity. + """Compute expected object_id hash for an entity. Args: - entity: The entity to compute the key for + entity: The entity to compute hash for device_info: Device info from the API device_id_to_name: Mapping of device_id to device name for sub-devices Returns: - The computed FNV-1 hash of the raw name + The computed FNV-1 hash """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return fnv1_hash_name(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return fnv1_hash_object_id(name_for_id) def verify_entity_object_id( @@ -119,7 +118,7 @@ def verify_entity_object_id( f"expected '{expected_object_id}', got '{entity.object_id}'" ) - expected_hash = compute_entity_key(entity, device_info, device_id_to_name) + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) assert entity.key == expected_hash, ( f"hash mismatch for entity '{entity.name}': " f"expected {expected_hash:#x}, got {entity.key:#x}" diff --git a/tests/integration/fixtures/camera_mock.yaml b/tests/integration/fixtures/camera_mock.yaml new file mode 100644 index 0000000000..fa354d341f --- /dev/null +++ b/tests/integration/fixtures/camera_mock.yaml @@ -0,0 +1,19 @@ +esphome: + name: camera-mock-test + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +mock_camera: + name: Mock Camera + # Larger than MAX_BATCH_PACKET_SIZE (1390) so the image is split across + # multiple CameraImageResponse chunks and the client must reassemble. + # Must match IMAGE_SIZE in test_camera_mock.py. + image_size: 4096 diff --git a/tests/integration/fixtures/external_components/mock_camera/__init__.py b/tests/integration/fixtures/external_components/mock_camera/__init__.py new file mode 100644 index 0000000000..57aaf07ab9 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core.entity_helpers import setup_entity +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/tests"] +AUTO_LOAD = ["camera"] + +CONF_IMAGE_SIZE = "image_size" + +mock_camera_ns = cg.esphome_ns.namespace("mock_camera") +MockCamera = mock_camera_ns.class_("MockCamera", cg.Component, cg.EntityBase) + +CONFIG_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(MockCamera), + cv.Optional(CONF_IMAGE_SIZE, default=1024): cv.positive_not_null_int, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_CAMERA") + var = cg.new_Pvariable(config[CONF_ID]) + await setup_entity(var, config, "camera") + await cg.register_component(var, config) + cg.add(var.set_image_size(config[CONF_IMAGE_SIZE])) diff --git a/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp b/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp new file mode 100644 index 0000000000..64ed6bfe5c --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp @@ -0,0 +1,30 @@ +#include "mock_camera.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mock_camera { + +static const char *const TAG = "mock_camera"; + +void MockCamera::loop() { + uint8_t requesters = this->single_requesters_ | this->stream_requesters_; + if (requesters == 0) + return; + uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_frame_ms_ < FRAME_INTERVAL_MS) + return; + this->last_frame_ms_ = now; + this->single_requesters_ = 0; + + auto image = std::make_shared(this->image_size_, this->frame_counter_, requesters); + ESP_LOGV(TAG, "Producing frame %u (%u bytes, requesters 0x%02X)", this->frame_counter_, this->image_size_, + requesters); + this->frame_counter_++; + for (auto *listener : this->listeners_) { + listener->on_camera_image(image); + } +} + +void MockCamera::dump_config() { ESP_LOGCONFIG(TAG, "Mock Camera (%u byte frames)", this->image_size_); } + +} // namespace esphome::mock_camera diff --git a/tests/integration/fixtures/external_components/mock_camera/mock_camera.h b/tests/integration/fixtures/external_components/mock_camera/mock_camera.h new file mode 100644 index 0000000000..bcf40bba67 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/mock_camera.h @@ -0,0 +1,80 @@ +#pragma once + +#include "esphome/components/camera/camera.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::mock_camera { + +/** Deterministic in-memory camera image. + * Byte i of frame N is (N + i) & 0xFF so tests can validate + * reassembled data from just the first byte. + */ +class MockCameraImage : public camera::CameraImage { + public: + MockCameraImage(size_t size, uint8_t frame_counter, uint8_t requesters) + : data_(new uint8_t[size]), size_(size), requesters_(requesters) { + for (size_t i = 0; i < size; i++) { + this->data_[i] = static_cast(frame_counter + i); + } + } + uint8_t *get_data_buffer() override { return this->data_.get(); } + size_t get_data_length() override { return this->size_; } + bool was_requested_by(camera::CameraRequester requester) const override { + return (this->requesters_ & (1 << requester)) != 0; + } + + protected: + std::unique_ptr data_; + size_t size_; + uint8_t requesters_; +}; + +class MockCameraImageReader : public camera::CameraImageReader { + public: + void set_image(std::shared_ptr image) override { + this->image_ = std::move(image); + this->offset_ = 0; + } + size_t available() const override { return this->image_ ? this->image_->get_data_length() - this->offset_ : 0; } + uint8_t *peek_data_buffer() override { return this->image_->get_data_buffer() + this->offset_; } + void consume_data(size_t consumed) override { this->offset_ += consumed; } + void return_image() override { + this->image_.reset(); + this->offset_ = 0; + } + + protected: + std::shared_ptr image_; + size_t offset_{0}; +}; + +/** Virtual camera producing deterministic frames on request or stream. */ +class MockCamera : public camera::Camera { + public: + void loop() override; + void dump_config() override; + + void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); } + camera::CameraImageReader *create_image_reader() override { return new MockCameraImageReader(); } + void request_image(camera::CameraRequester requester) override { this->single_requesters_ |= (1 << requester); } + void start_stream(camera::CameraRequester requester) override { this->stream_requesters_ |= (1 << requester); } + void stop_stream(camera::CameraRequester requester) override { this->stream_requesters_ &= ~(1 << requester); } + + void set_image_size(uint32_t size) { this->image_size_ = size; } + + protected: + static constexpr uint32_t FRAME_INTERVAL_MS = 50; + + // Members ordered largest to smallest to minimize padding + std::vector listeners_; + uint32_t image_size_{1024}; + uint32_t last_frame_ms_{0}; + uint8_t frame_counter_{0}; + uint8_t single_requesters_{0}; + uint8_t stream_requesters_{0}; +}; + +} // namespace esphome::mock_camera diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index d4511bb8c6..2097b2fbf9 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -71,38 +71,6 @@ esphome: ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); } - // Raw name hash: matches Python fnv1_hash_name("My Sensor Name") - uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14); - if (hash_raw == 0x8cec6fb0) { - ESP_LOGI("FNV1_OID", "raw PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw); - } - - // Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température") - uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12); - if (hash_raw_utf8 == 0x531a74aa) { - ESP_LOGI("FNV1_OID", "raw_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8); - } - - // Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température") - uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true); - if (hash_old_utf8 == 0x965698f3) { - ESP_LOGI("FNV1_OID", "old_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8); - } - - // Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度") - uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true); - if (hash_old_cjk == 0x3276cb9f) { - ESP_LOGI("FNV1_OID", "old_cjk PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk); - } - host: api: logger: diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 582add90a8..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -156,17 +156,10 @@ button: ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); - // Log preference key bases for entities that actually store preferences. - // This is the key base make_entity_preference() uses: entity key XOR device id. - ESP_LOGI("test", "Device A Switch Pref Hash: %u", - id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Switch Pref Hash: %u", - id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Switch Pref Hash: %u", - id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero()); - ESP_LOGI("test", "Device A Number Pref Hash: %u", - id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Number Pref Hash: %u", - id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Number Pref Hash: %u", - id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/fixtures/preference_key_migration.yaml b/tests/integration/fixtures/preference_key_stability.yaml similarity index 94% rename from tests/integration/fixtures/preference_key_migration.yaml rename to tests/integration/fixtures/preference_key_stability.yaml index a9b01fc2d2..a74bb2c7f7 100644 --- a/tests/integration/fixtures/preference_key_migration.yaml +++ b/tests/integration/fixtures/preference_key_stability.yaml @@ -1,5 +1,5 @@ esphome: - name: host-pref-key-migration + name: host-pref-key-stability host: api: diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml index 2494a430da..b01c8e452b 100644 --- a/tests/integration/fixtures/sensor_filters_delta.yaml +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -33,6 +33,11 @@ sensor: id: source_sensor_5 accuracy_decimals: 1 + - platform: template + name: "Source Sensor 6" + id: source_sensor_6 + accuracy_decimals: 1 + - platform: copy source_id: source_sensor_1 name: "Filter Min" @@ -81,6 +86,13 @@ sensor: filters: - delta: 50% + - platform: copy + source_id: source_sensor_6 + name: "Filter NaN" + id: filter_nan + filters: + - delta: 0 + script: - id: test_filter_min then: @@ -188,6 +200,24 @@ script: id: source_sensor_5 state: 250.0 # Passes (delta=90 > 80) + - id: test_filter_nan + then: + - sensor.template.publish: + id: source_sensor_6 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: 2.0 + button: - platform: template name: "Test Filter Min" @@ -218,3 +248,9 @@ button: id: btn_filter_percentage on_press: - script.execute: test_filter_percentage + + - platform: template + name: "Test Filter NaN" + id: btn_filter_nan + on_press: + - script.execute: test_filter_nan diff --git a/tests/integration/test_camera_mock.py b/tests/integration/test_camera_mock.py new file mode 100644 index 0000000000..6819d7a6d4 --- /dev/null +++ b/tests/integration/test_camera_mock.py @@ -0,0 +1,73 @@ +"""Integration test for the camera API flow using a mock camera platform.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import CameraInfo, CameraState, EntityState +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Must match image_size in fixtures/camera_mock.yaml +IMAGE_SIZE = 4096 +STREAM_FRAMES = 3 + + +def _verify_frame(data: bytes) -> int: + """Verify the deterministic frame pattern and return the frame counter.""" + assert len(data) == IMAGE_SIZE, f"expected {IMAGE_SIZE} bytes, got {len(data)}" + counter = data[0] + assert data == bytes((counter + i) & 0xFF for i in range(IMAGE_SIZE)), ( + "frame pattern mismatch" + ) + return counter + + +@pytest.mark.asyncio +async def test_camera_mock( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Single-image and stream requests deliver reassembled deterministic frames.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + camera = require_entity(entities, "mock_camera", CameraInfo) + + loop = asyncio.get_running_loop() + images: list[bytes] = [] + single_image: asyncio.Future[None] = loop.create_future() + stream_done: asyncio.Future[None] = loop.create_future() + + def on_state(state: EntityState) -> None: + if not (isinstance(state, CameraState) and state.key == camera.key): + return + images.append(bytes(state.data)) + if not single_image.done(): + single_image.set_result(None) + elif len(images) >= STREAM_FRAMES and not stream_done.done(): + stream_done.set_result(None) + + client.subscribe_states(on_state) + + # Single image request: one complete frame arrives, reassembled + # from multiple chunks (4096 > 1390 byte packets) + client.request_single_image() + await asyncio.wait_for(single_image, timeout=10) + first_counter = _verify_frame(images[0]) + + # Stream request: multiple consecutive frames arrive + images.clear() + client.request_image_stream() + await asyncio.wait_for(stream_done, timeout=10) + + # Frames are distinct, ordered, and fresh per the mock's counter. + # Not exactly consecutive: the API drops frames by design while the + # previous image is still being sent, so allow small gaps. + counters = [_verify_frame(img) for img in images[:STREAM_FRAMES]] + for prev, cur in zip(counters, counters[1:], strict=False): + assert cur != prev, f"duplicate frames: {counters}" + assert ((cur - prev) & 0xFF) < 16, f"frames out of order: {counters}" + assert counters[0] != first_counter, "stream should produce new frames" diff --git a/tests/integration/test_fnv1_hash_object_id.py b/tests/integration/test_fnv1_hash_object_id.py index 0c2848a20c..23e8ca04c2 100644 --- a/tests/integration/test_fnv1_hash_object_id.py +++ b/tests/integration/test_fnv1_hash_object_id.py @@ -37,10 +37,6 @@ async def test_fnv1_hash_object_id( "special", "complex", "empty", - "raw", - "raw_utf8", - "old_utf8", - "old_cjk", } def on_log_line(line: str) -> None: diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index 8dafb37c64..c8603e0682 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -2,8 +2,8 @@ This test verifies a three-way match between: 1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) -2. C++ entity key generation (fnv1_hash of the raw name in helpers.h) -3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py) +2. C++ hash generation (fnv1_hash_object_id in helpers.h) +3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) The API response contains C++ computed values, so verifying API == Python implicitly verifies C++ == Python == API for both object_id and hash. @@ -25,7 +25,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -123,7 +123,7 @@ async def test_object_id_api_verification( ) # Verify hash can be computed from the name - hash_from_name = fnv1_hash_name(entity_name) + hash_from_name = fnv1_hash_object_id(entity_name) assert hash_from_name == entity.key, ( f"Entity '{entity_name}': hash mismatch. " f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" @@ -164,7 +164,7 @@ async def test_object_id_api_verification( ) # Verify hash matches - expected_hash = fnv1_hash_name(expected_name) + expected_hash = fnv1_hash_object_id(expected_name) assert entity.key == expected_hash, ( f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " f"API key: {entity.key:#x}, expected: {expected_hash:#x}" diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index b58593f2ef..7199a2b371 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import ( compute_object_id, @@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix( ) # Hash should match friendly_name - expected_hash = fnv1_hash_name("My Friendly Device") + expected_hash = fnv1_hash_object_id("My Friendly Device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 45b5f730a6..b548f02fde 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( OLD behavior: - is_object_id_dynamic_() returned false (mac suffix not enabled) - Used object_id_c_str_ which was pre-computed in Python - - Python used get_base_entity_name() with fallback to CORE.name + - Python used get_base_entity_object_id() with fallback to CORE.name Result: object_id = sanitize(snake_case(device_name)) """ @@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( ) # Hash should match device name - expected_hash = fnv1_hash_name("test-device") + expected_hash = fnv1_hash_object_id("test-device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_preference_key_migration.py b/tests/integration/test_preference_key_stability.py similarity index 57% rename from tests/integration/test_preference_key_migration.py rename to tests/integration/test_preference_key_stability.py index e7f699bb12..5704075746 100644 --- a/tests/integration/test_preference_key_migration.py +++ b/tests/integration/test_preference_key_stability.py @@ -1,14 +1,14 @@ -"""Integration test for entity preference key migration. +"""Integration test for entity preference key stability. -Entity keys are now the FNV-1 hash of the raw name instead of the sanitized -object_id (https://github.com/esphome/backlog/issues/85). On key-lookup -preference backends, make_entity_preference() must move data stored under the -old key to the new key, so devices keep their restored state after upgrading. +Entity preferences are stored under keys derived from the sanitized object_id +hash. This test seeds the host preferences file the way existing firmware +wrote it and verifies the state is restored, proving the key scheme has not +drifted; a save and reload round trip cannot catch drift because it writes +and reads with the same code. -This test seeds the host preferences file the way a pre-migration firmware -would have written it and verifies: -1. Data stored under the OLD key is restored (migration happened, no data loss) -2. Data already stored under the NEW key is never overwritten by old data +The second run also seeds the raw-name-hash entries a 2026.8 beta device left +behind (see https://github.com/esphome/esphome/pull/18361) and proves they are +ignored: the object_id entries win and the beta leftovers are inert. """ from __future__ import annotations @@ -33,22 +33,23 @@ from .host_prefs import clear_host_prefs, write_host_prefs from .state_utils import InitialStateHelper, require_entity from .types import CompileFunction, ConfigWriter -DEVICE_NAME = "host-pref-key-migration" +DEVICE_NAME = "host-pref-key-stability" -# The pre-migration preference key was the sanitized object_id hash; the new -# key is the raw-name hash. All entities are on the main device (device_id 0) -# and their preferences use no version salt, so the key is just the hash. -SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch") -SWITCH_NEW_KEY = fnv1_hash_name("Test Switch") -NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number") -NUMBER_NEW_KEY = fnv1_hash_name("Test Number") +# All entities are on the main device (device_id 0) and their preferences use +# no version salt, so the key is just the object_id hash. +SWITCH_KEY = fnv1_hash_object_id("Test Switch") +NUMBER_KEY = fnv1_hash_object_id("Test Number") + +# Raw-name-hash keys as written by 2026.8 beta firmware; never read by this build +SWITCH_BETA_KEY = fnv1_hash_name("Test Switch") +NUMBER_BETA_KEY = fnv1_hash_name("Test Number") # template_text salts its key with the length limits and pattern hash; this must # match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20, # no pattern configured) TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6) -TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF -TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF +TEXT_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF +TEXT_BETA_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF # TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes TEXT_MAX_LENGTH = 20 @@ -62,18 +63,18 @@ def text_pref_payload(value: str) -> bytes: @pytest.mark.asyncio -async def test_preference_key_migration( +async def test_preference_key_stability( yaml_config: str, write_yaml_config: ConfigWriter, compile_esphome: CompileFunction, reserved_tcp_port: tuple[int, socket.socket], ) -> None: - """Test that preferences stored under the old key survive the upgrade.""" + """Test that preferences stored by earlier firmware are restored.""" port, port_socket = reserved_tcp_port - assert SWITCH_OLD_KEY != SWITCH_NEW_KEY - assert NUMBER_OLD_KEY != NUMBER_NEW_KEY - assert TEXT_OLD_KEY != TEXT_NEW_KEY + assert SWITCH_KEY != SWITCH_BETA_KEY + assert NUMBER_KEY != NUMBER_BETA_KEY + assert TEXT_KEY != TEXT_BETA_KEY # Write and compile once config_path = await write_yaml_config(yaml_config) @@ -117,49 +118,51 @@ async def test_preference_key_migration( return switch_state, number_state, text_state try: - # --- Run 1: only OLD keys present, as written by pre-migration firmware. - # The restored states prove the data was migrated to the new keys. + # --- Run 1: entries under the object_id-hash keys, exactly as any + # earlier firmware wrote them. The restored states prove the key + # scheme has not drifted. write_host_prefs( DEVICE_NAME, { - SWITCH_OLD_KEY: b"\x01", # bool: switch was ON - NUMBER_OLD_KEY: struct.pack(" None: - if not isinstance(state, SensorState) or state.missing_state: + if not isinstance(state, SensorState): return sensor_name = key_to_sensor.get(state.key) if sensor_name not in sensor_values: return - sensor_values[sensor_name].append(state.state) + if state.missing_state: + # Only the NaN test is interested in unavailable states + if sensor_name != "filter_nan": + return + sensor_values[sensor_name].append(math.nan) + else: + sensor_values[sensor_name].append(state.state) # Check completion conditions if ( @@ -74,6 +83,12 @@ async def test_sensor_filters_delta( and not filter_percentage_done.done() ): filter_percentage_done.set_result(True) + elif ( + sensor_name == "filter_nan" + and len(sensor_values[sensor_name]) == 3 + and not filter_nan_done.done() + ): + filter_nan_done.set_result(True) async with ( run_compiled(yaml_config), @@ -89,6 +104,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": "Filter Baseline Max", "filter_zero_delta": "Filter Zero Delta", "filter_percentage": "Filter Percentage", + "filter_nan": "Filter NaN", }, ) @@ -108,13 +124,14 @@ async def test_sensor_filters_delta( "Test Filter Baseline Max": "filter_baseline_max", "Test Filter Zero Delta": "filter_zero_delta", "Test Filter Percentage": "filter_percentage", + "Test Filter NaN": "filter_nan", } buttons = {} for entity in entities: if isinstance(entity, ButtonInfo) and entity.name in button_name_map: buttons[button_name_map[entity.name]] = entity.key - assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}" + assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}" # Test 1: Min sensor_values["filter_min"].clear() @@ -186,3 +203,18 @@ async def test_sensor_filters_delta( assert sensor_values["filter_percentage"] == pytest.approx(expected), ( f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}" ) + + # Test 6: NaN passes through once, then is suppressed + sensor_values["filter_nan"].clear() + client.button_command(buttons["filter_nan"]) + try: + await asyncio.wait_for(filter_nan_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}") + + values = sensor_values["filter_nan"] + assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}" + assert math.isnan(values[1]), ( + f"Test 6 failed: NaN not passed through, got {values}" + ) + assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}" diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 077b6ef23e..a07e56cea5 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -20,6 +20,7 @@ changed_files = helpers.changed_files filter_changed = helpers.filter_changed get_changed_components = helpers.get_changed_components _get_changed_files_from_command = helpers._get_changed_files_from_command +run_gh_command = helpers.run_gh_command _get_pr_number_from_github_env = helpers._get_pr_number_from_github_env _get_changed_files_github_actions = helpers._get_changed_files_github_actions _filter_changed_ci = helpers._filter_changed_ci @@ -1872,3 +1873,123 @@ def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> def test_base_python_changed(files: list[str], expected: bool) -> None: """Only Python modules directly in esphome/ count as base Python changes.""" assert helpers.base_python_changed(files) is expected + + +def _gh_error(stderr: str) -> subprocess.CalledProcessError: + return subprocess.CalledProcessError(1, ["gh"], output="", stderr=stderr) + + +def _gh_success(stdout: str = "ok\n") -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(["gh"], 0, stdout=stdout, stderr="") + + +def test_run_gh_command_success() -> None: + """A successful command returns without retrying.""" + with patch("helpers.subprocess.run", return_value=_gh_success()) as mock_run: + result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert result.stdout == "ok\n" + mock_run.assert_called_once() + + +@pytest.mark.parametrize( + "second_error", + [ + ( + 'Post "https://api.github.com/graphql": tls: failed to verify' + " certificate: x509: certificate is not valid for any names," + " but wanted to match api.github.com" + ), + 'Post "https://api.github.com/graphql": EOF', + ( + "error connecting to api.github.com\n" + "check your internet connection or https://githubstatus.com" + ), + ], +) +def test_run_gh_command_retries_transient_error(second_error: str) -> None: + """Transient server errors are retried with 2s/4s backoff.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=[ + _gh_error("HTTP 502: 502 Bad Gateway (https://api.github.com/graphql)"), + _gh_error(second_error), + _gh_success(), + ], + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + ): + result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert result.stdout == "ok\n" + assert mock_run.call_count == 3 + assert [call.args[0] for call in mock_sleep.call_args_list] == [2, 4] + + +def test_run_gh_command_gives_up_after_max_attempts() -> None: + """A persistent transient error raises after the third attempt.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=_gh_error("HTTP 503: Service Unavailable"), + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert mock_run.call_count == 3 + assert mock_sleep.call_count == 2 + + +@pytest.mark.parametrize( + "stderr", + [ + "HTTP 404: Not Found (https://api.github.com/repos/x)", + "HTTP 401: Bad credentials", + "HTTP 403: API rate limit exceeded for installation ID 123.", + "diff exceeded the maximum number of changed files (300)", + ( + "GraphQL: Could not resolve to a PullRequest with the number of 999999." + " (repository.pullRequest)" + ), + ], +) +def test_run_gh_command_permanent_error_not_retried(stderr: str) -> None: + """Permanent failures raise immediately without any retry.""" + with ( + patch("helpers.subprocess.run", side_effect=_gh_error(stderr)) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + mock_run.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_gh_command_no_retry_for_non_idempotent_commands() -> None: + """retry=False fails on the first error even when it looks transient.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=_gh_error("HTTP 502: 502 Bad Gateway"), + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "comment", "123", "--body", "x"], retry=False) + + mock_run.assert_called_once() + mock_sleep.assert_not_called() + + +def test_get_changed_files_from_command_gh_failure_keeps_stderr() -> None: + """Failures from gh surface stderr so callers can detect the 300-file limit.""" + stderr = "diff exceeded the maximum number of changed files (300)" + with ( + patch("helpers.subprocess.run", side_effect=_gh_error(stderr)), + pytest.raises(Exception, match="maximum number of changed files"), + ): + _get_changed_files_from_command(["gh", "pr", "diff", "123", "--name-only"]) diff --git a/tests/unit_tests/components/mqtt/test_object_id_conflicts.py b/tests/unit_tests/components/mqtt/test_object_id_conflicts.py deleted file mode 100644 index 0ac61b4dad..0000000000 --- a/tests/unit_tests/components/mqtt/test_object_id_conflicts.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Tests for the MQTT object_id conflict filter. - -MQTT still builds default topics and discovery topics from the sanitized -object_id, so entity names that only differ in characters lost during -sanitizing conflict there; _topics_conflict() exempts entities that never -use an object_id-derived topic. See https://github.com/esphome/backlog/issues/85 -""" - -from pathlib import Path - -import pytest - -from esphome.components.mqtt import ( - _COMMAND_TOPIC_PLATFORMS, - _SUB_TOPIC_PLATFORMS, - _topics_conflict, -) -from esphome.config_validation import Invalid -from esphome.const import ( - CONF_COMMAND_TOPIC, - CONF_DISCOVERY, - CONF_NAME, - CONF_STATE_TOPIC, - CONF_TOPIC_PREFIX, -) -from esphome.core import CORE -from esphome.core.entity_helpers import ( - entity_duplicate_validator, - validate_no_object_id_conflicts, -) - -COMPONENTS_DIR = Path(__file__).parents[4] / "esphome" / "components" - -REASON = "mqtt builds default topics from the entity object_id" - - -# MQTT infrastructure sources, not entity components -_NON_ENTITY_MQTT_SOURCES = {"mqtt_client", "mqtt_component"} -# The date, time and datetime MQTT components all belong to the datetime platform -_DATETIME_STEMS = {"date", "time", "datetime"} - - -def test_command_topic_platforms_in_sync() -> None: - """Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe. - - Drift silently reintroduces shared subscribe topics, so this derives the set - from the C++ components that actually call subscribe(); that also catches - platforms like text that subscribe a command topic without exposing a - command_topic key in their schema. - """ - expected: set[str] = set() - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"): - if path.stem in _NON_ENTITY_MQTT_SOURCES: - continue - if "this->subscribe" not in path.read_text(encoding="utf-8"): - continue - stem = path.stem.removeprefix("mqtt_") - expected.add("datetime" if stem in _DATETIME_STEMS else stem) - assert expected == _COMMAND_TOPIC_PLATFORMS - - -def test_sub_topic_platforms_in_sync() -> None: - """Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics. - - Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra - topics such as position/command from the object_id. - """ - expected = { - path.stem.removeprefix("mqtt_") - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h") - if path.stem != "mqtt_component" - and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8") - } - assert expected == _SUB_TOPIC_PLATFORMS - - -def test_conflict_filter_exempts_custom_topics() -> None: - """Test that custom state topics with discovery off avoid the conflict.""" - validator = entity_duplicate_validator("sensor") - # Both entities have custom state topics and discovery disabled per entity, - # so no object_id-derived MQTT topic is used - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - # Without the filter the same conflicts are fatal - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - validate_no_object_id_conflicts(REASON)({}) - - -def test_conflict_on_default_command_topic() -> None: - """Test that commandable platforms conflict through their default command topic. - - Custom state topics with discovery off are not enough for platforms that also - subscribe to an object_id-derived command topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - # Both switches share the default command topic: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator(mqtt_config) - - # With custom command topics as well, nothing derives from the object_id - CORE.reset() - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - assert component_validator(mqtt_config) is mqtt_config - - -def test_conflict_on_sub_topic_platforms() -> None: - """Test that platforms with extra object_id sub-topics always conflict. - - Covers derive topics like position/command from the object_id through their - own config keys, so custom state and command topics cannot exempt them. - """ - validator = entity_duplicate_validator("cover") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}) - - -def test_no_conflict_on_disjoint_default_topics() -> None: - """Test that entities whose default topics are disjoint do not conflict. - - One entity uses only the default command topic and the other only the default - state topic, so they never share a topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - -def test_no_conflict_on_empty_topic_prefix() -> None: - """Test that an empty topic_prefix disables the default topic conflict. - - With topic_prefix set to null no default topics exist at runtime, so entities - without custom state topics cannot conflict; only discovery still matters. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - # No default topics and no discovery: valid - config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""} - assert component_validator(config) is config - - # Discovery still uses object_id-derived config topics: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""}) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 64400c4fd4..53035ad713 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,4 +1,4 @@ -"""Tests for entity helpers: name selection, entity key hashing, duplicate checks.""" +"""Test get_base_entity_object_id function matches C++ behavior.""" from collections.abc import Callable, Generator from pathlib import Path @@ -25,17 +25,16 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, finalize_entity_strings, - get_base_entity_name, + get_base_entity_object_id, register_device_class, register_icon, register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, - validate_no_object_id_conflicts, ) from esphome.cpp_generator import MockObj -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture @@ -58,26 +57,206 @@ def restore_core_state() -> Generator[None, None, None]: CORE.friendly_name = original_friendly_name -def test_get_base_entity_name_priority_order() -> None: +def test_with_entity_name() -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + # Even with device name, entity name takes precedence + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" + + # Mixed case + assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" + + +def test_empty_name_with_device_name() -> None: + """Test when entity has empty name and is on a sub-device - should use device name.""" + # C++ behavior: when has_own_name is false and device is set, uses device->get_name() + assert ( + get_base_entity_object_id("", "Friendly Device", "Sub Device 1") + == "sub_device_1" + ) + assert ( + get_base_entity_object_id("", "Kitchen Controller", "controller_1") + == "controller_1" + ) + assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" + + +def test_empty_name_with_friendly_name() -> None: + """Test when entity has empty name and no device - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + +def test_empty_name_no_friendly_name() -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + +def test_edge_cases() -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ], +) +def test_matches_cpp_helpers(name: str, expected: str) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + +def test_empty_name_fallback() -> None: + """Test empty name handling which falls back to friendly_name or CORE.name.""" + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + + +def test_name_add_mac_suffix_behavior() -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" + + +def test_priority_order() -> None: """Test the priority order: entity name > device name > friendly name > CORE.name.""" CORE.name = "core-device" - # 1. Entity name has highest priority and is used as-is, no transformations + # 1. Entity name has highest priority assert ( - get_base_entity_name("Entity Name", "Friendly Name", "Device Name") - == "Entity Name" + get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") + == "entity_name" ) - assert get_base_entity_name("Température", None) == "Température" # 2. Device name is next priority (when entity name is empty) - assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name" + assert ( + get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" + ) # 3. Friendly name is next (when entity and device names are empty) - assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name" + assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" - # 4. CORE.name is last resort; an empty friendly name falls through to it - assert get_base_entity_name("", None, None) == "core-device" - assert get_base_entity_name("", "") == "core-device" + # 4. CORE.name is last resort + assert get_base_entity_object_id("", None, None) == "core-device" + + +@pytest.mark.parametrize( + ("name", "friendly_name", "device_name", "expected"), + [ + # name, friendly_name, device_name, expected + ("Living Room Light", None, None, "living_room_light"), + ("", "Kitchen Controller", None, "kitchen_controller"), + ( + "", + "ESP32 Device", + "controller_1", + "controller_1", + ), # Device name takes precedence + ("GPIO2 Button", None, None, "gpio2_button"), + ("WiFi Signal", "My Device", None, "wifi_signal"), + ("", None, "esp32_node", "esp32_node"), + ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), + ], +) +def test_real_world_examples( + name: str, friendly_name: str | None, device_name: str | None, expected: str +) -> None: + """Test real-world entity naming scenarios.""" + result = get_base_entity_object_id(name, friendly_name, device_name) + assert result == expected + + +def test_issue_6953_scenarios() -> None: + """Test specific scenarios from issue #6953.""" + # Scenario 1: Multiple empty names on main device with name_add_mac_suffix + # The Python code calculates the base, C++ might append MAC suffix dynamically + CORE.name = "device-name" + CORE.friendly_name = "Friendly Device" + + # All empty names should resolve to same base + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + + # Scenario 2: Empty names on sub-devices + assert ( + get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" + ) + assert ( + get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" + ) + + # Scenario 3: xyz duplicates + assert get_base_entity_object_id("xyz", None) == "xyz" + assert get_base_entity_object_id("xyz", "Device") == "xyz" # Tests for setup_entity function @@ -336,10 +515,9 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Check metadata was stored - metadata = CORE.unique_ids[temperature_key] + metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))] assert metadata["name"] == "Temperature" assert metadata["platform"] == "sensor" @@ -347,9 +525,8 @@ def test_entity_duplicate_validator() -> None: config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - humidity_key = ("", "sensor", fnv1_hash_name("Humidity")) - assert humidity_key in CORE.unique_ids - metadata2 = CORE.unique_ids[humidity_key] + assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))] assert metadata2["name"] == "Humidity" # Duplicate entity should fail @@ -360,6 +537,34 @@ def test_entity_duplicate_validator() -> None: validator(config3) +def test_entity_duplicate_validator_hash_collision() -> None: + """Test that two different object_ids with the same FNV-1 hash are rejected.""" + # Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4 + name_a = "Sensor aooxzi" + name_b = "Sensor baraia" + object_id_a = sanitize(snake_case(name_a)) + object_id_b = sanitize(snake_case(name_b)) + assert object_id_a != object_id_b + assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b) + + validator = entity_duplicate_validator("sensor") + + config1 = {CONF_NAME: name_a} + validated1 = validator(config1) + assert validated1 == config1 + + config2 = {CONF_NAME: name_b} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate sensor entity with name 'Sensor baraia' found.*" + r"produce the same entity key hash \(0xe95747e4\)", + re.DOTALL, + ), + ): + validator(config2) + + def test_entity_duplicate_validator_with_devices() -> None: """Test entity_duplicate_validator with devices.""" # Create validator for sensor platform @@ -370,19 +575,18 @@ def test_entity_duplicate_validator_with_devices() -> None: device2 = ID("device2", type="Device") # Same name on different devices should pass - name_hash = fnv1_hash_name("Temperature") config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", name_hash) in CORE.unique_ids - metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)] + assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))] assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 - assert ("device2", "sensor", name_hash) in CORE.unique_ids - metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)] + assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))] assert metadata2["device_id"] == "device2" # Duplicate on same device should fail @@ -434,33 +638,6 @@ def test_entity_different_platforms_yaml_validation( assert result is not None -def test_object_id_conflict_mqtt_yaml_validation( - yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] -) -> None: - """Test that names sanitizing to the same object_id fail when mqtt is configured.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR - ) - assert result is None - - captured = capsys.readouterr() - assert ( - "mqtt builds default topics and discovery topics from the entity object_id" - in captured.out - ) - - -def test_object_id_conflict_without_mqtt_yaml_validation( - yaml_file: Callable[[str], str], -) -> None: - """Test that names sanitizing to the same object_id pass without mqtt/prometheus.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR - ) - # This should succeed - assert result is not None - - def test_entity_duplicate_validator_error_message() -> None: """Test that duplicate entity error messages include helpful metadata.""" # Create validator for sensor platform @@ -519,8 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated1 = validator(config1) assert validated1 == config1 # New format includes device_id (empty string for main device) - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} @@ -528,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: assert validated2 == config2 # Internal entity should not be added to unique_ids # Count how many times the key appears (should still be 1) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Another internal entity with same name should also pass @@ -536,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Non-internal entity with same name should fail @@ -564,148 +744,30 @@ def test_empty_or_null_device_id_on_entity() -> None: def test_entity_duplicate_validator_non_ascii_names() -> None: - """Test that distinct non-ASCII names no longer collide. - - These names used to be rejected because both sanitize to only underscores; - the entity key now hashes the raw name so they stay distinct. - """ + """Test that non-ASCII names show helpful error messages.""" # Create validator for binary_sensor platform validator = entity_duplicate_validator("binary_sensor") - # Both Russian sensors should pass even though they sanitize identically + # First Russian sensor should pass config1 = {CONF_NAME: "Датчик открытия основного крана"} validated1 = validator(config1) assert validated1 == config1 + # Second Russian sensor with different text but same ASCII conversion should fail config2 = {CONF_NAME: "Датчик закрытия основного крана"} - validated2 = validator(config2) - assert validated2 == config2 - - # An exact duplicate still fails - config3 = {CONF_NAME: "Датчик открытия основного крана"} - with pytest.raises( - Invalid, - match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found", - ): - validator(config3) - - -def test_entity_duplicate_validator_hash_collision() -> None: - """Test that two different names with the same FNV-1 hash are rejected.""" - # Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b - name_a = "Sensor m2CZ" - name_b = "Sensor qCaa" - assert name_a != name_b - assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b) - - validator = entity_duplicate_validator("sensor") - - config1 = {CONF_NAME: name_a} - validated1 = validator(config1) - assert validated1 == config1 - - config2 = {CONF_NAME: name_b} with pytest.raises( Invalid, match=re.compile( - rf"Duplicate sensor entity with name '{name_b}' found.*" - rf"The names '{name_b}' and '{name_a}' produce the.*" - r"same entity key hash \(0x0ee5ff7b\).*" - r"To fix: Rename one of the entities", + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", re.DOTALL, ), ): validator(config2) -def test_object_id_conflicts_rejected_by_component_validator() -> None: - """Test that object_id conflicts pass entity validation but fail for mqtt/prometheus.""" - validator = entity_duplicate_validator("sensor") - - # Both names validate fine in general (distinct raw names, distinct keys) - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - # A component that addresses entities by object_id must reject the config - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - with pytest.raises( - Invalid, - match=re.compile( - r"mqtt builds default topics from the entity object_id.*" - r"sensor entities 'Датчик открытия', 'Датчик закрытия' " - r"share the object_id '_______________'.*" - r"To fix: Add unique ASCII characters", - re.DOTALL, - ), - ): - component_validator({}) - - -def test_object_id_conflicts_skipped_in_testing_mode() -> None: - """Test that testing_mode skips the conflict check, as used for grouped testing.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - CORE.testing_mode = True - try: - config: dict = {} - assert component_validator(config) is config - finally: - CORE.testing_mode = False - - -def test_object_id_conflicts_none_recorded() -> None: - """Test that distinct object_ids produce no conflicts.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature"}) - validator({CONF_NAME: "Humidity"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - -def test_object_id_conflicts_device_scoped() -> None: - """Test that the object_id conflict check is scoped per device. - - Same-named entities on different sub-devices were accepted before entity keys - moved to raw names, so the check keeps that scope; conflicts within one device - are still reported with the device named in the message. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")}) - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")}) - - component_validator = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - # Two names sanitizing identically on the same sub-device still conflict - validator( - {CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - validator( - {CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - with pytest.raises( - Invalid, - match=re.compile( - r"prometheus builds metric labels.*on device 'device1'", re.DOTALL - ), - ): - component_validator({}) - - def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: """Test that identical names don't show the enhanced message.""" # Create validator for sensor platform @@ -763,7 +825,7 @@ async def test_setup_entity_empty_name_with_device( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -792,7 +854,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -822,7 +884,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -853,7 +915,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 def test_register_string_overflow() -> None: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml deleted file mode 100644 index 4a6f56f473..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml +++ /dev/null @@ -1,22 +0,0 @@ -esphome: - name: test-object-id-conflict - -esp32: - board: esp32dev - -wifi: - ssid: MySSID - password: password1 - -mqtt: - broker: test.mosquitto.org - -sensor: - # Distinct raw names are fine in general, but both sanitize to the same - # object_id, which MQTT still uses to build default topics - should fail - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml deleted file mode 100644 index c0fbd5cbba..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml +++ /dev/null @@ -1,15 +0,0 @@ -esphome: - name: test-object-id-ok - -esp32: - board: esp32dev - -sensor: - # Distinct raw names that sanitize to the same object_id are allowed when no - # component addresses entities by object_id (no mqtt or prometheus configured) - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/lazy_imports/_storage.py b/tests/unit_tests/fixtures/lazy_imports/_storage.py index 969528304b..94acd2e93a 100644 --- a/tests/unit_tests/fixtures/lazy_imports/_storage.py +++ b/tests/unit_tests/fixtures/lazy_imports/_storage.py @@ -1,10 +1,15 @@ """Shared storage-sidecar factory for the lazy-import fixture scripts.""" +from pathlib import Path + from esphome.storage_json import StorageJSON def make_storage() -> StorageJSON: - """A minimal post-compile esp32 sidecar the upload/logs fast path accepts.""" + """A minimal post-compile esp32 sidecar the upload/logs fast path accepts. + + build_path must be set: the fast path rejects sidecars without one. + """ return StorageJSON( storage_version=1, name="test", @@ -15,8 +20,8 @@ def make_storage() -> StorageJSON: address="1.2.3.4", web_port=None, target_platform="ESP32S3", - build_path=None, - firmware_bin_path=None, + build_path=Path("/build/test"), + firmware_bin_path=Path("/build/test/firmware.bin"), loaded_integrations=set(), loaded_platforms=set(), no_mdns=False, diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index b3c2170c3f..77690a6897 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import contextmanager from ipaddress import IPv4Address, IPv4Network import json import os @@ -19,6 +20,7 @@ from esphome.compiled_config import ( compiled_config_path, load_compiled_config, save_compiled_config, + save_compiled_config_and_sidecar, ) from esphome.const import ( CONF_API, @@ -31,7 +33,16 @@ from esphome.const import ( KEY_VARIANT, Toolchain, ) -from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds +from esphome.core import ( + CORE, + ID, + EsphomeError, + HexInt, + Lambda, + MACAddress, + TimePeriodMilliseconds, +) +from esphome.storage_json import StorageJSON from esphome.util import OrderedDict _VALIDATED_CONFIG = { @@ -54,8 +65,9 @@ def _cache_body(config: dict | None = None) -> str: def _write_storage( storage_path: Path, *, - esp_platform: str = "ESP32", + esp_platform: str | None = "ESP32", core_platform: str | None = "esp32", + build_path: str | None = "/build/lite_test", ) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) @@ -69,7 +81,7 @@ def _write_storage( "address": "192.168.1.42", "web_port": None, "esp_platform": esp_platform, - "build_path": "/build/lite_test", + "build_path": build_path, "firmware_bin_path": "/build/lite_test/firmware.bin", "loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_platforms": [], @@ -359,31 +371,262 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( mock_read.assert_called_once() -def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( - tmp_path: Path, -) -> None: - """Without a StorageJSON sidecar (no compile has run), the fallback - skips the cache write -- load_compiled_config requires the sidecar, - so writing the rendered (secret-resolved) config would be inert and - leak secrets to disk for nothing.""" +def _storage_fixture(tmp_path: Path) -> StorageJSON: + """A loaded StorageJSON instance matching _write_storage's contents.""" + fixture = tmp_path / "fixture_storage.json" + _write_storage(fixture) + return StorageJSON.load(fixture) + + +def _bare_yaml(tmp_path: Path) -> Path: + """A minimal YAML with CORE.config_path pointed at it.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path + return yaml_path + +@contextmanager +def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any: + """Patch the fallback path's collaborators for a run_esphome call. + + Without kwargs, from_esphome_core stays real (yielded mock is None). + """ with ( patch( "esphome.config.read_config", return_value={"esphome": {"name": "lite_test"}}, - ), - patch("esphome.compiled_config.save_compiled_config") as mock_save, + ) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", - {"upload": lambda args, config: 0}, + {command: lambda args, config: 0}, ), ): - run_esphome(["esphome", "upload", str(yaml_path)]) + if not from_core_kwargs: + yield mock_read, None + return + with patch.object( + StorageJSON, "from_esphome_core", **from_core_kwargs + ) as mock_from_core: + yield mock_read, mock_from_core + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar( + tmp_path: Path, command: str +) -> None: + """A never-compiled config caches on its first upload/logs run: the + fallback writes the StorageJSON sidecar itself (load_compiled_config + needs it), so the second run hits the fast path.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + + with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as ( + mock_read, + mock_from_core, + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + mock_from_core.assert_called_once() + assert (storage_dir / "lite_test.yaml.validated.json").exists() + storage = StorageJSON.load(storage_dir / "lite_test.yaml.json") + assert storage is not None + # No compile happened, so the sidecar must not claim one. + assert mock_from_core.call_args.kwargs == {"claim_build": False} + + # The second run loads the cache instead of re-validating. + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + mock_read.assert_called_once() + + +# as_dict serialized unset paths as str(None) until 2026.9; files +# written by those wizards are still on disk. +_WIZARD_SIDECAR_CASES = pytest.mark.parametrize( + "wizard_kwargs", + [ + {"esp_platform": None, "core_platform": None, "build_path": None}, + {"build_path": None}, + {"build_path": "None"}, + ], + ids=["legacy_wizard", "modern_wizard", "none_string_wizard"], +) + + +def _prime_core(tmp_path: Path) -> None: + """Set the post-validation CORE state from_esphome_core reads.""" + CORE.name = "lite_test" + CORE.build_path = tmp_path / "build" / "lite_test" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp8266", + KEY_TARGET_FRAMEWORK: "arduino", + } + + +@_WIZARD_SIDECAR_CASES +def test_run_esphome_fallback_completes_wizard_sidecar( + tmp_path: Path, wizard_kwargs: dict[str, Any] +) -> None: + """A wizard-written sidecar can't drive the fast path (no build_path; + older wizards also no platform fields); the fallback rewrites it from + CORE so the cache loads on the next run.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs) + + with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_called_once() + storage = StorageJSON.load(storage_dir / "lite_test.yaml.json") + assert storage is not None and storage.core_platform == "esp32" + # What the wizard recorded about a build (nothing, or a real one) + # carries through instead of being stamped with this run's values. + assert storage.esphome_version == "2026.1.0" + assert load_compiled_config(yaml_path) is not None + + +def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails( + tmp_path: Path, +) -> None: + """A failed sidecar write is non-fatal and skips the cache save too: + without the sidecar the cache could never be loaded back, so writing + it would only leave resolved secrets on disk.""" + yaml_path = _bare_yaml(tmp_path) + + with ( + _fallback_run(side_effect=RuntimeError("boom")), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + ): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 mock_save.assert_not_called() + assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists() + + +def test_run_esphome_fallback_write_failure_takes_io_branch( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """StorageJSON.save raises EsphomeError (write_file wraps OSError into + it), which must land in the plain I/O warning, not the traceback + branch for structural bugs.""" + yaml_path = _bare_yaml(tmp_path) + + with ( + _fallback_run(return_value=_storage_fixture(tmp_path)), + patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + caplog.at_level("WARNING", logger="esphome.compiled_config"), + ): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_save.assert_not_called() + assert "Could not refresh the storage sidecar" in caplog.text + assert "Unexpected error" not in caplog.text + + +def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None: + """A present-but-corrupt sidecar is not overwritten: it may hold a real + build's metadata, and replacing it would suppress the next compile's + clean of a possibly incoherent build tree. The cache save is skipped.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + sidecar = storage_dir / "lite_test.yaml.json" + sidecar.parent.mkdir(parents=True, exist_ok=True) + sidecar.write_text("{truncated", encoding="utf-8") + + with _fallback_run(return_value=None) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_not_called() + assert sidecar.read_text(encoding="utf-8") == "{truncated" + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + + +def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete( + tmp_path: Path, +) -> None: + """If the rebuilt sidecar would still be incomplete, nothing is written: + the cache could never be loaded back, so saving it would only rewrite + resolved secrets on every run.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + + incomplete = tmp_path / "incomplete_storage.json" + _write_storage(incomplete, build_path=None) + + with _fallback_run(return_value=StorageJSON.load(incomplete)): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + assert not (storage_dir / "lite_test.yaml.json").exists() + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + + +def test_run_esphome_fallback_sidecar_records_platformio_toolchain( + tmp_path: Path, +) -> None: + """The toolchain fallback runs before the sidecar write, so platforms + whose validators leave CORE.toolchain unset record the same + "platformio" a compile writes, not null.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + assert CORE.toolchain is None + + with _fallback_run(): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + storage = StorageJSON.load( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + ) + assert storage is not None + assert storage.toolchain == "platformio" + + +@pytest.mark.parametrize("existing_sidecar", [None, "wizard"]) +def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists( + tmp_path: Path, existing_sidecar: str | None +) -> None: + """An existing build tree with a missing or wizard-only sidecar keeps + it that way: the mismatch is what makes the next compile wipe the + unknown tree, so the fallback writes nothing and skips the cache.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.build_path.mkdir(parents=True) + storage_dir = tmp_path / ".esphome" / "storage" + if existing_sidecar == "wizard": + _write_storage(storage_dir / "lite_test.yaml.json", build_path=None) + wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8") + + with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_not_called() + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + if existing_sidecar == "wizard": + sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8") + assert sidecar_body == wizard_body + else: + assert not (storage_dir / "lite_test.yaml.json").exists() + + +def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None: + """Drive the real from_esphome_core on the fallback path: the + post-validation CORE state yields a complete, loadable sidecar.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}} + CORE.toolchain = Toolchain.PLATFORMIO + + save_compiled_config_and_sidecar(CORE.config) + + storage = StorageJSON.load( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + ) + assert storage is not None + assert storage.core_platform == "esp8266" + assert storage.build_path is not None + # No compile happened, so the sidecar must not claim one. + assert storage.esphome_version is None + assert storage.firmware_bin_path is None + assert load_compiled_config(yaml_path) is not None @pytest.mark.parametrize("command", ["upload", "logs"]) @@ -409,6 +652,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( patch( "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config ) as mock_save, + patch.object(StorageJSON, "from_esphome_core") as mock_from_core, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {command: lambda args, config: 0}, @@ -417,6 +661,8 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( assert run_esphome(["esphome", command, str(yaml_path)]) == 0 mock_save.assert_called_once_with(fresh_config) + # The compile-written sidecar is complete; the fallback leaves it alone. + mock_from_core.assert_not_called() # mtime is now newer than the source YAML, so a follow-up call hits # the fast path instead of repeating read_config. assert cache.stat().st_mtime >= yaml_path.stat().st_mtime @@ -647,24 +893,15 @@ def test_int_keys_coerce_to_strings(primed_storage: Path) -> None: assert config["table"] == {"1": "a", "2": "b"} -def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None: - """A wizard-only sidecar (no compile -- no core_platform / target_platform) - can't drive upload/logs, so the fast path falls back.""" - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text("esphome:\n name: lite_test\n") - CORE.config_path = yaml_path - +@_WIZARD_SIDECAR_CASES +def test_load_compiled_config_rejects_wizard_only_sidecar( + tmp_path: Path, wizard_kwargs: dict[str, Any] +) -> None: + """A wizard-written sidecar (no build_path; older wizards also no + platform fields) can't drive upload/logs, so the fast path falls back.""" + yaml_path = _bare_yaml(tmp_path) storage_dir = tmp_path / ".esphome" / "storage" - storage_dir.mkdir(parents=True, exist_ok=True) - # StorageJSON with both core_platform and target_platform unset. - (storage_dir / "lite_test.yaml.json").write_text( - '{"storage_version": 1, "name": "lite_test", "friendly_name": null, ' - '"comment": null, "esphome_version": null, "src_version": 1, ' - '"address": null, "web_port": null, "esp_platform": null, ' - '"build_path": null, "firmware_bin_path": null, ' - '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' - '"framework": null, "core_platform": null}' - ) + _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs) cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache_path, yaml_path, offset=5) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7627ef9273..971c4e462d 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2967,6 +2967,23 @@ def test_require_esphome_version_older_prerelease_fails() -> None: cv.require_esphome_version(2026, 8, 0)("test") +def test_parse_esphome_version_deprecated_shim( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removed helper still works for external components and warns.""" + from esphome import const, util + + with ( + patch.object(const, "__version__", "2026.9.0-dev"), + caplog.at_level(logging.WARNING), + ): + assert cv.parse_esphome_version() == (2026, 9, 0) + assert cv.parse_esphome_version() < (9999, 0, 0) + assert "parse_esphome_version() is deprecated" in caplog.text + # Both historical import paths resolve to the same function + assert cv.parse_esphome_version is util.parse_esphome_version + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py new file mode 100644 index 0000000000..2ccf53f7e3 --- /dev/null +++ b/tests/unit_tests/test_download_types.py @@ -0,0 +1,52 @@ +"""Platform get_download_types contract for never-built configs. + +Wizard-written and upload/logs-fallback sidecars record no +firmware_bin_path; the download panel must get an empty list for them, +not entries pointing at files that were never built. +""" + +from __future__ import annotations + +from importlib import import_module +from pathlib import Path +from typing import Any + +import pytest + +from esphome.storage_json import StorageJSON + +PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52"] + + +def _download_types(platform: str, storage: StorageJSON) -> list[dict[str, Any]]: + return import_module(f"esphome.components.{platform}").get_download_types(storage) + + +def _wizard_storage() -> StorageJSON: + return StorageJSON.from_wizard( + name="test_device", + friendly_name="Test Device", + address="test_device.local", + platform="ESP32", + ) + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_no_firmware_path_yields_no_downloads(platform: str) -> None: + """No recorded firmware path means nothing was built; no downloads.""" + assert _download_types(platform, _wizard_storage()) == [] + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) -> None: + """With a firmware path recorded, every platform offers entries in + the documented title/description/file/download shape.""" + storage = _wizard_storage() + storage.firmware_bin_path = tmp_path / "firmware.bin" + + types = _download_types(platform, storage) + + assert types + assert all( + {"title", "description", "file", "download"} <= entry.keys() for entry in types + ) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 56f358a24c..26d812af8b 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -265,6 +265,21 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: + """A PYTHONPATH from the parent environment must not reach idf.py. + + It would override the IDF venv's isolation, shadowing its pinned + packages and failing idf.py's dependency check. + """ + toolchain._cache().env.clear() + with patch.dict( + os.environ, + {"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"}, + ): + env = toolchain._get_idf_env(version="5.5.4") + assert "PYTHONPATH" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 9413fbcf29..db4a4b1117 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -44,13 +44,17 @@ def mock_file() -> io.BytesIO: @pytest.fixture -def mock_time() -> Generator[None]: +def mock_sleep() -> Generator[Mock]: + """Mock time.sleep so delays don't slow down tests.""" + with patch("time.sleep") as mock: + yield mock + + +@pytest.fixture +def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with ( - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), - ): + with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): yield @@ -79,6 +83,28 @@ def mock_resolve_ip() -> Generator[Mock]: yield mock +DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0) +DUAL_STACK_SA4 = ("192.168.1.100", 3232) + + +@pytest.fixture +def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock: + """Make resolve_ip_address return an IPv6 and an IPv4 address.""" + mock_resolve_ip.return_value = [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6), + (socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4), + ] + return mock_resolve_ip + + +@pytest.fixture +def firmware_file(tmp_path: Path) -> Path: + """Create a firmware file on disk for run_ota_impl_ tests.""" + firmware = tmp_path / "firmware.bin" + firmware.write_bytes(b"firmware content") + return firmware + + @pytest.fixture def mock_perform_ota() -> Generator[Mock]: """Mock perform_ota function for testing.""" @@ -137,9 +163,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: with pytest.raises( espota2.OTAError, match="receiving auth:.*Authentication invalid" - ): + ) as exc_info: espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + # Device-reported errors must stay plain OTAError, not the retryable kind + assert not isinstance(exc_info.value, espota2.OTANetworkError) mock_socket.close.assert_called_once() @@ -147,10 +175,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: """Test receive_exactly handles socket errors.""" mock_socket.recv.side_effect = OSError("Connection reset") - with pytest.raises(espota2.OTAError, match="receiving test response"): + with pytest.raises(espota2.OTANetworkError, match="receiving test response"): espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) +def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None: + """Test receive_exactly handles socket errors after the first byte.""" + mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")] + + with pytest.raises(espota2.OTANetworkError, match="receiving test:"): + espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + +def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None: + """Test receive_exactly raises OTANetworkError when the device closes the connection.""" + mock_socket.recv.return_value = b"" + + with pytest.raises( + espota2.OTANetworkError, match="Device closed connection without responding" + ): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + mock_socket.close.assert_called_once() + + @pytest.mark.parametrize( ("error_code", "expected_msg"), [ @@ -227,15 +275,15 @@ def test_check_error_unexpected_response() -> None: def test_check_error_empty_data() -> None: - """Test check_error raises error when device closes connection without responding.""" + """Test check_error raises the retryable OTANetworkError when the device closes the connection.""" with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error([], [espota2.RESPONSE_OK]) # Also test with empty bytes with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error(b"", [espota2.RESPONSE_OK]) @@ -530,6 +578,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, None, mock_file, "test.bin") +def _no_auth_handshake(version: int) -> list[bytes]: + """Recv responses for a handshake without auth, up to the MD5 check.""" + return [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([version]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA raises the retryable OTANetworkError when sending a chunk fails.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Probe for a pending error byte fails too + ] + # Sends before the data phase: magic bytes, features, binary size, MD5; + # fail on the fifth sendall, the first firmware chunk + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises(espota2.OTANetworkError, match="sending data:"): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error_surfaces_device_error( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a device error byte pending behind a send failure becomes the cause.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed + ] + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises( + espota2.OTAError, match="Writing OTA data to flash memory failed" + ) as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device-reported error is not retryable + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_final_chunk_ack_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a lost ack for the final chunk is not retried.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the only (final) chunk is lost + ] + + with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device already had the whole image, so it may be committing + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_intermediate_chunk_ack_failure_retryable( + mock_socket: Mock, +) -> None: + """Test a lost ack for a non-final chunk stays retryable.""" + # Two chunks: the firmware is larger than one upload block + big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1)) + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the first of two chunks is lost + ] + + with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"): + espota2.perform_ota(mock_socket, None, big_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_post_commit_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a network failure after the device committed is a plain OTAError.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + OSError("Connection reset"), # Connection lost waiting for end result + ] + + with pytest.raises(espota2.OTAError, match="receiving update end result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # Must not be the retryable kind; the device is already rebooting + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_mismatch_not_marked_committed( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test an MD5 mismatch keeps its own message and stays non-retryable.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update + ] + + with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device aborted without committing, so the message must not claim + # the update may have been installed, and the error must not be retried + assert not isinstance(exc.value, espota2.OTANetworkError) + assert "committed" not in str(exc.value) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_end_ack_send_failure_is_success( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a send failure on the final acknowledgement does not fail the OTA.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed + ] + # Sends: magic bytes, features, binary size, MD5, one firmware chunk; + # fail on the sixth sendall, the end acknowledgement + mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")] + + # Must not raise; the device treats a missing acknowledgement as non-fatal + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + assert mock_socket.sendall.call_count == 6 + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_successful( mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock @@ -564,21 +750,183 @@ def test_run_ota_impl_successful( @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") -def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: - """Test run_ota_impl_ when connection fails.""" +def test_run_ota_impl_connection_failed( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries when connection fails and eventually gives up.""" mock_socket.connect.side_effect = OSError("Connection refused") - # Create a real firmware file - firmware_file = tmp_path / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) ) assert result_code == 1 assert result_host is None - mock_socket.close.assert_called_once() + # A single address gets the whole attempt budget, with a delay before + # each revisit + assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connect_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ succeeds when a retry connects after a failed attempt.""" + mock_socket.connect.side_effect = [OSError("Connection timed out"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_socket.connect.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries after a network error during the upload.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("receiving features: Device closed connection"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_perform_ota.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_exhausts_attempts( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ gives up after all attempts hit network errors.""" + mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_multiple_addresses_cycle( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ visits every address and cycles for the retries.""" + mock_socket.connect.side_effect = OSError("No route to host") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + # Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare + # attempts cycle back through them; the budget is shared, not per address + assert mock_socket.connect.call_args_list == [ + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + ] + # No connect ever reached the device, so the delay only applies before + # the revisits + assert mock_sleep.call_count == 2 + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_second_address_succeeds_without_delay( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ falls through to the next address with no pause.""" + mock_socket.connect.side_effect = [OSError("No route to host"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + mock_sleep.assert_not_called() + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_pauses_after_reaching_device( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ pauses before the next address once the device was reached.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("sending data: connection reset"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + # The first attempt reached the device, so the next one waits first even + # though it targets a fresh address + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_device_error_not_retried( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails immediately on a device-reported error.""" + mock_perform_ota.side_effect = espota2.OTAError( + "Authentication invalid. Is the password correct?" + ) + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_perform_ota.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_ota_impl_no_addresses( + firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + mock_resolve_ip.return_value = [] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_sleep.assert_not_called() def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 7451ee9b39..2022c15bfe 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -188,6 +188,24 @@ def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" +def test_run_command_pops_inherited_pythonpath(mock_subprocess_run: Mock) -> None: + """A PYTHONPATH from the parent environment must not leak into subprocesses.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"]) + assert "PYTHONPATH" not in mock_subprocess_run.call_args[1]["env"] + + +def test_run_command_env_pythonpath_preferred_over_pop( + mock_subprocess_run: Mock, +) -> None: + """A PYTHONPATH set explicitly via ``env`` is passed through.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"], env={"PYTHONPATH": "/idf/tools"}) + assert mock_subprocess_run.call_args[1]["env"]["PYTHONPATH"] == "/idf/tools" + + def test_run_command_passes_cwd(mock_subprocess_run: Mock, tmp_path: Path) -> None: mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") run_command(["cmd"], cwd=str(tmp_path)) diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 41dd462678..74515e9d4c 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome.component_aliases import COMPONENT_ALIASES from esphome.loader import ( AliasMeta, ComponentManifest, @@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None: assert meta["rp2040"].removal_version == "2027.7.0" +def test_alias_registry_matches_component_tree() -> None: + """The checked-in registry must match a live scan of the component tree.""" + _, meta_map = _build_alias_map() + expected = { + alias: (meta.canonical, meta.removal_version) + for alias, meta in meta_map.items() + } + assert expected == COMPONENT_ALIASES, ( + "esphome/component_aliases.py is out of date; " + "run script/build_alias_registry.py" + ) + + +def test_alias_map_built_from_registry() -> None: + """The runtime alias map comes from the generated registry, not a scan.""" + with ( + patch( + "esphome.component_aliases.COMPONENT_ALIASES", + {"legacy": ("modern", "2099.1.0")}, + ), + patch("esphome.loader._ALIAS_META_CACHE", None), + ): + assert get_alias_metadata() == { + "legacy": AliasMeta(canonical="modern", removal_version="2099.1.0") + } + + def test_get_component_resolves_alias() -> None: """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 02c11b4e45..eebb0b8cd7 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path import shutil +import subprocess import sys import threading from types import SimpleNamespace @@ -431,6 +432,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -457,6 +459,44 @@ def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: assert env == {"ESPHOME_CCACHE_ENABLE": "0"} +@pytest.mark.parametrize( + "probe_error", + [ + pytest.param(OSError("not runnable"), id="oserror"), + pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"), + pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"), + ], +) +def test_ccache_env_disabled_when_probe_fails( + setup_core: Path, probe_error: Exception +) -> None: + """A ccache that resolves on PATH but fails to run stays disabled.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run", side_effect=probe_error), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: + """An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + mock_probe.assert_not_called() + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -496,6 +536,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir( with ( patch.dict(os.environ, user_env, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -514,6 +555,7 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( with ( patch.dict(os.environ, {}, clear=False), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): os.environ.pop("ESPHOME_CCACHE_ENABLE", None) mock_run_external_process.return_value = 0 @@ -533,6 +575,7 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), pytest.raises(ValueError, match="CORE.build_path must be set"), ): toolchain._ccache_env() @@ -544,7 +587,10 @@ def test_run_platformio_cli_merges_caller_env( """A caller-supplied env is the base and gains the ccache settings.""" CORE.build_path = str(setup_core / "build" / "test") - with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"): + with ( + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), + ): mock_run_external_process.return_value = 0 toolchain.run_platformio_cli( "test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"} diff --git a/tests/unit_tests/test_preference_hash_stability.py b/tests/unit_tests/test_preference_hash_stability.py index d3e5fac36a..d8506afae7 100644 --- a/tests/unit_tests/test_preference_hash_stability.py +++ b/tests/unit_tests/test_preference_hash_stability.py @@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on firmware upgrades, or break entity state routing to API clients. Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85): -1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1). - Existing devices have preferences stored under keys derived from it; slot-based - backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it. -2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes). - Sent to API clients and used as the preference key base on key-lookup backends. +1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1). + The entity key sent to API clients and the base of every stored preference key. +2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta + firmware stored preferences under keys derived from it; a future key migration + must reconstruct those keys to recover that data. DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm, the change breaks backward compatibility and will cause data loss. @@ -124,8 +124,9 @@ def test_entity_object_id_hash_stability( """Verify fnv1_hash_object_id produces stable hashes for entity names. CRITICAL: These expected values MUST NOT CHANGE. Existing devices have - preferences stored under keys derived from this legacy hash; changing it - breaks the old-to-new key migration and loses stored preferences. + preferences stored under keys derived from this hash, and it is the entity + key sent to API clients; changing it loses stored preferences and breaks + entity state routing. """ actual = fnv1_hash_object_id(entity_name) assert actual == expected_object_id_hash, ( @@ -144,9 +145,8 @@ def compute_legacy_preference_key( ) -> int: """Compute the legacy preference key: (object_id_hash ^ device_id) ^ version. - This is the key existing devices have data stored under. Slot-based backends - (ESP8266, RP2040) still use it directly; key-lookup backends compute it as the - migration source in EntityBase::make_entity_preference_() (entity_base.cpp). + This is the key EntityBase::make_entity_preference_() (entity_base.cpp) + stores every entity preference under. """ object_id_hash = fnv1_hash_object_id(entity_name) preference_hash = object_id_hash ^ device_id @@ -179,8 +179,8 @@ def test_legacy_preference_key_computation( ) -> None: """Verify legacy preference key computation matches expected values. - This test ensures the formula doesn't change, which would break both slot-based - preference storage and the migration source keys on key-lookup backends. + This test ensures the formula doesn't change, which would lose stored + preferences on every platform. """ actual_key = compute_legacy_preference_key(entity_name, version, device_id) @@ -215,12 +215,12 @@ def test_legacy_preference_key_computation( ], ) def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None: - """Verify fnv1_hash_name produces stable entity keys. + """Verify fnv1_hash_name produces stable raw-name hashes. - CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to - API clients and is the new preference key base; changing the algorithm - would break state routing and lose stored preferences. - Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h. + CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored + preferences under keys derived from this hash; a future key migration must + reconstruct those keys, and changing the algorithm would strand that data. + Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores. """ actual = fnv1_hash_name(entity_name) assert actual == expected_key, ( diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 01683507c1..857795d02f 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -915,3 +915,102 @@ def test_storage_json_load_area(tmp_path: Path) -> None: legacy = storage_json.StorageJSON.load(legacy_path) assert legacy is not None assert legacy.area is None + + +def test_from_esphome_core_without_claiming_a_build(setup_core: Path) -> None: + """claim_build=False carries the build artifact fields from the old + sidecar while validation-derived fields still stamp from CORE.""" + mock_core = MagicMock() + mock_core.name = "my_device" + mock_core.friendly_name = "My Device" + mock_core.comment = None + mock_core.address = "my_device.local" + mock_core.web_port = None + mock_core.target_platform = "esp8266" + mock_core.is_esp32 = False + mock_core.is_nrf52 = False + mock_core.build_path = "/build/my_device" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} + mock_core.target_framework = "arduino" + mock_core.toolchain = Toolchain.PLATFORMIO + mock_core.area = None + + old = storage_json.StorageJSON.from_wizard( + name="my_device", + friendly_name="My Device", + address="my_device.local", + platform="ESP8266", + ) + old.esphome_version = "2025.1.0" + old.firmware_bin_path = Path("/old/firmware.bin") + + result = storage_json.StorageJSON.from_esphome_core( + mock_core, old, claim_build=False + ) + + # Build artifact fields carry from the old sidecar, not this run. + assert result.esphome_version == "2025.1.0" + assert result.firmware_bin_path == Path("/old/firmware.bin") + # Validation-derived fields stamp from CORE. + assert result.build_path == "/build/my_device" + assert result.toolchain == "platformio" + assert result.core_platform == "esp8266" + + # With no old sidecar, no build is claimed at all. + bare = storage_json.StorageJSON.from_esphome_core( + mock_core, None, claim_build=False + ) + assert bare.esphome_version is None + assert bare.firmware_bin_path is None + + +def test_load_strict_distinguishes_missing_from_unreadable(tmp_path: Path) -> None: + """load_strict returns None only for a missing file; corrupt raises.""" + assert storage_json.StorageJSON.load_strict(tmp_path / "missing.json") is None + + corrupt = tmp_path / "corrupt.json" + corrupt.write_text("{truncated") + with pytest.raises(ValueError): + storage_json.StorageJSON.load_strict(corrupt) + + +def test_as_dict_serializes_unset_paths_as_null(setup_core: Path) -> None: + """Unset build/firmware paths serialize as JSON null, not str(None).""" + storage = storage_json.StorageJSON.from_wizard( + name="wiz", + friendly_name="Wiz", + address="wiz.local", + platform="ESP32", + ) + + result = storage.as_dict() + + assert result["build_path"] is None + assert result["firmware_bin_path"] is None + + +def test_load_treats_legacy_none_string_paths_as_unset(tmp_path: Path) -> None: + """Sidecars written before as_dict emitted null hold str(None); those + must load as unset, not as Path("None").""" + file_path = tmp_path / "legacy_none.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "wiz", + "friendly_name": "Wiz", + "esp_platform": "ESP32", + "core_platform": "esp32", + "build_path": "None", + "firmware_bin_path": "None", + } + ) + ) + + result = storage_json.StorageJSON.load(file_path) + + assert result is not None + assert result.build_path is None + assert result.firmware_bin_path is None