Merge pull request #17794 from esphome/bump-2026.7.2
CI / Create common environment (push) Canceled after 0s
CI / Check pylint (push) Canceled after 0s
CI / Run script/ci-custom (push) Canceled after 0s
CI / Check import esphome.__main__ time (push) Canceled after 0s
CI / Test downstream esphome/device-builder (push) Canceled after 0s
CI / Run pytest (macOS-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (macOS-latest, 3.14) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.13) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.14) (push) Canceled after 0s
CI / Run pytest (windows-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (windows-latest, 3.14) (push) Canceled after 0s
CI / Determine which jobs to run (push) Canceled after 0s
CI / Run integration tests () (push) Canceled after 0s
CI / Run C++ unit tests (push) Canceled after 0s
CI / Run CodSpeed benchmarks (push) Canceled after 0s
CI / Run script/clang-tidy for ESP8266 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 Arduino (push) Canceled after 0s
CI / Run script/clang-tidy for ZEPHYR (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 1/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 2/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 3/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 C6 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 P4 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 S3 (push) Canceled after 0s
CI / Test components batch () (push) Canceled after 0s
CI / Test esp32 components with PlatformIO (push) Canceled after 0s
CI / Seed pre-commit cache (push) Canceled after 0s
CI / pre-commit.ci lite (push) Canceled after 0s
CI / Build target branch for memory impact (push) Canceled after 0s
CI / Build PR branch for memory impact (push) Canceled after 0s
CI / Comment memory impact (push) Canceled after 0s
CI / CI Status (push) Canceled after 0s

2026.7.2
This commit is contained in:
Jesse Hills
2026-07-23 14:41:52 +12:00
committed by GitHub
16 changed files with 531 additions and 45 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 \
+100 -1
View File
@@ -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,
)
+7 -7
View File
@@ -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<const uint8_t *>(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();
+24 -16
View File
@@ -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());
}
+2
View File
@@ -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<GasTuning> voc_tuning_params_;
optional<GasTuning> nox_tuning_params_;
optional<TemperatureCompensation> temperature_compensation_;
optional<Sen5xType> model_override_;
ESPPreferenceObject pref_;
};
+14 -1
View File
@@ -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)
+3 -6
View File
@@ -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]),
+6 -1
View File
@@ -179,7 +179,12 @@ std::optional<uint32_t> SendspinHub::load_last_server_hash() {
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
std::optional<bool> 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);
}
}
+24 -6
View File
@@ -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
+1 -1
View File
@@ -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 = (
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+1
View File
@@ -42,4 +42,5 @@ sensor:
auto_cleaning_interval: 604800s
acceleration_mode: low
store_baseline: true
model: sen55
address: 0x69
+268 -2
View File
@@ -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]
@@ -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")