From 18f93c0e410b74836ebf78549716c60cdf137279 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:02:30 +0000 Subject: [PATCH 1/7] Bump aioesphomeapi from 45.6.2 to 45.7.0 (#17768) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7168c8a488e..b1f7bc197e2 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.6.2 +aioesphomeapi==45.7.0 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From a08070a836843b5c2fbcc2d513203f79dd01b0de Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:18:21 -0500 Subject: [PATCH 2/7] [sen5x] Add model option to override autodetection (#17764) --- esphome/components/sen5x/sen5x.cpp | 40 ++++++++++++++++++------------ esphome/components/sen5x/sen5x.h | 2 ++ esphome/components/sen5x/sensor.py | 15 ++++++++++- tests/components/sen5x/common.yaml | 1 + 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 588650e6307..f8df89ee33f 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -101,25 +101,31 @@ void SEN5XComponent::setup() { ESP_LOGV(TAG, "Serial number %s", this->serial_number_); uint16_t raw_product_name[16]; - if (!this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) { - ESP_LOGE(TAG, "Failed to read product name"); - this->error_code_ = PRODUCT_NAME_FAILED; - this->mark_failed(); - return; + Sen5xType detected_type = Sen5xType::UNKNOWN; + if (this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) { + const char *product_name = sensirion_convert_to_string_in_place(raw_product_name, 16); + if (strncmp(product_name, "SEN50", 5) == 0) { + detected_type = Sen5xType::SEN50; + } else if (strncmp(product_name, "SEN54", 5) == 0) { + detected_type = Sen5xType::SEN54; + } else if (strncmp(product_name, "SEN55", 5) == 0) { + detected_type = Sen5xType::SEN55; + } } - const char *product_name = sensirion_convert_to_string_in_place(raw_product_name, 16); - if (strncmp(product_name, "SEN50", 5) == 0) { - this->type_ = Sen5xType::SEN50; - } else if (strncmp(product_name, "SEN54", 5) == 0) { - this->type_ = Sen5xType::SEN54; - } else if (strncmp(product_name, "SEN55", 5) == 0) { - this->type_ = Sen5xType::SEN55; - } else { + + if (this->model_override_.has_value()) { + if (detected_type != this->model_override_.value()) { + ESP_LOGW(TAG, "Detected %s, using %s", LOG_STR_ARG(type_to_string(detected_type)), + LOG_STR_ARG(type_to_string(this->model_override_.value()))); + } + this->type_ = this->model_override_.value(); + } else if (detected_type == Sen5xType::UNKNOWN) { this->type_ = Sen5xType::UNKNOWN; - ESP_LOGE(TAG, "Unknown product name: %.32s", product_name); this->error_code_ = PRODUCT_NAME_FAILED; this->mark_failed(); return; + } else { + this->type_ = detected_type; } ESP_LOGD(TAG, "Type: %s", LOG_STR_ARG(type_to_string(this->type_))); @@ -255,10 +261,12 @@ void SEN5XComponent::dump_config() { } } ESP_LOGCONFIG(TAG, - " Type: %s\n" + " Type: %s%s\n" " Firmware version: %d\n" " Serial number: %s", - LOG_STR_ARG(type_to_string(this->type_)), this->firmware_version_, this->serial_number_); + LOG_STR_ARG(type_to_string(this->type_)), + this->model_override_.has_value() ? LOG_STR_LITERAL(" (overridden)") : LOG_STR_LITERAL(""), + this->firmware_version_, this->serial_number_); if (this->auto_cleaning_interval_.has_value()) { ESP_LOGCONFIG(TAG, " Auto cleaning interval: %" PRId32 "s", this->auto_cleaning_interval_.value()); } diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index 6b5a1f85103..ed7689af523 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -95,6 +95,7 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S temp_comp.time_constant = time_constant; this->temperature_compensation_ = temp_comp; } + void set_model(Sen5xType model) { this->model_override_ = model; } bool start_fan_cleaning(); protected: @@ -126,6 +127,7 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S optional voc_tuning_params_; optional nox_tuning_params_; optional temperature_compensation_; + optional model_override_; ESPPreferenceObject pref_; }; diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 480654ee1b0..3ea526d931e 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_MODEL, CONF_NORMALIZED_OFFSET_SLOPE, CONF_NOX, CONF_OFFSET, @@ -39,6 +40,7 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) +from esphome.types import ConfigType CODEOWNERS = ["@martgras"] DEPENDENCIES = ["i2c"] @@ -49,6 +51,7 @@ SEN5XComponent = sen5x_ns.class_( "SEN5XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) RhtAccelerationMode = sen5x_ns.enum("RhtAccelerationMode") +Sen5xType = sen5x_ns.enum("Sen5xType", is_class=True) CONF_ACCELERATION_MODE = "acceleration_mode" CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" @@ -63,6 +66,12 @@ ACCELERATION_MODES = { "high": RhtAccelerationMode.HIGH_ACCELERATION, } +MODELS = { + "SEN50": Sen5xType.SEN50, + "SEN54": Sen5xType.SEN54, + "SEN55": Sen5xType.SEN55, +} + def _gas_sensor( *, @@ -186,6 +195,7 @@ CONFIG_SCHEMA = ( } ), cv.Optional(CONF_ACCELERATION_MODE): cv.enum(ACCELERATION_MODES), + cv.Optional(CONF_MODEL): cv.enum(MODELS, upper=True), } ) .extend(cv.polling_component_schema("60s")) @@ -210,7 +220,7 @@ SETTING_MAP = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -219,6 +229,9 @@ async def to_code(config): if cfg := config.get(key): cg.add(getattr(var, funcName)(cfg)) + if (model := config.get(CONF_MODEL)) is not None: + cg.add(var.set_model(model)) + for key, funcName in SENSOR_MAP.items(): if cfg := config.get(key): sens = await sensor.new_sensor(cfg) diff --git a/tests/components/sen5x/common.yaml b/tests/components/sen5x/common.yaml index a4462a16ea0..20f3a1dfd85 100644 --- a/tests/components/sen5x/common.yaml +++ b/tests/components/sen5x/common.yaml @@ -42,4 +42,5 @@ sensor: auto_cleaning_interval: 604800s acceleration_mode: low store_baseline: true + model: sen55 address: 0x69 From e46bdf9e18ec0377f0a821f98a2bf7be5cf873c0 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 22 Jul 2026 14:41:33 -0400 Subject: [PATCH 3/7] [sendspin] Bump sendspin-cpp to v0.7.0 (#17781) --- esphome/components/sendspin/__init__.py | 9 +++------ esphome/components/sendspin/sendspin_hub.cpp | 7 ++++++- esphome/idf_component.yml | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 97e7f4e22c5..e20925f323d 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -129,12 +129,12 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType: """ network.require_high_performance_networking() # Socket consumption varies by mode: - # - Server mode: 1 listening socket + 2 client connections (for handoff) + # - Server mode: 1 listening socket + 4 client connections (established connection, unproven connections, and a spare) # - Client mode: 1 outbound connection socket.consume_sockets( 1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN )(config) - socket.consume_sockets(2, "sendspin_websocket_server")(config) + socket.consume_sockets(4, "sendspin_websocket_server")(config) socket.consume_sockets(1, "sendspin_websocket_client")(config) wifi.enable_runtime_power_save_control() @@ -198,7 +198,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.0") cg.add_define("USE_SENDSPIN", True) # for MDNS @@ -255,9 +255,6 @@ async def to_code(config: ConfigType) -> None: if psram_stack: psram.request_external_task_stack() - # Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not - # starved by the HTTP server during the initial encoded-audio burst at stream start), - # decode buffer location PREFER_EXTERNAL. player_struct_fields = [ ("audio_formats", audio_format_structs), ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index b95d95b2bcb..04dbab0080c 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -179,7 +179,12 @@ std::optional SendspinHub::load_last_server_hash() { void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, std::optional mute) { if (this->is_ready()) { - this->controller_role_->send_command(command, volume, mute); + sendspin::ClientCommandControllerObject obj = { + .command = command, + .volume = volume, + .muted = mute, + }; + this->controller_role_->send_command(obj); } } diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 60b00d33c73..45efd20bf6c 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.6.1 + version: 0.7.0 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From f7bf4e9727ac0d5d95b280d9fdda5819834235b6 Mon Sep 17 00:00:00 2001 From: Flautz Date: Wed, 22 Jul 2026 22:50:11 +0200 Subject: [PATCH 4/7] [mipi_spi] fix partial update bug (#17747) Co-authored-by: clyde <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/mipi_spi/mipi_spi.h | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 701bcd7169d..b269f46dc98 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -385,10 +385,10 @@ class MipiSpi : public display::Display, * @param ptr The pointer to the pixel data * @param w Width of each line in bytes * @param h Height of the buffer in rows - * @param pad Padding in bytes after each line + * @param stride Total length of each line in bytes, including any padding */ - void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t pad) { - if (pad == 0) { + void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t stride) { + if (stride == w) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w * h); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -405,7 +405,7 @@ class MipiSpi : public display::Display, } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { this->write_cmd_addr_data(0, 0, 0, 0, ptr, w, 8); } - ptr += w + pad; + ptr += stride; } } } @@ -423,7 +423,7 @@ class MipiSpi : public display::Display, ptr += y_offset * (x_offset + w + x_pad) + x_offset; if constexpr (BUFFERPIXEL == DISPLAYPIXEL) { this->write_display_data_(reinterpret_cast(ptr), w * sizeof(BUFFERTYPE), h, - x_pad * sizeof(BUFFERTYPE)); + (x_offset + w + x_pad) * sizeof(BUFFERTYPE)); } else { // type conversion required, do it in chunks uint8_t dbuffer[DISPLAYPIXEL * 48]; @@ -459,14 +459,14 @@ class MipiSpi : public display::Display, } // buffer full? Flush. if (dptr == dbuffer + sizeof(dbuffer)) { - this->write_display_data_(dbuffer, sizeof(dbuffer), 1, 0); + this->write_display_data_(dbuffer, sizeof(dbuffer), 1, sizeof(dbuffer)); dptr = dbuffer; } } } // flush any remaining data if (dptr != dbuffer) { - this->write_display_data_(dbuffer, dptr - dbuffer, 1, 0); + this->write_display_data_(dbuffer, dptr - dbuffer, 1, dptr - dbuffer); } } this->disable(); From e1cedaba877fcb183a9f8c49b78d4f2464b49f45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jul 2026 13:28:22 -1000 Subject: [PATCH 5/7] [bundle] Remap absolute file paths when compiling an extracted bundle (#17765) --- esphome/bundle.py | 101 +++++++- esphome/config_validation.py | 30 ++- tests/unit_tests/test_bundle.py | 270 ++++++++++++++++++++- tests/unit_tests/test_config_validation.py | 77 ++++++ 4 files changed, 469 insertions(+), 9 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index 88df87c3ba4..dcaea03646f 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -12,7 +12,7 @@ from enum import StrEnum import io import json import logging -from pathlib import Path +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath import re import shutil import tarfile @@ -51,6 +51,7 @@ class ManifestKey(StrEnum): MANIFEST_VERSION = "manifest_version" ESPHOME_VERSION = "esphome_version" CONFIG_FILENAME = "config_filename" + CONFIG_DIR = "config_dir" FILES = "files" HAS_SECRETS = "has_secrets" @@ -127,6 +128,12 @@ class BundleData: """Files components asked to include, keyed under DOMAIN in CORE.data.""" extra_files: list[Path] = field(default_factory=list) + # Original config dir parsed from an extracted bundle's manifest.json, + # kept in the path flavor of the machine the bundle was created on. + # The checked flag makes the manifest lookup happen at most once per run; + # CORE.data is cleared between runs. + original_config_dir: PurePath | None = None + original_config_dir_checked: bool = False def _get_data() -> BundleData: @@ -148,6 +155,94 @@ def add_bundle_file(path: Path) -> None: _get_data().extra_files.append(CORE.relative_config_path(path)) +# Windows paths start with a drive letter or contain backslashes; POSIX +# paths do neither in practice, so this is how the flavor of a recorded +# path string is recognized on any host. +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:") + + +def _path_flavor(value: str) -> type[PurePath]: + """Pick the pure path class matching the flavor ``value`` was written in.""" + if "\\" in value or _WINDOWS_DRIVE_RE.match(value): + return PureWindowsPath + return PurePosixPath + + +def _load_original_config_dir() -> PurePath | None: + """Read the original config dir from an extracted bundle's manifest. + + Returns None when the current config dir is not an extracted bundle or + the manifest does not record the original config dir. + """ + manifest_path = CORE.config_dir / MANIFEST_FILENAME + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except FileNotFoundError: + # The common case: this config dir is not an extracted bundle. + return None + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err: + # A manifest.json is present but unreadable or malformed. Say so + # instead of letting it look identical to "not a bundle". + _LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err) + return None + if not isinstance(manifest, dict): + return None + # A manifest.json in the config dir does not have to be ours. Only trust + # one that looks like a bundle manifest for exactly this config file. + version = manifest.get(ManifestKey.MANIFEST_VERSION) + if not isinstance(version, int) or version < 1: + return None + if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name: + return None + config_dir = manifest.get(ManifestKey.CONFIG_DIR) + if not isinstance(config_dir, str) or not config_dir: + return None + return _path_flavor(config_dir)(config_dir) + + +def remap_bundle_path(value: str) -> Path | None: + """Remap an absolute path from the machine a bundle was created on. + + A bundled config may reference files by absolute path. The referenced + files ship inside the bundle at their config-relative locations, but the + YAML text is copied verbatim, so after extraction on another machine the + absolute reference points at a path that only existed on the creating + machine. The bundle manifest records that machine's config dir; when + ``value`` names a path that lived under it, return the corresponding + file next to the extracted config. + + ``value`` is the raw path string from the config. It is parsed with the + original machine's path flavor, so a bundle created on Windows remaps on + a POSIX build server and vice versa. + + Returns None when not compiling an extracted bundle, when ``value`` was + not under the original config dir, or when the bundle does not contain + the file. + """ + data = _get_data() + if not data.original_config_dir_checked: + data.original_config_dir_checked = True + data.original_config_dir = _load_original_config_dir() + original_dir = data.original_config_dir + if original_dir is None: + return None + path = type(original_dir)(value) + if not path.is_absolute(): + return None + try: + rel = path.relative_to(original_dir) + except ValueError: + return None + # relative_to is lexical, so ".." segments survive it. Refuse them: the + # remapped file must land strictly inside the extracted config tree. + if ".." in rel.parts: + return None + remapped = CORE.relative_config_path(Path(*rel.parts)) + if not remapped.exists(): + return None + return remapped + + @dataclass class BundleFile: """A file to include in the bundle.""" @@ -174,6 +269,7 @@ class BundleManifest: config_filename: str files: list[str] has_secrets: bool + config_dir: str | None = None class ConfigBundleCreator: @@ -438,6 +534,7 @@ class ConfigBundleCreator: ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, ManifestKey.ESPHOME_VERSION: const.__version__, ManifestKey.CONFIG_FILENAME: self._config_path.name, + ManifestKey.CONFIG_DIR: str(self._config_dir), ManifestKey.FILES: [f.path for f in files], ManifestKey.HAS_SECRETS: has_secrets, } @@ -522,12 +619,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest: except tarfile.TarError as err: raise EsphomeError(f"Failed to read bundle: {err}") from err + config_dir = manifest.get(ManifestKey.CONFIG_DIR) return BundleManifest( manifest_version=manifest[ManifestKey.MANIFEST_VERSION], esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"), config_filename=manifest[ManifestKey.CONFIG_FILENAME], files=manifest.get(ManifestKey.FILES, []), has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False), + config_dir=config_dir if isinstance(config_dir, str) else None, ) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 3f7c8ff7835..713df5452a6 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1938,14 +1938,29 @@ def dimensions(value): return dimensions([match.group(1), match.group(2)]) +def _remap_bundle_path(value: str) -> Path | None: + """Resolve a path from the machine an extracted bundle was created on. + + An absolute path in a config compiled from an extracted bundle may point + at the machine the bundle was created on; the bundle ships the file at + its config-relative location instead. + """ + from esphome.bundle import remap_bundle_path + + return remap_bundle_path(value) + + def directory(value: object) -> Path: value = string(value) path = CORE.relative_config_path(value) if not path.exists(): - raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) + remapped = _remap_bundle_path(value) + if remapped is None: + raise Invalid( + f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." + ) + path = remapped if not path.is_dir(): raise Invalid( f"Path '{path}' is not a directory (full path: {path.resolve()})." @@ -1958,9 +1973,12 @@ def file_(value: object) -> Path: path = CORE.relative_config_path(value) if not path.exists(): - raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) + remapped = _remap_bundle_path(value) + if remapped is None: + raise Invalid( + f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." + ) + path = remapped if not path.is_file(): raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") return path diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 6cecb63c2d0..f0abcc74c69 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -27,6 +27,7 @@ from esphome.bundle import ( is_bundle_path, prepare_bundle_for_compile, read_bundle_manifest, + remap_bundle_path, ) from esphome.core import CORE, EsphomeError from esphome.yaml_util import force_load_include_files @@ -478,7 +479,10 @@ def test_read_bundle_manifest_corrupted_tar(tmp_path: Path) -> None: def test_read_bundle_manifest(tmp_path: Path) -> None: bundle_path = _make_bundle( tmp_path, - manifest_overrides={ManifestKey.HAS_SECRETS: True}, + manifest_overrides={ + ManifestKey.HAS_SECRETS: True, + ManifestKey.CONFIG_DIR: "/original/config", + }, extra_files={"secrets.yaml": b"wifi: test\n"}, ) @@ -489,6 +493,7 @@ def test_read_bundle_manifest(tmp_path: Path) -> None: assert manifest.esphome_version == "2026.2.0-test" assert manifest.config_filename == "test.yaml" assert manifest.has_secrets is True + assert manifest.config_dir == "/original/config" def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: @@ -508,6 +513,266 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: assert result.esphome_version == "unknown" assert not result.files assert result.has_secrets is False + assert result.config_dir is None + + +def test_read_bundle_manifest_non_string_config_dir(tmp_path: Path) -> None: + """A malformed config_dir value is dropped rather than propagated.""" + bundle_path = _make_bundle( + tmp_path, manifest_overrides={ManifestKey.CONFIG_DIR: 42} + ) + + assert read_bundle_manifest(bundle_path).config_dir is None + + +# --------------------------------------------------------------------------- +# remap_bundle_path +# --------------------------------------------------------------------------- + + +ORIGINAL_CONFIG_DIR = "/original/config" + + +def _bundle_manifest_dict(**overrides: Any) -> dict[str, Any]: + """Manifest content an extracted bundle would contain.""" + manifest: dict[str, Any] = { + ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, + ManifestKey.CONFIG_FILENAME: "test.yaml", + ManifestKey.CONFIG_DIR: ORIGINAL_CONFIG_DIR, + } + manifest.update(overrides) + return manifest + + +def _setup_extracted_dir( + tmp_path: Path, + manifest: dict[str, Any] | str | None, + files: dict[str, str] | None = None, +) -> Path: + """Create a directory shaped like an extracted bundle and point CORE at it.""" + extract_dir = _setup_config_dir(tmp_path, files) + if manifest is not None: + content = manifest if isinstance(manifest, str) else json.dumps(manifest) + (extract_dir / MANIFEST_FILENAME).write_text(content) + return extract_dir + + +def test_remap_bundle_path_success(tmp_path: Path) -> None: + """A stale absolute path resolves to the bundled copy next to the config.""" + extract_dir = _setup_extracted_dir( + tmp_path, _bundle_manifest_dict(), files={"boards/partitions.csv": "csv\n"} + ) + + remapped = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/boards/partitions.csv") + + assert remapped == extract_dir / "boards" / "partitions.csv" + assert remapped.is_file() + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(r"C:\Users\nick\esphome\boards\partitions.csv", id="backslashes"), + pytest.param("C:/Users/nick/esphome/boards/partitions.csv", id="forward"), + pytest.param(r"c:\users\NICK\esphome\boards\partitions.csv", id="case"), + ], +) +def test_remap_bundle_path_windows_bundle_on_posix(tmp_path: Path, value: str) -> None: + """A bundle created on Windows remaps on a build server with another layout.""" + extract_dir = _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}), + files={"boards/partitions.csv": "csv\n"}, + ) + + remapped = remap_bundle_path(value) + + assert remapped == extract_dir / "boards" / "partitions.csv" + assert remapped.is_file() + + +def test_remap_bundle_path_windows_bundle_path_not_under_config_dir( + tmp_path: Path, +) -> None: + """A Windows path outside the original config dir is left alone.""" + _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}), + files={"partitions.csv": "csv\n"}, + ) + + assert remap_bundle_path(r"D:\other\partitions.csv") is None + + +def test_remap_bundle_path_windows_profile_with_spaces(tmp_path: Path) -> None: + r"""A Windows profile like C:\Users\First Last remaps like any other dir.""" + extract_dir = _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict( + **{ManifestKey.CONFIG_DIR: r"C:\Users\First Last\esphome"} + ), + files={"boards/my partitions.csv": "csv\n"}, + ) + + remapped = remap_bundle_path( + r"C:\Users\First Last\esphome\boards\my partitions.csv" + ) + + assert remapped == extract_dir / "boards" / "my partitions.csv" + assert remapped.is_file() + + +def test_remap_bundle_path_unc_config_dir(tmp_path: Path) -> None: + """A bundle created from a UNC share remaps like any other Windows path.""" + extract_dir = _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"\\server\share\esphome"}), + files={"partitions.csv": "csv\n"}, + ) + + remapped = remap_bundle_path(r"\\server\share\esphome\partitions.csv") + + assert remapped == extract_dir / "partitions.csv" + + +def test_remap_bundle_path_flavor_mismatch(tmp_path: Path) -> None: + """A POSIX style value cannot come from a Windows config dir; no remap.""" + _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}), + files={"partitions.csv": "csv\n"}, + ) + + assert remap_bundle_path("/original/config/partitions.csv") is None + + +def test_remap_bundle_path_rejects_traversal(tmp_path: Path) -> None: + """A remap may never escape the extracted config tree.""" + extract_dir = _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + (tmp_path / "outside.csv").write_text("csv\n") + assert (extract_dir / ".." / "outside.csv").resolve().is_file() + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/../outside.csv") is None + + +def test_remap_bundle_path_relative_value(tmp_path: Path) -> None: + """Relative references resolve normally and are never remapped.""" + _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + + assert remap_bundle_path("missing.csv") is None + + +def test_remap_bundle_path_no_manifest(tmp_path: Path) -> None: + """A config dir without a manifest is not an extracted bundle.""" + _setup_extracted_dir(tmp_path, None, files={"partitions.csv": "csv\n"}) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + + +@pytest.mark.parametrize( + "manifest", + [ + pytest.param("{not json", id="malformed_json"), + pytest.param("[]", id="not_a_dict"), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.MANIFEST_VERSION: "x"}), + id="version_not_int", + ), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.MANIFEST_VERSION: 0}), + id="version_zero", + ), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.CONFIG_FILENAME: "other.yaml"}), + id="config_filename_mismatch", + ), + pytest.param( + { + ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, + ManifestKey.CONFIG_FILENAME: "test.yaml", + }, + id="config_dir_missing", + ), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: ""}), + id="config_dir_empty", + ), + ], +) +def test_remap_bundle_path_untrusted_manifest( + tmp_path: Path, manifest: dict[str, Any] | str +) -> None: + """Manifests that do not look like this bundle's manifest are ignored.""" + _setup_extracted_dir(tmp_path, manifest, files={"partitions.csv": "csv\n"}) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + + +def test_remap_bundle_path_unreadable_manifest_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A present but broken manifest is reported, not silently ignored.""" + _setup_extracted_dir(tmp_path, "{not json", files={"partitions.csv": "csv\n"}) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + assert "ignoring unreadable" in caplog.text + + +def test_remap_bundle_path_outside_original_config_dir(tmp_path: Path) -> None: + """Paths that were not under the original config dir are left alone.""" + _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + + assert remap_bundle_path("/elsewhere/partitions.csv") is None + + +def test_remap_bundle_path_bundled_copy_missing(tmp_path: Path) -> None: + """No remap when the bundle does not contain the file.""" + _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + + +def test_remap_bundle_path_manifest_read_once(tmp_path: Path) -> None: + """The manifest lookup result is cached for the rest of the run.""" + extract_dir = _setup_extracted_dir( + tmp_path, _bundle_manifest_dict(), files={"partitions.csv": "csv\n"} + ) + + first = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") + assert first == extract_dir / "partitions.csv" + + (extract_dir / MANIFEST_FILENAME).unlink() + second = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") + assert second == first + + +def test_remap_bundle_path_round_trip(tmp_path: Path) -> None: + """A file referenced by absolute path survives bundle create and extract. + + Reproduces https://github.com/esphome/esphome/issues/17755: the config + names its partitions csv by absolute path, the bundle is extracted on a + machine where that path does not exist, and the reference must resolve + to the bundled copy. + """ + config_dir = _setup_config_dir(tmp_path, files={"partitions.csv": "nvs,data\n"}) + abs_path = (config_dir / "partitions.csv").resolve() + + creator = ConfigBundleCreator({"esp32": {"partitions": abs_path}}) + result = creator.create_bundle() + + bundle_path = tmp_path / f"device{BUNDLE_EXTENSION}" + bundle_path.write_bytes(result.data) + target = tmp_path / "build_server" + config_path = extract_bundle(bundle_path, target) + + # Simulate the build server: fresh run, original config dir gone + CORE.reset() + CORE.config_path = config_path + shutil.rmtree(config_dir) + + remapped = remap_bundle_path(str(abs_path)) + assert remapped == target.resolve() / "partitions.csv" + assert remapped.is_file() # --------------------------------------------------------------------------- @@ -1261,7 +1526,7 @@ def test_create_bundle_produces_valid_archive(tmp_path: Path) -> None: def test_create_bundle_manifest_content(tmp_path: Path) -> None: - _setup_config_dir(tmp_path) + config_dir = _setup_config_dir(tmp_path) creator = ConfigBundleCreator({}) result = creator.create_bundle() @@ -1269,6 +1534,7 @@ def test_create_bundle_manifest_content(tmp_path: Path) -> None: manifest = result.manifest assert manifest[ManifestKey.MANIFEST_VERSION] == CURRENT_MANIFEST_VERSION assert manifest[ManifestKey.CONFIG_FILENAME] == "test.yaml" + assert manifest[ManifestKey.CONFIG_DIR] == str(config_dir.resolve()) assert "test.yaml" in manifest[ManifestKey.FILES] diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index fd21ac92ea3..1da3d5593a6 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +import json from pathlib import Path import string @@ -2912,3 +2913,79 @@ def test_rename_key_present() -> None: def test_rename_key_absent() -> None: assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} + + +def test_file__existing_relative_path(setup_core: Path) -> None: + (setup_core / "partitions.csv").write_text("csv\n") + + assert cv.file_("partitions.csv") == setup_core / "partitions.csv" + + +def test_file__missing_raises(setup_core: Path) -> None: + with pytest.raises(Invalid, match="Could not find file"): + cv.file_("partitions.csv") + + +def test_file__remaps_bundle_absolute_path(setup_core: Path) -> None: + """A stale absolute path in an extracted bundle resolves to the bundled copy.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "/original/config", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "partitions.csv").write_text("csv\n") + + assert cv.file_("/original/config/partitions.csv") == setup_core / "partitions.csv" + + +def test_file__missing_absolute_path_without_bundle(setup_core: Path) -> None: + with pytest.raises(Invalid, match="Could not find file"): + cv.file_("/original/config/partitions.csv") + + +def test_file__remaps_windows_bundle_absolute_path(setup_core: Path) -> None: + """A bundle created on Windows resolves on a host with another layout.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "C:\\Users\\nick\\esphome", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "partitions.csv").write_text("csv\n") + + result = cv.file_("C:\\Users\\nick\\esphome\\partitions.csv") + + assert result == setup_core / "partitions.csv" + + +def test_directory_remaps_bundle_absolute_path(setup_core: Path) -> None: + """A stale absolute directory in an extracted bundle resolves to the bundled copy.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "/original/config", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "headers").mkdir() + + assert cv.directory("/original/config/headers") == setup_core / "headers" + + +def test_directory_missing_raises(setup_core: Path) -> None: + with pytest.raises(Invalid, match="Could not find directory"): + cv.directory("/original/config/headers") + + +def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None: + """A remapped path that is a directory still fails file validation.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "/original/config", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "headers").mkdir() + + with pytest.raises(Invalid, match="is not a file"): + cv.file_("/original/config/headers") From 52ace9c448d7c4485bef99629a1ea60fe544fe92 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:49:38 -1000 Subject: [PATCH 6/7] Bump bundled esphome-device-builder to 1.6.9 (#17793) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1a34fda5201..8649dbcd77b 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.6.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.9 RUN \ platformio settings set enable_telemetry No \ From 66615a24a76e2cff6c723869c1b0c6a86c7c872d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:55:39 +1200 Subject: [PATCH 7/7] Bump version to 2026.7.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index abaaa7b7aa4..0dd5e208d45 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.1 +PROJECT_NUMBER = 2026.7.2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index cc44622c861..d351ce286f6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.1" +__version__ = "2026.7.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (