Compare commits

...
78 Commits
Author SHA1 Message Date
Jesse HillsandGitHub 90d6d8f5ca Merge pull request #18083 from esphome/bump-2026.7.4
2026.7.4
2026-08-06 08:08:37 +12:00
Jesse Hills e2ca80ec41 Bump version to 2026.7.4 2026-08-05 13:19:43 +12:00
920ff9c25f [esp32] explicitly disable BLE 5.0 (#18047)
Co-authored-by: Samuel Sieb <samuel@sieb.net>
2026-08-05 13:19:42 +12:00
esphome[bot]andJesse Hills 56a90d2b25 Bump bundled esphome-device-builder to 1.9.2 (#18051) 2026-08-05 13:19:42 +12:00
esphome[bot]andJesse Hills b4166a883e Bump bundled esphome-device-builder to 1.9.1 (#18025) 2026-08-05 13:19:42 +12:00
esphome[bot]andJesse Hills b8703a8a1b Bump bundled esphome-device-builder to 1.9.0 (#18017) 2026-08-05 13:19:42 +12:00
esphome[bot]andJesse Hills 57bb4e4e77 Bump bundled esphome-device-builder to 1.8.2 (#17995) 2026-08-05 13:19:42 +12:00
J. Nick KostonandJesse Hills de93815c6b [api] Fix double free when overflow buffer drain is re-entered (#17969) 2026-08-05 13:19:42 +12:00
Jesse Hills d4372ed008 [esp32] Restrict toolchain validation to supported values (#17972) 2026-08-05 13:19:42 +12:00
a199ac41ee [espidf] Use forward slashes in generated component CMakeLists (#17965)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-05 13:19:42 +12:00
J. Nick KostonandJesse Hills c0f494450d [git] Fix device adoption failing on first attempt: lock the clone cache against concurrent resolutions (#17923) 2026-08-05 13:19:42 +12:00
6b6903e568 [epaper_spi] Default init sequence to empty not None (#17966)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:19:42 +12:00
esphome[bot]andJesse Hills d0f68802b9 Bump bundled esphome-device-builder to 1.8.1 (#17964) 2026-08-05 13:19:42 +12:00
esphome[bot]Jesse Hillsesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
7862520450 Bump bundled esphome-device-builder to 1.8.0 (#17953)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-08-05 13:19:42 +12:00
dependabot[bot]andJesse Hills cbdddc8020 Bump platformdirs from 4.10.0 to 4.11.0 (#17756)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 13:19:41 +12:00
dependabot[bot]andJesse Hills 7d1317ad53 Bump filelock from 3.29.0 to 3.32.0 (#17759)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 13:19:41 +12:00
Jesse HillsandGitHub 985a08e247 Merge pull request #17905 from esphome/bump-2026.7.3
2026.7.3
2026-07-28 22:43:16 +12:00
Jesse Hills 4f3db4c15a Bump version to 2026.7.3 2026-07-28 21:32:52 +12:00
esphome[bot]andJesse Hills 56512abb7e Update webserver local assets to 20260728-053845 (#17899)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-28 21:32:51 +12:00
Clyde StubbsandJesse Hills 71349a6feb [light] Ensure binary light is off with brightness 0 (#17893) 2026-07-28 21:32:51 +12:00
Jonathan SwobodaandJesse Hills 489e3d17ca [ethernet] Set SPI CS hold time for ENC28J60 (#17885) 2026-07-28 21:32:51 +12:00
Jesse Hills d93772fed6 [captive_portal] Escape SSID when building config JSON (#17872) 2026-07-28 21:32:51 +12:00
J. Nick KostonandJesse Hills 50f02aa523 [git] Fix submodule update failure when cloning libraries on the esp-idf toolchain (#17862) 2026-07-28 21:32:51 +12:00
J. Nick KostonandJesse Hills 5a87ad8fc0 [wifi] Lock scan results shared with the captive portal web task (#17850) 2026-07-28 21:32:51 +12:00
esphome[bot]andJesse Hills e40579ad93 Bump bundled esphome-device-builder to 1.7.0 (#17871) 2026-07-28 21:32:51 +12:00
Rui MarinhoandJesse Hills 37fe59dc37 [espidf] Include .cc, .cxx and .c++ sources in the app source glob (#17754) 2026-07-28 21:32:51 +12:00
Jonathan SwobodaandJesse Hills 3829d368ff [espidf] Honor compile_process_limit in native ESP-IDF builds (#17857) 2026-07-28 21:32:42 +12:00
Marcus VoßandJesse Hills 013c5d7217 [esp32_ble] Forward ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT to GAP handlers (#17833) 2026-07-28 21:29:28 +12:00
esphome[bot]andJesse Hills e38ae51de2 Bump bundled esphome-device-builder to 1.6.10 (#17801) 2026-07-28 21:29:28 +12:00
Jesse HillsandGitHub b832e135cb Merge pull request #17794 from esphome/bump-2026.7.2
2026.7.2
2026-07-23 14:41:52 +12:00
Jesse Hills 66615a24a7 Bump version to 2026.7.2 2026-07-23 13:55:39 +12:00
esphome[bot]andJesse Hills 52ace9c448 Bump bundled esphome-device-builder to 1.6.9 (#17793) 2026-07-23 13:55:39 +12:00
J. Nick KostonandJesse Hills e1cedaba87 [bundle] Remap absolute file paths when compiling an extracted bundle (#17765) 2026-07-23 13:55:38 +12:00
f7bf4e9727 [mipi_spi] fix partial update bug (#17747)
Co-authored-by: clyde <2366188+clydebarrow@users.noreply.github.com>
2026-07-23 13:55:38 +12:00
Kevin AhrendtandJesse Hills e46bdf9e18 [sendspin] Bump sendspin-cpp to v0.7.0 (#17781) 2026-07-23 13:55:38 +12:00
Brandon HarveyandJesse Hills a08070a836 [sen5x] Add model option to override autodetection (#17764) 2026-07-23 13:55:38 +12:00
dependabot[bot]andJesse Hills 18f93c0e41 Bump aioesphomeapi from 45.6.2 to 45.7.0 (#17768)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 13:55:38 +12:00
Jesse HillsandGitHub bbc5669efa Merge pull request #17739 from esphome/bump-2026.7.1
2026.7.1
2026-07-21 10:44:34 +12:00
Guanzhong ChenandJesse Hills 718b04c15a [zephyr] implement ISRInternalGPIOPin::digital_write (#17601) 2026-07-21 09:27:55 +12:00
Jonathan SwobodaandJesse Hills 24a8634a4b [haier] Fix outdoor defrost temperature reporting the coil temperature (#17492) 2026-07-21 09:27:55 +12:00
Jonathan SwobodaandJesse Hills fc6664d737 [emc2101] Fix negative external temperatures reported as large positives (#17494) 2026-07-21 09:27:55 +12:00
dependabot[bot]andJesse Hills 388e411469 Bump aioesphomeapi from 45.6.1 to 45.6.2 (#17654)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 09:17:18 +12:00
Jesse Hills a8ebdcb8f2 Bump version to 2026.7.1 2026-07-21 08:25:51 +12:00
J. Nick KostonandJesse Hills f5f1c48f51 [esp8266] Fail fast when Rosetta 2 is missing on Apple Silicon Macs (#17737) 2026-07-21 08:25:51 +12:00
J. Nick KostonandJesse Hills 7c55de311f [wireguard] Mark private keys sensitive, stop redacting public keys (#17736) 2026-07-21 08:25:46 +12:00
J. Nick KostonandJesse Hills 307faa6c38 [espidf] Resume interrupted toolchain downloads instead of restarting (#17706) 2026-07-21 08:18:16 +12:00
J. Nick KostonandJesse Hills 5f2adcf9b3 [platformio] Include cache path in invalid library error (#17692) 2026-07-21 08:18:16 +12:00
7738464f0b [http_request] Fix usage of http response body (#17713)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-07-21 08:18:16 +12:00
J. Nick KostonandJesse Hills 5a86e26f68 [platformio] Re-download library when cached copy is missing its manifest (#17691)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-21 08:18:16 +12:00
J. Nick KostonJesse Hillspre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
786b47d8c2 [core] Auto-clean the PlatformIO build environment when the Python version changes (#17671)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills 83092ea05c Bump bundled esphome-device-builder to 1.6.8 (#17708) 2026-07-21 08:18:15 +12:00
629afd38f6 [light] Fix pulse and other effects (#17645)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 3ffc3a9610 [espidf] Make openocd-esp32 optional so its libusb check cannot break installs (#17686) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 9de7bd7461 [git] Detect interrupted clones and re-clone automatically (#17690) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 5df922e0df [platformio] Accept git URLs passed as the library name (#17697) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills b571d2a5ab [micro_wake_word] Download models in parallel (#17701) 2026-07-21 08:18:15 +12:00
Jesse Hills 231a2897c0 [ssd1306] Fix offset_x being ignored on SH1106/SH1107 displays (#17700) 2026-07-21 08:18:15 +12:00
Brandon HarveyandJesse Hills 2223b14794 Split multi-token build.flags entries when generating ESP-IDF component CMakeLists (#17649) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills 3d340f4d90 Bump bundled esphome-device-builder to 1.6.7 (#17696) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills 2f99466f3a Bump bundled esphome-device-builder to 1.6.6 (#17681) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills f05e15522c Bump bundled esphome-device-builder to 1.6.5 (#17675) 2026-07-21 08:18:15 +12:00
Jonathan SwobodaandJesse Hills f37dad683b [esp32] Bump recommended ESP-IDF to 5.5.5 and Arduino to 3.3.10 (#17669) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills a0fb14bf54 Bump bundled esphome-device-builder to 1.6.4 (#17662) 2026-07-21 08:18:15 +12:00
Jesse Hills d4443be0c1 [nrf52] Install PlatformIO toolchain Python packages into a dedicated venv (#17635) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 0dfb573e18 [logs] Cap the logs reconnect backoff for deep-sleep devices (#17656) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 572fc033cd Ship component requirements.txt files in the sdist and wheel (#17660) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 4062f0a323 Pin cryptography to 48.0.1 on Intel macOS (#17658) 2026-07-21 08:18:15 +12:00
Brandon HarveyandJesse Hills cd40fb1c68 [core] Fix srcFilter exclusions being silently ignored on Windows (#17648) 2026-07-21 08:18:15 +12:00
Jonathan SwobodaandJesse Hills 7afe7750cd [espidf] Suggest installing missing system libraries when the tools install fails (#17619) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills e08bdf8cca Bump bundled esphome-device-builder to 1.6.3 (#17651) 2026-07-21 08:18:15 +12:00
dependabot[bot]andJesse Hills b3172ecae8 Bump aioesphomeapi from 45.6.0 to 45.6.1 (#17653)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 08:18:14 +12:00
esphome[bot]Jesse Hillsesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2b4d9c0af9 Bump bundled esphome-device-builder to 1.6.2 (#17640)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-07-21 08:18:14 +12:00
Jesse Hills 3a10d2c187 [nrf52] Set ZEPHYR_SDK_INSTALL_DIR for Zephyr SDK discovery (#17633) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills 05e2c6b133 [web_server_idf] Use core format_hex_to helper for digest auth (fixes Arduino build) (#17608) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills 2797349c75 [http_request] Fix use-after-return of header collection state in IDF backend (#17627) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills 95a01ac2ab [core] Improve framework mirror selection, download errors, and version parsing (#17615) 2026-07-21 08:18:14 +12:00
J. Nick KostonandJesse Hills ea01c909b7 [micro_wake_word] Include the local model file in bundles (#17604) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills d3655597ea [as3935_i2c] Use repeated start when reading registers (#17584) 2026-07-21 08:18:14 +12:00
110 changed files with 13693 additions and 6176 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.0
PROJECT_NUMBER = 2026.7.4
# 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
View File
@@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c
recursive-include esphome *.py.script
recursive-include esphome *.jinja
recursive-include esphome LICENSE.txt
recursive-include esphome requirements.txt
+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.1
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.2
RUN \
platformio settings set enable_telemetry No \
+17 -2
View File
@@ -776,6 +776,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
check_placeholder_credentials(config)
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
# working on machines that cannot run the toolchain.
if CORE.is_esp8266:
from esphome.components.esp8266 import check_rosetta
check_rosetta()
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
@@ -1510,10 +1517,18 @@ def _redact_with_legacy_fallback(output: str) -> str:
m = _LEGACY_REDACTION_RE.search(line)
if m is None:
continue
key = m.group("key")
if not in_substitutions:
unmarked.add(m.group("key"))
# Public keys (e.g. wireguard's peer_public_key) are not secret;
# redacting them and telling maintainers to mark them cv.sensitive
# would be wrong on both counts. Substitution keys are user-named
# with no schema behind them, so anything secret-shaped there
# (public or not) stays conservatively redacted.
if "public" in key.split("_"):
continue
unmarked.add(key)
lines[i] = (
f"{line[: m.start()]}{m.group('key')}: "
f"{line[: m.start()]}{key}: "
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
)
output = "\n".join(lines)
+12
View File
@@ -210,15 +210,27 @@ def get_component_cmakelists() -> str:
if(CMAKE_SCRIPT_MODE_FILE)
file(GLOB_RECURSE app_sources
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
else()
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
endif()
+135 -3
View File
@@ -7,12 +7,12 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
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
@@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError
_LOGGER = logging.getLogger(__name__)
DOMAIN = "bundle"
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1
@@ -49,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"
@@ -120,6 +123,126 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
return keys
@dataclass
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:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = BundleData()
return CORE.data[DOMAIN]
def add_bundle_file(path: Path) -> None:
"""Register a file that a bundle must include.
Bundle discovery walks the validated config, so it only finds files the config
names. Components call this during validation for files it cannot see, such as a
file that is referenced from inside another file.
A relative path is taken as relative to the config directory. Files outside the
config directory are skipped when the bundle is built.
"""
_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."""
@@ -146,6 +269,7 @@ class BundleManifest:
config_filename: str
files: list[str]
has_secrets: bool
config_dir: str | None = None
class ConfigBundleCreator:
@@ -286,13 +410,18 @@ class ConfigBundleCreator:
with known file extensions are also resolved and checked.
Core ESPHome concepts that use relative paths or directories
are handled explicitly.
are handled explicitly. Files the config does not name at all are
registered by their component with add_bundle_file().
"""
config = self._config
# Generic walk: find all file paths in the validated config
self._walk_config_for_files(config)
# Files registered by components during validation
for extra_file in _get_data().extra_files:
self._add_file(extra_file)
# --- Core ESPHome concepts needing explicit handling ---
# esphome.includes / includes_c - can be relative paths and directories
@@ -405,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,
}
@@ -489,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,
)
+19 -2
View File
@@ -12,6 +12,22 @@ APIOverflowBuffer::~APIOverflowBuffer() {
}
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
// socket->write() can re-enter this function: a log message emitted from an
// lwip callback during the write goes out over the API and lands back in the
// frame helper's write/drain path. If a nested drain ran here it would send
// and free the entry the outer drain is still holding, causing a double free.
// Report "no progress" instead; the outer drain keeps draining, and the
// nested send is enqueued behind the existing backlog.
if (this->draining_)
return 0;
// RAII so the flag is cleared on every return path
struct DrainGuard {
explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; }
~DrainGuard() { this->flag_ = false; }
bool &flag_;
} guard(this->draining_);
while (this->count_ > 0) {
Entry *front = this->queue_[this->head_];
@@ -29,11 +45,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
return sent;
}
// Entry fully sent — free it and advance
Entry::destroy(front);
// Entry fully sent — unlink it before freeing so a freed pointer is never
// reachable from the queue
this->queue_[this->head_] = nullptr;
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
this->count_--;
Entry::destroy(front);
}
return 0; // All drained
@@ -69,6 +69,10 @@ class APIOverflowBuffer {
uint8_t head_{0};
uint8_t tail_{0};
uint8_t count_{0};
// Guards against re-entrant drains: socket->write() can re-enter the API
// send path (e.g. a log message emitted from an lwip callback), and a nested
// drain would free the entry the outer drain is still holding.
bool draining_{false};
};
} // namespace esphome::api
+3
View File
@@ -152,6 +152,9 @@ async def async_run_logs(
name=name,
subscribe_states=subscribe_states,
allow_plaintext_fallback=True,
# A top-level ``deep_sleep:`` block means the device is only awake
# briefly; cap the reconnect backoff so a wake window is not missed.
deep_sleep="deep_sleep" in config,
)
try:
await asyncio.Event().wait()
+1 -5
View File
@@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits,
uint8_t I2CAS3935Component::read_register(uint8_t reg) {
uint8_t value;
if (write(&reg, 1) != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Writing register failed!");
return 0;
}
if (read(&value, 1) != i2c::ERROR_OK) {
if (!this->read_byte(reg, &value)) {
ESP_LOGW(TAG, "Reading register failed!");
return 0;
}
@@ -1,7 +1,7 @@
import logging
import esphome.codegen as cg
from esphome.components import web_server_base
from esphome.components import web_server_base, wifi
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
@@ -101,6 +101,9 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID], paren)
await cg.register_component(var, config)
cg.add_define("USE_CAPTIVE_PORTAL")
# The portal reads wifi scan results from the web server task; this makes the
# wifi component guard them with a lock on multi-threaded platforms.
wifi.request_wifi_scan_results_lock()
if config[CONF_COMPRESSION] == "gzip":
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
+137 -136
View File
@@ -7,145 +7,146 @@ namespace esphome::captive_portal {
#ifdef USE_CAPTIVE_PORTAL_GZIP
constexpr uint8_t INDEX_GZ[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e,
0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36,
0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf,
0x7e, 0xa0, 0x24, 0x7b, 0x9d, 0x45, 0x73, 0xb8, 0xb3, 0x60, 0x61, 0x38, 0xef, 0x19, 0xcd, 0x83, 0xc5, 0xdf, 0x2a,
0xc5, 0xec, 0xbe, 0x03, 0xd4, 0xd8, 0x56, 0x94, 0x85, 0x7b, 0x23, 0x41, 0xe5, 0x8a, 0x80, 0x2c, 0x8b, 0x06, 0x68,
0x55, 0x16, 0x2d, 0x58, 0x8a, 0x58, 0x43, 0xb5, 0x01, 0x4b, 0xfe, 0xbc, 0xff, 0x39, 0xb8, 0x2d, 0x0b, 0xc1, 0xe5,
0x1a, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0x24, 0x22,
0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0x04, 0x6f, 0x79, 0x65, 0x1b, 0x52,
0xc1, 0x86, 0x33, 0x08, 0x86, 0xc3, 0x35, 0x97, 0xdc, 0x72, 0x2a, 0x02, 0xc3, 0xa8, 0x00, 0x92, 0x5c, 0xf7, 0x06,
0xf4, 0x70, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0x5c, 0x16, 0x86, 0x69, 0xde, 0x59, 0xe4, 0x5c, 0x25, 0xad, 0xaa, 0x7a,
0x01, 0x65, 0x14, 0x51, 0x63, 0xc0, 0x9a, 0x88, 0xcb, 0x0a, 0x76, 0xe1, 0x32, 0x66, 0x2c, 0x86, 0xdb, 0xdb, 0xf0,
0xb3, 0x79, 0x56, 0x29, 0xd6, 0xb7, 0x20, 0x6d, 0x28, 0x14, 0xa3, 0x96, 0x2b, 0x19, 0x1a, 0xa0, 0x9a, 0x35, 0x84,
0x10, 0xfc, 0xa3, 0xa1, 0x1b, 0xc0, 0xdf, 0x7f, 0xef, 0x9d, 0x99, 0x56, 0x60, 0xff, 0x21, 0xc0, 0x81, 0xe6, 0xa7,
0xfd, 0x3d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x30, 0x35, 0xbc, 0x02, 0xec, 0x7f, 0x8c, 0x3f, 0x85, 0xc6, 0xee, 0x05,
0x84, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x82, 0x97, 0x42, 0xb1, 0x35, 0xf6, 0xf3, 0xba, 0x97, 0xcc, 0x29, 0x47, 0xc6,
0x03, 0xff, 0x20, 0xc0, 0x22, 0x4b, 0xde, 0x51, 0xdb, 0x84, 0x2d, 0xdd, 0x79, 0x23, 0xc0, 0xa5, 0x97, 0xfe, 0xe0,
0xc1, 0xcb, 0x24, 0x8e, 0xfd, 0xeb, 0xe1, 0x15, 0xfb, 0x51, 0x12, 0xc7, 0xb9, 0x06, 0xdb, 0x6b, 0x89, 0xa8, 0xf7,
0x50, 0x74, 0xd4, 0x36, 0xa8, 0x22, 0xf8, 0x5d, 0x92, 0xa2, 0xe4, 0x75, 0x98, 0xce, 0x7f, 0x0b, 0x5f, 0xa1, 0x9b,
0x30, 0x9d, 0xb3, 0x57, 0xc1, 0x1c, 0x25, 0x37, 0xc1, 0x1c, 0xa5, 0x69, 0x38, 0x47, 0xf1, 0x17, 0x8c, 0x6a, 0x2e,
0x04, 0xc1, 0x52, 0x49, 0xc0, 0xc8, 0x58, 0xad, 0xd6, 0x40, 0x30, 0xeb, 0xb5, 0x06, 0x69, 0xdf, 0x2a, 0xa1, 0x34,
0x8e, 0xca, 0x67, 0xff, 0x97, 0x42, 0xab, 0xa9, 0x34, 0xb5, 0xd2, 0x2d, 0xc1, 0x43, 0xf6, 0xbd, 0x17, 0x07, 0x7b,
0x44, 0xee, 0xe5, 0x5f, 0x10, 0x03, 0xa5, 0xf9, 0x8a, 0x4b, 0x82, 0x9d, 0xc6, 0x5b, 0x1c, 0x95, 0x0f, 0xfe, 0xf1,
0x1c, 0x3d, 0x75, 0xd1, 0x4f, 0xf1, 0x28, 0xef, 0xe3, 0x43, 0x61, 0x36, 0x2b, 0xb4, 0x6b, 0x85, 0x34, 0x04, 0x37,
0xd6, 0x76, 0x59, 0x14, 0x6d, 0xb7, 0xdb, 0x70, 0x3b, 0x0b, 0x95, 0x5e, 0x45, 0x69, 0x1c, 0xc7, 0x91, 0xd9, 0xac,
0x30, 0x1a, 0x0b, 0x01, 0xa7, 0x37, 0x18, 0x35, 0xc0, 0x57, 0x8d, 0x1d, 0xe0, 0xf2, 0xc5, 0x01, 0x8e, 0x85, 0xe3,
0x28, 0x1f, 0x3e, 0x5d, 0x58, 0xe1, 0x17, 0x56, 0xe0, 0x47, 0xea, 0xe1, 0x53, 0x98, 0x57, 0x43, 0x98, 0xaf, 0x68,
0x8a, 0x52, 0x14, 0x0f, 0x4f, 0x1a, 0x38, 0x78, 0x3a, 0x05, 0x4f, 0x4e, 0xe8, 0xe2, 0xe4, 0xa0, 0x76, 0x11, 0xbc,
0x3e, 0xcb, 0x26, 0x0e, 0xb3, 0x49, 0xe2, 0x47, 0x84, 0x13, 0xf8, 0x65, 0x71, 0x79, 0x0e, 0xd2, 0x0f, 0x97, 0x0c,
0xce, 0x5a, 0x93, 0x7c, 0x58, 0xd0, 0x39, 0x9a, 0x4f, 0x98, 0x79, 0xe0, 0xe0, 0xf3, 0x09, 0xcd, 0x37, 0x69, 0x93,
0xb4, 0xc1, 0x22, 0x98, 0xd3, 0x19, 0x9a, 0x4d, 0x8e, 0xcc, 0xd0, 0x6c, 0x93, 0x36, 0x8b, 0x0f, 0x8b, 0x4b, 0x5c,
0x30, 0xfb, 0x72, 0x15, 0x95, 0xd8, 0xcf, 0x30, 0x7e, 0x8c, 0x5c, 0x5d, 0x46, 0x1e, 0x7e, 0x56, 0x5c, 0x7a, 0x18,
0xfb, 0xc7, 0x1a, 0x2c, 0x6b, 0x3c, 0x1c, 0x31, 0x25, 0x6b, 0xbe, 0x0a, 0x3f, 0x1b, 0x25, 0xb1, 0x1f, 0xda, 0x06,
0xa4, 0x77, 0x12, 0x75, 0x82, 0x30, 0x50, 0xbc, 0xa7, 0x14, 0xeb, 0x1f, 0xce, 0xf5, 0x6f, 0xb9, 0x15, 0x40, 0x6c,
0xe8, 0x1a, 0xf6, 0xfa, 0x8c, 0x5d, 0xaa, 0x6a, 0xff, 0x8d, 0xd6, 0x68, 0x92, 0xb1, 0x2f, 0xb8, 0x94, 0xa0, 0xef,
0x61, 0x67, 0x09, 0x7e, 0xf7, 0xe6, 0x2d, 0x7a, 0x53, 0x55, 0x1a, 0x8c, 0xc9, 0x10, 0x7e, 0x69, 0xc3, 0x96, 0xb2,
0xff, 0x5d, 0x57, 0xf2, 0x95, 0xae, 0x7f, 0xf2, 0x9f, 0x39, 0xfa, 0x1d, 0xec, 0x56, 0xe9, 0xf5, 0xa4, 0xcd, 0xb9,
0x96, 0xbb, 0x0e, 0xd3, 0xc4, 0x86, 0xb4, 0x33, 0xa1, 0x11, 0x9c, 0x81, 0x97, 0xf8, 0x61, 0x4b, 0xbb, 0xc7, 0xa8,
0xe4, 0x29, 0x51, 0x0f, 0x45, 0xc5, 0x37, 0x88, 0x09, 0x6a, 0x0c, 0xc1, 0x72, 0x54, 0x85, 0xd1, 0x33, 0x34, 0xfc,
0x94, 0x64, 0x82, 0xb3, 0x35, 0xc1, 0x7f, 0x31, 0x01, 0x7e, 0xda, 0xff, 0x5a, 0x79, 0x57, 0xc6, 0xf0, 0xea, 0xca,
0x0f, 0x37, 0x54, 0xf4, 0x80, 0x08, 0xb2, 0x0d, 0x37, 0x8f, 0x0e, 0xe6, 0xdf, 0x14, 0xeb, 0xcc, 0xfa, 0xca, 0x0f,
0x6b, 0xc5, 0x7a, 0xe3, 0xf9, 0xb8, 0x9c, 0xcc, 0x15, 0x74, 0x1c, 0x90, 0xf8, 0x39, 0x7e, 0xe2, 0x51, 0x20, 0xa0,
0xb6, 0x67, 0x3e, 0x84, 0x5e, 0x1c, 0x8c, 0x27, 0x43, 0x6d, 0x0c, 0xf7, 0x8f, 0x67, 0x64, 0x61, 0x3a, 0x2a, 0x9f,
0x0a, 0x3a, 0x07, 0x5d, 0xab, 0xc8, 0xd0, 0x41, 0xae, 0x5f, 0x3a, 0x2a, 0xcf, 0x06, 0x23, 0x7a, 0x02, 0x5f, 0x1c,
0xb8, 0x27, 0xdd, 0x14, 0x5c, 0x9f, 0x35, 0x16, 0x51, 0xc5, 0x37, 0xe5, 0xc3, 0xd1, 0x7f, 0x8c, 0xe3, 0x5f, 0x3d,
0xe8, 0xfd, 0x1d, 0x08, 0x60, 0x56, 0x69, 0x0f, 0x3f, 0x97, 0x60, 0xb1, 0x3f, 0x06, 0xfc, 0xcb, 0xfd, 0xbb, 0xdf,
0x88, 0xf2, 0xb4, 0x7f, 0xfd, 0x2d, 0x6e, 0xb7, 0x0a, 0x3e, 0x6a, 0x10, 0xff, 0x26, 0x57, 0x6e, 0x19, 0x5c, 0x7d,
0xc2, 0x7e, 0x38, 0xc4, 0xfb, 0xf0, 0xb8, 0x11, 0x5c, 0x3b, 0xbf, 0xdc, 0xb5, 0xe2, 0xda, 0x45, 0x18, 0x2c, 0xe6,
0xfe, 0xf1, 0xe1, 0xe8, 0x1f, 0xfd, 0xbc, 0x88, 0xc6, 0xb9, 0x5e, 0x16, 0xc3, 0x88, 0x2d, 0x7f, 0x38, 0x2c, 0xd5,
0x2e, 0x30, 0xfc, 0x0b, 0x97, 0xab, 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0xae, 0xb9, 0xec, 0x7a, 0x7b,
0xe8, 0x68, 0x55, 0x39, 0xca, 0xbc, 0xdb, 0xe5, 0xb5, 0x92, 0xd6, 0x71, 0x42, 0x96, 0x40, 0x7b, 0x1c, 0xe9, 0xc3,
0x44, 0xc9, 0x5e, 0xcf, 0xbf, 0x3b, 0xba, 0x82, 0x3b, 0x58, 0xd8, 0xd9, 0x80, 0x0a, 0xbe, 0x92, 0x19, 0x03, 0x69,
0x41, 0x8f, 0x42, 0x35, 0x6d, 0xb9, 0xd8, 0x67, 0x86, 0x4a, 0x13, 0x18, 0xd0, 0xbc, 0x3e, 0x2e, 0x7b, 0x6b, 0x95,
0x3c, 0x2c, 0x95, 0xae, 0x40, 0x67, 0x71, 0x3e, 0x02, 0x81, 0xa6, 0x15, 0xef, 0x4d, 0x16, 0xce, 0x34, 0xb4, 0xf9,
0x92, 0xb2, 0xf5, 0x4a, 0xab, 0x5e, 0x56, 0x01, 0x73, 0x93, 0x36, 0x7b, 0x9e, 0xd4, 0x74, 0x06, 0x2c, 0x9f, 0x4e,
0x75, 0x5d, 0xe7, 0x82, 0x4b, 0x08, 0xc6, 0x59, 0x96, 0xa5, 0xe1, 0x8d, 0x13, 0xbb, 0x70, 0x33, 0x4c, 0x1d, 0x62,
0xf4, 0x31, 0x89, 0xe3, 0xef, 0xf2, 0x53, 0x38, 0x71, 0xce, 0x7a, 0x6d, 0x94, 0xce, 0x3a, 0xc5, 0x9d, 0x9b, 0xc7,
0x96, 0x72, 0x79, 0xe9, 0xbd, 0x2b, 0x93, 0x7c, 0x5a, 0x3f, 0x19, 0x97, 0x83, 0x99, 0x61, 0x09, 0xe5, 0x2d, 0x97,
0xe3, 0x0e, 0xcd, 0xd2, 0x45, 0xdc, 0xed, 0x8e, 0xe1, 0x54, 0x20, 0x87, 0x13, 0x77, 0x2d, 0x60, 0x97, 0x7f, 0xee,
0x8d, 0xe5, 0xf5, 0x3e, 0x98, 0x76, 0x70, 0x66, 0x3a, 0xca, 0x20, 0x58, 0x82, 0xdd, 0x02, 0xc8, 0x7c, 0xb0, 0x11,
0x70, 0x0b, 0xad, 0x99, 0xf2, 0x74, 0x56, 0x33, 0x14, 0xe8, 0xd7, 0xba, 0xfe, 0x1b, 0xb7, 0xab, 0xc5, 0x43, 0x4b,
0xf5, 0x8a, 0xcb, 0x60, 0xa9, 0xac, 0x55, 0x6d, 0x16, 0xbc, 0xea, 0x76, 0xf9, 0x84, 0x72, 0xca, 0xb2, 0xc4, 0xb9,
0x39, 0xec, 0xd6, 0x53, 0xbe, 0x93, 0x6e, 0x87, 0x8c, 0x12, 0xbc, 0x9a, 0xf8, 0x06, 0x16, 0x14, 0x9f, 0xd3, 0x93,
0xcc, 0xbb, 0x1d, 0x72, 0xb8, 0x53, 0xaa, 0x6f, 0xea, 0x5b, 0x9a, 0xc4, 0x7f, 0xf1, 0x45, 0xaa, 0xba, 0x4e, 0x97,
0xf5, 0x39, 0x53, 0x6e, 0x4d, 0xba, 0xd6, 0x18, 0x4a, 0xab, 0x88, 0xc6, 0xdb, 0x8c, 0xab, 0x8c, 0xb2, 0x70, 0x19,
0x2e, 0x8b, 0x26, 0x41, 0xbc, 0x22, 0x2d, 0x65, 0xe5, 0xc5, 0xf8, 0x2a, 0xa2, 0x26, 0x39, 0x91, 0x9a, 0xa4, 0xfc,
0x6a, 0x18, 0x8d, 0xb4, 0xc1, 0xfb, 0xf2, 0xad, 0x92, 0x12, 0x98, 0xe5, 0x72, 0x85, 0xac, 0x42, 0x53, 0x0a, 0xc2,
0x30, 0x2c, 0x96, 0xba, 0x7c, 0x2f, 0x80, 0x1a, 0x40, 0x5b, 0xca, 0x6d, 0x58, 0x44, 0x23, 0xff, 0xd8, 0xc7, 0xbc,
0x22, 0x12, 0x6c, 0x39, 0x35, 0x6c, 0xd1, 0xcc, 0x46, 0x03, 0x77, 0x60, 0x9d, 0x26, 0x67, 0x60, 0x56, 0x16, 0x6e,
0xe5, 0x22, 0x3a, 0x8c, 0x34, 0x12, 0x6d, 0x79, 0xcd, 0xdd, 0x95, 0xa5, 0x2c, 0x86, 0x22, 0x77, 0x1a, 0x5c, 0x9e,
0xc7, 0xeb, 0xd5, 0x00, 0x09, 0x90, 0x2b, 0xdb, 0x90, 0x59, 0x8a, 0x3a, 0x41, 0x19, 0x34, 0x4a, 0x54, 0xa0, 0xc9,
0xdd, 0xdd, 0xaf, 0x7f, 0x2f, 0x9d, 0x33, 0x8f, 0x72, 0x9d, 0x59, 0x8f, 0x62, 0x0e, 0x98, 0xa4, 0x16, 0x37, 0xe3,
0xa5, 0xaa, 0xa3, 0xc6, 0x6c, 0x95, 0xae, 0xbe, 0xd2, 0xf1, 0x7e, 0x42, 0x8e, 0x7a, 0x86, 0xff, 0xd0, 0x2a, 0xe5,
0x1d, 0xdd, 0x40, 0x11, 0x4d, 0x87, 0x22, 0x72, 0x0e, 0x8f, 0xf4, 0x66, 0xe2, 0x6b, 0x92, 0xf2, 0x8f, 0xfb, 0x37,
0xe8, 0xcf, 0xae, 0xa2, 0x16, 0xc6, 0xb4, 0x0d, 0x51, 0xb5, 0x60, 0x1b, 0x55, 0x91, 0xf7, 0x7f, 0xdc, 0xdd, 0x9f,
0x23, 0xec, 0x07, 0x26, 0x04, 0x92, 0x8d, 0xd7, 0xbb, 0x5e, 0x58, 0xde, 0x51, 0x6d, 0x07, 0xb5, 0x81, 0x9b, 0x22,
0xa7, 0x18, 0x06, 0x7a, 0xcd, 0x05, 0x8c, 0x61, 0x8c, 0x82, 0x25, 0x3a, 0x79, 0x75, 0xb2, 0xf6, 0xc4, 0xaf, 0x68,
0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00};
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7f,
0x05, 0x8f, 0x4d, 0x1b, 0xa9, 0xb1, 0xa8, 0x87, 0xd7, 0xde, 0x44, 0x96, 0x54, 0xa4, 0x7b, 0x2d, 0x5a, 0xa0, 0x69,
0x03, 0xec, 0x36, 0xf7, 0x21, 0x08, 0xb0, 0x34, 0x39, 0xb2, 0x98, 0xa5, 0x48, 0x1d, 0x49, 0xbf, 0x62, 0xf8, 0x7e,
0xfb, 0x81, 0x92, 0xec, 0xf5, 0x2e, 0x9a, 0x03, 0x0e, 0x86, 0x85, 0x19, 0xce, 0x7b, 0x38, 0x0f, 0x16, 0xff, 0xe0,
0x9a, 0xb9, 0x7d, 0x07, 0xa8, 0x71, 0xad, 0xac, 0x0a, 0xff, 0x45, 0x92, 0xaa, 0x55, 0x09, 0xaa, 0x2a, 0x1a, 0xa0,
0xbc, 0x2a, 0x5a, 0x70, 0x14, 0xb1, 0x86, 0x1a, 0x0b, 0xae, 0xfc, 0xeb, 0xee, 0x97, 0xe8, 0x75, 0x55, 0x48, 0xa1,
0x1e, 0x90, 0x01, 0x59, 0x0a, 0xa6, 0x15, 0x6a, 0x0c, 0xd4, 0x25, 0xa7, 0x8e, 0xe6, 0xa2, 0xa5, 0x2b, 0x18, 0x45,
0x14, 0x6d, 0xa1, 0xdc, 0x08, 0xd8, 0x76, 0xda, 0x38, 0xc4, 0xb4, 0x72, 0xa0, 0x5c, 0x89, 0xb7, 0x82, 0xbb, 0xa6,
0xe4, 0xb0, 0x11, 0x0c, 0xa2, 0x1e, 0x99, 0x08, 0x25, 0x9c, 0xa0, 0x32, 0xb2, 0x8c, 0x4a, 0x28, 0xd3, 0xc9, 0xda,
0x82, 0xe9, 0x11, 0xba, 0x94, 0x50, 0x2a, 0x8d, 0xab, 0xc2, 0x32, 0x23, 0x3a, 0x87, 0xbc, 0xab, 0x65, 0xab, 0xf9,
0x5a, 0x42, 0x15, 0xc7, 0xd4, 0x5a, 0x70, 0x36, 0x16, 0x8a, 0xc3, 0x8e, 0xd0, 0x6b, 0xb8, 0xa6, 0x2c, 0x4d, 0xc8,
0x67, 0xfb, 0x0d, 0xd7, 0x6c, 0xdd, 0x82, 0x72, 0x44, 0x6a, 0x46, 0x9d, 0xd0, 0x8a, 0x58, 0xa0, 0x86, 0x35, 0x65,
0x59, 0xe2, 0x1f, 0x2d, 0xdd, 0x00, 0xfe, 0xfe, 0xfb, 0xe0, 0xcc, 0xb4, 0x02, 0xf7, 0xb3, 0x04, 0x0f, 0xda, 0x9f,
0xf6, 0x77, 0x74, 0xf5, 0x07, 0x6d, 0x21, 0xc0, 0xd4, 0x0a, 0x0e, 0x38, 0xfc, 0x98, 0x7c, 0x22, 0xd6, 0xed, 0x25,
0x10, 0x2e, 0x6c, 0x27, 0xe9, 0xbe, 0xc4, 0x4b, 0xa9, 0xd9, 0x03, 0x0e, 0x17, 0xf5, 0x5a, 0x31, 0xaf, 0x1c, 0xe9,
0x00, 0xc2, 0x83, 0x04, 0x87, 0x5c, 0xf9, 0x8e, 0xba, 0x86, 0xb4, 0x74, 0x17, 0x0c, 0x80, 0x50, 0x41, 0xf6, 0x43,
0x00, 0xaf, 0xd2, 0x24, 0x09, 0x27, 0xfd, 0x27, 0x09, 0xe3, 0x34, 0x49, 0x16, 0x06, 0xdc, 0xda, 0x28, 0x44, 0x83,
0xfb, 0xa2, 0xa3, 0xae, 0x41, 0xbc, 0xc4, 0xef, 0xd2, 0x0c, 0xa5, 0x6f, 0x48, 0x36, 0xfb, 0x9d, 0x5c, 0xa3, 0x2b,
0x92, 0xcd, 0xd8, 0x75, 0x34, 0x43, 0xe9, 0x55, 0x34, 0x43, 0x59, 0x46, 0x66, 0x28, 0xf9, 0x82, 0x51, 0x2d, 0xa4,
0x2c, 0xb1, 0xd2, 0x0a, 0x30, 0xb2, 0xce, 0xe8, 0x07, 0x28, 0x31, 0x5b, 0x1b, 0x03, 0xca, 0xdd, 0x68, 0xa9, 0x0d,
0x8e, 0xab, 0x6f, 0xfe, 0x2f, 0x85, 0xce, 0x50, 0x65, 0x6b, 0x6d, 0xda, 0x12, 0xf7, 0xd9, 0x0f, 0x5e, 0x1c, 0xdc,
0x11, 0xf9, 0x4f, 0x78, 0x41, 0x8c, 0xb4, 0x11, 0x2b, 0xa1, 0x4a, 0xec, 0x35, 0xbe, 0xc6, 0x71, 0x75, 0x1f, 0x1e,
0xcf, 0xd1, 0x53, 0x1f, 0xfd, 0x18, 0x0f, 0x0f, 0x3e, 0xde, 0x17, 0x76, 0xb3, 0x42, 0xbb, 0x56, 0x2a, 0x5b, 0xe2,
0xc6, 0xb9, 0x2e, 0x8f, 0xe3, 0xed, 0x76, 0x4b, 0xb6, 0x53, 0xa2, 0xcd, 0x2a, 0xce, 0x92, 0x24, 0x89, 0xed, 0x66,
0x85, 0xd1, 0x50, 0x08, 0x38, 0xbb, 0xc2, 0xa8, 0x01, 0xb1, 0x6a, 0x5c, 0x0f, 0x57, 0x2f, 0x0e, 0x70, 0x2c, 0x3c,
0x47, 0x75, 0xff, 0xe9, 0xc2, 0x8a, 0xb9, 0xb0, 0x02, 0x3f, 0xd2, 0x00, 0x9f, 0xc2, 0x7c, 0xd9, 0x87, 0x79, 0x4d,
0x33, 0x94, 0xa1, 0xa4, 0xff, 0x65, 0x91, 0x87, 0x47, 0x2c, 0x7a, 0x86, 0xa1, 0x0b, 0xcc, 0x43, 0xed, 0x3c, 0x7a,
0x73, 0x96, 0x4d, 0xfd, 0xc9, 0x26, 0x4d, 0x1e, 0x0f, 0xbc, 0xc0, 0xaf, 0xf3, 0x4b, 0x3c, 0xca, 0x3e, 0x5c, 0x32,
0x78, 0x6b, 0x4d, 0xfa, 0x61, 0x4e, 0x67, 0x68, 0x36, 0x9e, 0xcc, 0x22, 0x0f, 0x9f, 0x31, 0x34, 0xdb, 0x64, 0x4d,
0xda, 0x46, 0xf3, 0x68, 0x46, 0xa7, 0x68, 0x3a, 0x3a, 0x32, 0x45, 0xd3, 0x4d, 0xd6, 0xcc, 0x3f, 0xcc, 0x2f, 0xcf,
0xa2, 0xe9, 0x97, 0x97, 0x71, 0x85, 0xc3, 0x1c, 0xe3, 0xc7, 0xc8, 0xf9, 0x65, 0xe4, 0xe4, 0xb3, 0x16, 0x2a, 0xc0,
0x38, 0x3c, 0xd6, 0xe0, 0x58, 0x13, 0xe0, 0x98, 0x69, 0x55, 0x8b, 0x15, 0xf9, 0x6c, 0xb5, 0xc2, 0x21, 0x71, 0x0d,
0xa8, 0xe0, 0x24, 0xea, 0x05, 0xa1, 0xa7, 0x04, 0xcf, 0x29, 0x2e, 0x3c, 0x9c, 0xeb, 0xdf, 0x09, 0x27, 0xa1, 0x74,
0xc4, 0x37, 0xec, 0xe4, 0x6f, 0xba, 0xe2, 0xa7, 0xfd, 0x6f, 0x3c, 0xc0, 0x2d, 0x65, 0x38, 0x24, 0x42, 0x29, 0x30,
0x77, 0xb0, 0x73, 0x25, 0x7e, 0xf7, 0xf6, 0x06, 0xbd, 0xe5, 0xdc, 0x80, 0xb5, 0x39, 0xc2, 0xaf, 0x1c, 0x69, 0x29,
0xfb, 0xba, 0x78, 0x93, 0x3e, 0x95, 0xfe, 0x97, 0xf8, 0x45, 0xa0, 0x3f, 0xc0, 0x6d, 0xb5, 0x79, 0x18, 0xe5, 0xbd,
0xfd, 0x85, 0x6f, 0x23, 0x56, 0x7e, 0x55, 0x8d, 0x02, 0x87, 0xc3, 0x89, 0xf8, 0x3a, 0x83, 0xb5, 0x82, 0xe3, 0x70,
0x22, 0xbf, 0xce, 0xd1, 0x59, 0xdf, 0xbc, 0x8e, 0xd0, 0xce, 0x12, 0x2b, 0x05, 0x83, 0x20, 0x0d, 0x49, 0xad, 0xcd,
0xcf, 0x94, 0x35, 0x8f, 0x09, 0xb2, 0x43, 0x47, 0xab, 0x47, 0x3d, 0xcc, 0x00, 0x75, 0x30, 0xaa, 0x0a, 0x30, 0x17,
0x1b, 0x1c, 0x2e, 0x14, 0x61, 0x92, 0x5a, 0xeb, 0x47, 0x46, 0xe9, 0x9d, 0xf3, 0xe1, 0xe0, 0x89, 0x1a, 0x22, 0xfd,
0xf5, 0xee, 0xdd, 0xef, 0xe5, 0x7d, 0x41, 0x87, 0x01, 0x89, 0xbf, 0xc5, 0xa8, 0x67, 0x3e, 0x33, 0x46, 0x12, 0x6a,
0xe7, 0x2b, 0x5e, 0x07, 0x96, 0x18, 0x6b, 0x45, 0x78, 0x2c, 0x6c, 0x47, 0xd5, 0x73, 0xb6, 0x3e, 0xa6, 0xaa, 0x88,
0x3d, 0xad, 0x2a, 0x62, 0x5a, 0xbd, 0x38, 0x98, 0xc0, 0xfa, 0xe1, 0xf6, 0x10, 0x1e, 0xef, 0x27, 0x8a, 0xfc, 0x7b,
0x0d, 0x66, 0x7f, 0x0b, 0x12, 0x98, 0xd3, 0x26, 0xc0, 0xe4, 0x89, 0x60, 0x48, 0x1c, 0xec, 0xdc, 0xcd, 0x38, 0x7f,
0x2d, 0xf1, 0x87, 0x13, 0x45, 0xb4, 0x62, 0x52, 0xb0, 0x87, 0xf2, 0x1c, 0x71, 0x78, 0x10, 0x64, 0x43, 0xe5, 0x1a,
0x4e, 0x3c, 0x92, 0xd4, 0x9a, 0xad, 0x6d, 0x10, 0x1e, 0x27, 0x8c, 0xd0, 0xae, 0x03, 0xc5, 0x6f, 0x1a, 0x21, 0x79,
0xa0, 0xc2, 0x63, 0xf8, 0x78, 0xd3, 0xcf, 0x8c, 0xfb, 0xd5, 0xf0, 0xd1, 0x80, 0xfc, 0x4f, 0xf9, 0xd2, 0x2f, 0x87,
0x97, 0x9f, 0x70, 0x48, 0xfa, 0xf8, 0xef, 0x1f, 0x37, 0x84, 0x6f, 0xef, 0x57, 0xbb, 0x56, 0x4e, 0x7c, 0xe8, 0xd1,
0x7c, 0x16, 0x1e, 0xef, 0x8f, 0xe1, 0x31, 0x5c, 0x14, 0xf1, 0x30, 0xe7, 0xab, 0xa2, 0x1f, 0xb9, 0xd5, 0x0f, 0x87,
0xa5, 0xde, 0x45, 0x56, 0x7c, 0x11, 0x6a, 0x95, 0x0b, 0xd5, 0x80, 0x11, 0xee, 0xc8, 0xc5, 0x66, 0x22, 0x54, 0xb7,
0x76, 0x87, 0x8e, 0x72, 0xee, 0x29, 0xb3, 0x6e, 0xb7, 0xa8, 0xb5, 0x72, 0x9e, 0x13, 0xf2, 0x14, 0xda, 0xe3, 0x40,
0xef, 0x27, 0x4c, 0xfe, 0x66, 0xf6, 0xdd, 0x71, 0xa9, 0xf9, 0xfe, 0xe0, 0xd3, 0x10, 0x51, 0x29, 0x56, 0x2a, 0x67,
0xa0, 0x1c, 0x98, 0x41, 0xa8, 0xa6, 0xad, 0x90, 0xfb, 0xdc, 0x52, 0x65, 0x23, 0x0b, 0x46, 0xd4, 0xc7, 0xe5, 0xda,
0x39, 0xad, 0x0e, 0x4b, 0x6d, 0x38, 0x98, 0x3c, 0x59, 0x0c, 0x40, 0x64, 0x28, 0x17, 0x6b, 0x9b, 0x93, 0xa9, 0x81,
0x76, 0xb1, 0xa4, 0xec, 0x61, 0x65, 0xf4, 0x5a, 0xf1, 0x88, 0xf9, 0xc9, 0x9b, 0x7f, 0x9b, 0xd6, 0x74, 0x0a, 0x6c,
0x31, 0x62, 0x75, 0x5d, 0x2f, 0xa4, 0x50, 0x10, 0x0d, 0xb3, 0x2d, 0xcf, 0xc8, 0x95, 0x17, 0xbb, 0x70, 0x93, 0x64,
0xfe, 0x60, 0xf0, 0x31, 0x4d, 0x92, 0xef, 0x16, 0xa7, 0x70, 0x92, 0x05, 0x5b, 0x1b, 0xab, 0x4d, 0xde, 0x69, 0xe1,
0xdd, 0x3c, 0xb6, 0x54, 0xa8, 0x4b, 0xef, 0x7d, 0xd9, 0x2c, 0xc6, 0x75, 0x94, 0x0b, 0xd5, 0x9b, 0xe9, 0x97, 0xd2,
0xa2, 0x15, 0x6a, 0xd8, 0xa9, 0x79, 0x36, 0x4f, 0xba, 0xdd, 0xf1, 0x54, 0x09, 0x87, 0x13, 0x77, 0x2d, 0x61, 0xb7,
0xf8, 0xbc, 0xb6, 0x4e, 0xd4, 0xfb, 0x68, 0xdc, 0xc9, 0xb9, 0xed, 0x28, 0x83, 0x68, 0x09, 0x6e, 0x0b, 0xa0, 0x16,
0xbd, 0x8d, 0x48, 0x38, 0x68, 0xed, 0x98, 0xa7, 0xb3, 0x9a, 0xbe, 0x60, 0x9f, 0xea, 0xfa, 0x5f, 0xdc, 0xbe, 0x8a,
0x0e, 0x2d, 0x35, 0x2b, 0xa1, 0xa2, 0xa5, 0x76, 0x4e, 0xb7, 0x79, 0x74, 0xdd, 0xed, 0x16, 0xe3, 0x91, 0x57, 0x96,
0xa7, 0xde, 0xcd, 0x7e, 0xd7, 0x9e, 0xf2, 0x9d, 0x76, 0x3b, 0x64, 0xb5, 0x14, 0x7c, 0xe4, 0xeb, 0x59, 0x50, 0x72,
0x4e, 0x4f, 0x3a, 0xeb, 0x76, 0xc8, 0x9f, 0x9d, 0x52, 0x7d, 0x55, 0xbf, 0xa6, 0x69, 0xf2, 0x37, 0x37, 0xc2, 0xeb,
0x3a, 0x5b, 0xd6, 0xe7, 0x4c, 0xf9, 0xb5, 0xe9, 0x57, 0x4b, 0x5f, 0x5a, 0x45, 0x3c, 0xbc, 0x6e, 0x7c, 0x65, 0x54,
0x85, 0xcf, 0x70, 0x55, 0x34, 0x29, 0x12, 0xbc, 0x6c, 0x29, 0xab, 0x2e, 0x66, 0x5b, 0x11, 0x37, 0xe9, 0x89, 0xd4,
0xa4, 0xd5, 0x93, 0xb9, 0x35, 0xd0, 0x7a, 0xef, 0xab, 0x1b, 0xad, 0x14, 0x30, 0x27, 0xd4, 0x0a, 0x39, 0x8d, 0xc6,
0x14, 0x10, 0x42, 0x8a, 0xa5, 0xa9, 0xde, 0x4b, 0xa0, 0x16, 0xd0, 0x96, 0x0a, 0x47, 0x8a, 0x78, 0xe0, 0x1f, 0x3a,
0x5d, 0xf0, 0x52, 0x81, 0x3b, 0xf7, 0x76, 0x33, 0x1d, 0x0c, 0xdc, 0x82, 0xf3, 0x9a, 0xbc, 0x81, 0x69, 0x55, 0xf8,
0x15, 0x8c, 0x68, 0xdf, 0xa5, 0x65, 0xbc, 0x15, 0xb5, 0xf0, 0x4f, 0x98, 0xaa, 0xe8, 0x8b, 0xdc, 0x6b, 0xf0, 0x79,
0x1e, 0x9e, 0x5b, 0x3d, 0x24, 0x41, 0xad, 0x5c, 0x53, 0x4e, 0x33, 0xd4, 0x49, 0xca, 0xa0, 0xd1, 0x92, 0x83, 0x29,
0x6f, 0x6f, 0x7f, 0xfb, 0x67, 0xe5, 0x9d, 0x79, 0x94, 0xeb, 0xec, 0xc3, 0x20, 0xe6, 0x81, 0x51, 0x6a, 0x7e, 0x35,
0x3c, 0xb2, 0x3a, 0x6a, 0xed, 0x56, 0x1b, 0xfe, 0x44, 0xc7, 0xfb, 0xf1, 0x70, 0xd0, 0xd3, 0xff, 0xfb, 0x56, 0xa9,
0x6e, 0xe9, 0x06, 0x8a, 0x78, 0x44, 0x8a, 0xd8, 0x3b, 0x3c, 0xd0, 0x9b, 0x91, 0xaf, 0x49, 0xab, 0x3f, 0xef, 0xde,
0xa2, 0xbf, 0x3a, 0x4e, 0x1d, 0x0c, 0x69, 0xeb, 0xa3, 0x6a, 0xc1, 0x35, 0x9a, 0x97, 0xef, 0xff, 0xbc, 0xbd, 0x3b,
0x47, 0xb8, 0xee, 0x99, 0x10, 0x28, 0x36, 0x3c, 0xf7, 0xd6, 0xd2, 0x89, 0x8e, 0x1a, 0xd7, 0xab, 0x8d, 0xfc, 0x14,
0x39, 0xc5, 0xd0, 0xd3, 0x6b, 0x21, 0x61, 0x08, 0x63, 0x10, 0xac, 0xd0, 0xc9, 0xab, 0x93, 0xb5, 0x67, 0x7e, 0xc5,
0xc3, 0x6d, 0xc7, 0xc3, 0xd5, 0xc7, 0xfd, 0xcb, 0xf7, 0xbf, 0x81, 0xdb, 0x13, 0xb5, 0x09, 0x0b, 0x00, 0x00};
#else // Brotli (default, smaller)
constexpr uint8_t INDEX_BR[] PROGMEM = {
0x1b, 0xf8, 0x0a, 0x00, 0x64, 0x5a, 0xd3, 0xfa, 0xe7, 0xf3, 0x62, 0xd8, 0x06, 0x1b, 0xe9, 0x6a, 0x8a, 0x81, 0x2b,
0xb5, 0x49, 0x14, 0x37, 0xdc, 0x9e, 0x1a, 0xcb, 0x56, 0x87, 0xfb, 0xff, 0xf7, 0x73, 0x75, 0x12, 0x0a, 0xd6, 0x48,
0x84, 0xc6, 0x21, 0xa4, 0x6d, 0xb5, 0x71, 0xef, 0x13, 0xbe, 0x4e, 0x54, 0xf1, 0x64, 0x8f, 0x3f, 0xcc, 0x9a, 0x78,
0xa5, 0x89, 0x25, 0xb3, 0xda, 0x2c, 0xa2, 0x32, 0x9c, 0x57, 0x07, 0x56, 0xbc, 0x34, 0x13, 0xff, 0x5c, 0x0a, 0xa1,
0x67, 0x82, 0xb8, 0x6b, 0x4c, 0x76, 0x31, 0x6c, 0xe3, 0x40, 0x46, 0xea, 0xb0, 0xd4, 0xf4, 0x3b, 0x02, 0x65, 0x18,
0xa4, 0xaf, 0xac, 0x6d, 0x55, 0xd6, 0xbe, 0x59, 0x66, 0x7a, 0x7c, 0x60, 0xb2, 0x83, 0x33, 0x23, 0xc9, 0x79, 0x82,
0x47, 0xb4, 0x28, 0xf4, 0x24, 0xb5, 0x23, 0x5a, 0x44, 0xe1, 0xc3, 0x27, 0x04, 0xe8, 0x0c, 0xdd, 0xb4, 0xd0, 0x8c,
0xfb, 0x10, 0x39, 0x93, 0x04, 0x2a, 0x66, 0x18, 0x4b, 0x74, 0xca, 0x31, 0x7f, 0xb2, 0xe5, 0x45, 0xc1, 0xdd, 0x72,
0x49, 0xff, 0x0e, 0xb3, 0xf0, 0x93, 0x18, 0xab, 0x68, 0xad, 0xe1, 0x9d, 0xe4, 0x29, 0xc0, 0xe3, 0x63, 0x54, 0x61,
0x1b, 0x45, 0xb9, 0x6c, 0x23, 0x0f, 0x99, 0x7f, 0x8e, 0x69, 0xaa, 0xc1, 0xb8, 0x4e, 0x42, 0x9c, 0xc5, 0x6e, 0x69,
0x40, 0x0e, 0x4f, 0x97, 0xd3, 0x23, 0x18, 0xf5, 0xc8, 0x75, 0x73, 0xb5, 0xbd, 0x46, 0x8a, 0x97, 0x7d, 0x83, 0xe4,
0x29, 0x72, 0x73, 0xc1, 0x39, 0x8e, 0x7e, 0x84, 0x39, 0x66, 0x57, 0xc6, 0x85, 0x19, 0x8b, 0xf2, 0x4d, 0xd9, 0xfe,
0x75, 0xa9, 0xe1, 0x2b, 0x21, 0x81, 0x58, 0x51, 0x99, 0xbc, 0xa4, 0x0b, 0x10, 0x6f, 0x86, 0x17, 0x0b, 0x92, 0x00,
0x11, 0x6f, 0x3b, 0xa4, 0xa4, 0x11, 0x7e, 0x0b, 0x97, 0x85, 0x23, 0x0c, 0x01, 0x6f, 0x2a, 0x18, 0xc6, 0xbe, 0x3d,
0x77, 0x1a, 0xe6, 0x00, 0x5c, 0x1a, 0x14, 0x47, 0xc6, 0xcc, 0xcc, 0x52, 0xbe, 0x04, 0x19, 0x31, 0x05, 0x46, 0xa0,
0xc3, 0x69, 0x0c, 0x60, 0xb7, 0x14, 0x57, 0xa0, 0x92, 0xbf, 0xb7, 0x0c, 0xd8, 0x3a, 0x79, 0x09, 0x99, 0xc9, 0x71,
0x88, 0x01, 0x8b, 0xa5, 0x61, 0x0a, 0xb5, 0xe8, 0xc7, 0x71, 0xe7, 0x70, 0x79, 0xb6, 0xe4, 0x01, 0xfc, 0x1a, 0x4a,
0x7b, 0x60, 0x6e, 0xef, 0x95, 0x62, 0x59, 0x28, 0xb5, 0x25, 0x56, 0x15, 0xe7, 0xca, 0xad, 0x32, 0xe6, 0xf7, 0x01,
0x31, 0x34, 0x87, 0x93, 0x0b, 0x9b, 0x9d, 0x26, 0xff, 0xe5, 0x92, 0xad, 0x6f, 0xb8, 0x3b, 0x16, 0xc1, 0xa0, 0x5a,
0x4f, 0x52, 0x0b, 0x2b, 0xc1, 0xa7, 0x95, 0x7b, 0x24, 0x51, 0xd3, 0xb3, 0x23, 0x62, 0x0b, 0xcc, 0xa0, 0x58, 0xa7,
0x64, 0x45, 0x2f, 0x0b, 0xdd, 0x1d, 0x97, 0x82, 0x1f, 0xcc, 0x64, 0xdb, 0xd3, 0xf4, 0xb0, 0x8b, 0xc8, 0xcf, 0x15,
0x81, 0x8b, 0xa1, 0x9d, 0xf8, 0xfc, 0xec, 0x49, 0x40, 0x12, 0x01, 0x09, 0x51, 0xf3, 0x73, 0x18, 0x24, 0x97, 0x55,
0x85, 0x6a, 0x92, 0x1a, 0xf5, 0x5a, 0x05, 0x54, 0x1f, 0x27, 0x0a, 0xa8, 0xa1, 0x94, 0x58, 0x78, 0x7d, 0x87, 0xa8,
0xdb, 0x13, 0x66, 0x20, 0x5e, 0x43, 0x18, 0x7a, 0xbb, 0x16, 0x16, 0x07, 0xc8, 0xab, 0x10, 0xe2, 0x50, 0xb9, 0xb1,
0xd8, 0x21, 0xc8, 0x4a, 0x2e, 0x99, 0x0e, 0x23, 0x52, 0xc6, 0xcb, 0x29, 0x84, 0x91, 0x03, 0xb1, 0xe2, 0x4c, 0x1d,
0x22, 0xd3, 0xc8, 0x79, 0x00, 0x8b, 0x8b, 0x88, 0x1e, 0x29, 0xd3, 0xae, 0x10, 0x15, 0x22, 0x6d, 0xb0, 0x87, 0x6f,
0x27, 0x2e, 0x7c, 0xc2, 0x7a, 0x61, 0xbd, 0x22, 0xe5, 0x5f, 0xdd, 0x7b, 0x00, 0x04, 0xf2, 0x7d, 0x5a, 0x03, 0x38,
0x1f, 0x69, 0x6d, 0x0b, 0xfb, 0xec, 0x45, 0xfe, 0x8b, 0x7f, 0xec, 0x7b, 0xad, 0xc2, 0x33, 0xf1, 0x9e, 0x9c, 0x71,
0xd9, 0xe8, 0x5e, 0x8f, 0xd4, 0xee, 0x87, 0x45, 0x6c, 0xe2, 0x12, 0xf8, 0xb8, 0xc5, 0xee, 0x43, 0xa6, 0x37, 0x91,
0xb5, 0x2c, 0x2f, 0xe9, 0xe8, 0x24, 0xd0, 0x45, 0xc1, 0x0c, 0x7c, 0xf0, 0xb2, 0xb5, 0x2d, 0x10, 0x36, 0x7e, 0x18,
0x7c, 0x79, 0x82, 0x69, 0x3d, 0x35, 0xca, 0x52, 0xee, 0xc9, 0xb5, 0x65, 0xa4, 0xa1, 0xfd, 0x70, 0x7e, 0xe0, 0x7d,
0x67, 0xf9, 0xa1, 0x71, 0xd2, 0x08, 0x74, 0x33, 0x5f, 0x69, 0xa4, 0x59, 0x03, 0xfd, 0xf8, 0xf0, 0x70, 0x1a, 0x50,
0x43, 0xfb, 0x61, 0xf0, 0x38, 0x18, 0x88, 0x85, 0x36, 0x23, 0x06, 0x4f, 0x02, 0xbb, 0x78, 0x1a, 0xaa, 0xd2, 0x02,
0x5e, 0xa0, 0x74, 0x30, 0xc8, 0x7a, 0x66, 0xab, 0xd9, 0x43, 0x99, 0x45, 0xb7, 0x0c, 0x5c, 0xec, 0xc8, 0x03, 0x0e,
0x0b, 0xca, 0x4a, 0x22, 0x48, 0xfb, 0xb7, 0x3d, 0x82, 0x07, 0x8d, 0x1b, 0x21, 0x87, 0x4d, 0x57, 0xa4, 0x5b, 0xd4,
0xe3, 0x88, 0x02, 0xc4, 0x81, 0xf9, 0x47, 0xe4, 0xbf, 0x3e, 0x39, 0xbb, 0x4f, 0x7e, 0x91, 0x63, 0x98, 0x97, 0xe4,
0x52, 0x01, 0x58, 0xba, 0x32, 0xbf, 0xae, 0xff, 0x45, 0xa1, 0xbc, 0x9b, 0xa4, 0x09, 0x0e, 0x79, 0xc0, 0x41, 0x86,
0x52, 0x88, 0x55, 0x39, 0x9d, 0xb6, 0xed, 0x35, 0x68, 0x29, 0xfa, 0xe6, 0x6c, 0x3d, 0x0a, 0xcd, 0x6a, 0x28, 0xfd,
0x65, 0x24, 0xce, 0x38, 0x98, 0x01, 0xd9, 0x3f, 0x1b, 0x4c, 0xc4, 0x5c, 0x1d, 0xaa, 0x21, 0x78, 0x67, 0xaf, 0x55,
0x72, 0x34, 0xf8, 0x1b, 0x03, 0x21, 0x27, 0x08, 0xbd, 0x59, 0x60, 0x48, 0x0d, 0xe2, 0x56, 0x9b, 0x30, 0x92, 0x8f,
0x67, 0x8a, 0x7f, 0x20, 0xbd, 0x2d, 0xfd, 0xc5, 0xb0, 0xa6, 0xaa, 0x77, 0x75, 0x26, 0x33, 0x2f, 0x20, 0x2a, 0xab,
0x5c, 0xd1, 0x3b, 0xda, 0xb2, 0x4c, 0xa4, 0x86, 0x25, 0x8d, 0x49, 0x05, 0xaf, 0x7a, 0xa8, 0xd4, 0x9c, 0x0d, 0xd3,
0x38, 0xa6, 0x5c, 0x29, 0x6b, 0x16, 0x27, 0x07, 0xf1, 0xbe, 0xe2, 0x24, 0xc1, 0x8d, 0x25, 0x76, 0xbc, 0xf6, 0x0d,
0xc2, 0x94, 0x25, 0xb8, 0xf3, 0x07, 0x9a, 0x49, 0xf4, 0x89, 0x82, 0x4d, 0x51, 0xb1, 0x96, 0x61, 0x62, 0x8d, 0xc8,
0x61, 0x65, 0x0d, 0x14, 0x34, 0x02, 0x65, 0x94, 0xcc, 0x1d, 0x85, 0x00, 0x0f, 0x1a, 0x57, 0x68, 0x15, 0xcf, 0xa4,
0xa2, 0x7d, 0x6d, 0x53, 0x60, 0xce, 0x5c, 0x61, 0x82, 0x17, 0x32, 0xc1, 0x87, 0x02, 0x0c, 0x91, 0x85, 0x57, 0x51,
0xbe, 0xb2, 0x38, 0x9f, 0x3d, 0x2a, 0x52, 0x5a, 0xad, 0xba, 0x46, 0x9e, 0x3c, 0x8a, 0xa0, 0x46, 0x15, 0xf4, 0x59,
0x74, 0x5f, 0x2a, 0xae, 0x96, 0x56, 0xf0, 0x54, 0x39, 0xaf, 0xac, 0x2a, 0xb9, 0xad, 0x32, 0x50, 0xc9, 0xc1, 0xee,
0xd2, 0x0d, 0x34, 0xaa, 0x98, 0x4d, 0x6d, 0x3d, 0xc6, 0xb9, 0x5b, 0x00, 0x5f, 0xea, 0xda, 0x16, 0xa6, 0x08, 0x43,
0x58, 0x4d, 0x8d, 0x07, 0x55, 0x62, 0x81, 0x44, 0xcc, 0x31, 0x04, 0x4b, 0x4c, 0x8b, 0x3e, 0xff, 0xd8, 0xf6, 0x65,
0x19, 0xa1, 0x94, 0x62, 0x65, 0x0a, 0xdd, 0x60, 0x38, 0xd3, 0xbe, 0x0d, 0xa3, 0x99, 0xd5, 0x37, 0x68, 0xa1, 0x71,
0xa3, 0x41, 0xe7, 0xbe, 0x9d, 0x72, 0x84, 0x75, 0xb6, 0x8d, 0x98, 0xd6, 0xb8, 0x2d, 0x43, 0x85, 0x5d, 0xf9, 0xca,
0xc3, 0x96, 0xa5, 0xa6, 0xe7, 0x50, 0x88, 0x6b, 0x84, 0x58, 0x44, 0x45, 0x20, 0xdf, 0x1e, 0x5a, 0xc9, 0xce, 0x42,
0x2a, 0x1f, 0x3e, 0x3c, 0x7b, 0x68, 0x3c, 0x34, 0x8b, 0x36, 0xba, 0x1f, 0xce, 0x0f, 0xa0, 0x60, 0x37, 0x5f, 0x1a,
0x03, 0x2b, 0x86, 0x29, 0x45, 0x7b, 0xb4, 0xb7, 0x06, 0x68, 0x17, 0x7e, 0x13, 0x76, 0x91, 0x4d, 0x27, 0xee, 0xbc,
0x7e, 0x80, 0xc2, 0x66, 0xac, 0xc6, 0xbf, 0xeb, 0x7f, 0xd7, 0x84, 0x79, 0xf3, 0xf1, 0xde, 0xec, 0xa6, 0x93, 0xa8,
0x13, 0x3b, 0x4a, 0x81, 0xfa, 0x11, 0x1e, 0x4a, 0xd2, 0x50, 0x2a, 0xea, 0x9a, 0xc2, 0x37, 0x08, 0xed, 0x01, 0xf5,
0xa2, 0xd5, 0x32, 0x29, 0x49, 0xc4, 0x1a, 0x11, 0xc0, 0xda, 0x24, 0x28, 0x84, 0x38, 0x60, 0x80, 0xcf, 0xd0, 0x45,
0x83, 0xa7, 0xca, 0x52, 0x5c, 0xac, 0x23, 0x01};
0x1b, 0x08, 0x0b, 0x00, 0xe4, 0x7f, 0x9b, 0xad, 0xbb, 0x97, 0x53, 0xde, 0xb7, 0x25, 0x0e, 0x69, 0xd4, 0x69, 0x89,
0xba, 0xa5, 0x55, 0x22, 0x04, 0x27, 0xeb, 0x00, 0x52, 0xac, 0xbc, 0xec, 0x5f, 0xfb, 0xb5, 0x7a, 0x12, 0x0a, 0xd6,
0x48, 0x84, 0x4a, 0x48, 0x5a, 0xcb, 0xbd, 0xb7, 0x72, 0x66, 0x4e, 0x62, 0xd8, 0xbd, 0x7f, 0x88, 0x68, 0x86, 0x28,
0xd6, 0xf1, 0xda, 0x2c, 0xa2, 0x32, 0x5c, 0xdd, 0xab, 0xb2, 0x15, 0xbc, 0x24, 0x13, 0xe6, 0xbd, 0x0c, 0x52, 0x63,
0x82, 0x88, 0x6d, 0x74, 0x71, 0x51, 0x9c, 0xe2, 0x40, 0x56, 0xea, 0xb0, 0x5c, 0xb7, 0x1d, 0x81, 0x3a, 0x0c, 0xf2,
0xd7, 0xa8, 0x5c, 0x35, 0x2d, 0x43, 0xb3, 0x42, 0x37, 0x7c, 0x60, 0xd8, 0xc1, 0x95, 0x91, 0x94, 0x3c, 0x29, 0x20,
0x96, 0x20, 0xf6, 0x24, 0xb7, 0x83, 0x4a, 0x06, 0xf1, 0xc3, 0x2f, 0x88, 0x50, 0x55, 0x35, 0x2d, 0xe8, 0xb6, 0x21,
0x38, 0x61, 0x8e, 0x44, 0x2a, 0xac, 0x39, 0xcf, 0x74, 0xaa, 0x31, 0x7f, 0x62, 0x32, 0x9b, 0x99, 0x42, 0x0a, 0xf6,
0x6f, 0xd8, 0x89, 0x3f, 0x49, 0xb1, 0xa2, 0x52, 0x0a, 0x8e, 0xb3, 0x27, 0x0b, 0x07, 0x07, 0x58, 0xb0, 0x8f, 0xaa,
0xdc, 0x68, 0x12, 0x0f, 0x95, 0xbf, 0xc7, 0x36, 0xd5, 0x60, 0x5a, 0xc7, 0x80, 0xac, 0x52, 0xf7, 0xa7, 0x2d, 0xb6,
0x64, 0xda, 0xda, 0x11, 0x8d, 0x6a, 0xe5, 0xba, 0xe9, 0xda, 0xdc, 0x62, 0xc3, 0xcb, 0xae, 0xc1, 0xe1, 0x11, 0xb6,
0x33, 0x29, 0x04, 0x09, 0xfe, 0x09, 0x08, 0xc2, 0x1f, 0x98, 0x16, 0x26, 0x0d, 0xce, 0xd7, 0x75, 0xfb, 0xd7, 0xa5,
0x82, 0xd7, 0x32, 0x44, 0x72, 0xc1, 0xc2, 0xe4, 0x15, 0xcb, 0x50, 0xfc, 0xd3, 0x58, 0x64, 0x34, 0x41, 0x32, 0xbe,
0x1f, 0x08, 0x43, 0x16, 0x14, 0xf7, 0x70, 0x2c, 0x1c, 0x61, 0x09, 0x78, 0x73, 0xd1, 0x30, 0xf6, 0xed, 0x85, 0x55,
0x50, 0x02, 0xf0, 0x68, 0x50, 0x5c, 0x1a, 0xd7, 0x3b, 0x8e, 0xf2, 0x23, 0xc8, 0x88, 0x39, 0xd0, 0x84, 0xf7, 0xa6,
0xd1, 0xa3, 0xfb, 0xe5, 0x44, 0xc0, 0x4c, 0x2f, 0x6f, 0x19, 0x70, 0x75, 0xf6, 0x1c, 0xb8, 0xce, 0x89, 0x4f, 0x01,
0x8d, 0xa1, 0x71, 0xf2, 0x97, 0xf8, 0xe7, 0xd3, 0xf1, 0xe1, 0xfa, 0xfc, 0xc8, 0x03, 0x78, 0x14, 0xa0, 0x3d, 0x28,
0xb7, 0xeb, 0x26, 0x52, 0x59, 0xa8, 0xb5, 0x0d, 0x5c, 0x8a, 0x6b, 0xe5, 0xf6, 0x30, 0xd6, 0xf7, 0x71, 0x31, 0xe8,
0xbd, 0xc9, 0xfa, 0xf5, 0x71, 0x9d, 0xff, 0xf6, 0x69, 0x62, 0x5f, 0xb5, 0xc7, 0x06, 0x43, 0x54, 0xb5, 0x87, 0xf1,
0xcc, 0x84, 0xe8, 0xb7, 0x56, 0x38, 0x43, 0x6a, 0xa6, 0x2f, 0x53, 0xd1, 0x89, 0x48, 0x8d, 0x72, 0x75, 0x4a, 0x17,
0xf6, 0x8d, 0xb2, 0xeb, 0xb1, 0x6b, 0x29, 0x1e, 0xd5, 0x74, 0xc7, 0xb3, 0xf4, 0xf1, 0x04, 0x0d, 0xbf, 0x08, 0x81,
0x8f, 0xfe, 0x8d, 0xfc, 0xf2, 0x35, 0x92, 0xa0, 0x24, 0x88, 0x12, 0x6a, 0xe6, 0x2f, 0x01, 0x94, 0x5c, 0x4b, 0xf9,
0x6b, 0x9a, 0xf6, 0x1a, 0x35, 0x11, 0x8a, 0xc6, 0x04, 0x8d, 0x50, 0x64, 0x48, 0x2d, 0x8b, 0xdf, 0xdf, 0xa1, 0xd1,
0xfd, 0x21, 0xd7, 0x40, 0x96, 0x00, 0xb1, 0x9f, 0x54, 0xc2, 0xe1, 0x00, 0x79, 0x15, 0x80, 0xf8, 0xca, 0x8e, 0xc5,
0x06, 0x03, 0xdf, 0x72, 0x49, 0xc0, 0x08, 0x27, 0x90, 0xe3, 0x14, 0xc2, 0xac, 0xc3, 0x83, 0xc9, 0xf6, 0x9d, 0x12,
0xab, 0x46, 0xae, 0x03, 0x74, 0x1d, 0x27, 0xce, 0x91, 0x1d, 0x4e, 0xb4, 0xbe, 0x80, 0xe7, 0x6a, 0x6d, 0x0a, 0x20,
0x47, 0x6b, 0x00, 0x4e, 0x25, 0x52, 0xe6, 0xf5, 0xe9, 0x43, 0x84, 0xc1, 0x0d, 0x4b, 0x04, 0xb3, 0x91, 0x49, 0xf5,
0xe9, 0xc4, 0x2e, 0x1b, 0xf9, 0x98, 0xf9, 0xea, 0x9e, 0xb8, 0xf6, 0x53, 0xf8, 0x42, 0xc2, 0x60, 0x5c, 0xa9, 0xd2,
0xfe, 0x0a, 0xe5, 0xd4, 0x7d, 0x36, 0x76, 0x04, 0x12, 0x38, 0xa1, 0xc4, 0x30, 0xb8, 0xf2, 0x69, 0x86, 0x5b, 0xe7,
0xe5, 0xa0, 0xc0, 0xfe, 0x91, 0x19, 0x03, 0x1e, 0xa9, 0x93, 0x26, 0x49, 0x58, 0xd5, 0xf6, 0x33, 0x4e, 0x0a, 0x89,
0x64, 0x1e, 0xb4, 0x7a, 0x5d, 0x0d, 0x12, 0xcf, 0x2b, 0xdd, 0x35, 0x90, 0x55, 0xc3, 0x0e, 0xcd, 0x0e, 0xad, 0x6b,
0x8f, 0x43, 0xd0, 0xb4, 0x6a, 0xdb, 0x4b, 0xe5, 0xa4, 0x9b, 0x09, 0x23, 0x1a, 0x43, 0xa9, 0xff, 0x61, 0x8b, 0x07,
0xd6, 0x0f, 0x83, 0x23, 0xbe, 0x8a, 0x66, 0xa2, 0x10, 0x2f, 0x11, 0x01, 0x0d, 0xc6, 0x96, 0xba, 0x87, 0xaa, 0xa8,
0x44, 0x7c, 0x1e, 0x34, 0xdb, 0xba, 0x41, 0x90, 0xf0, 0x7a, 0xdb, 0x63, 0x60, 0x96, 0x8d, 0x64, 0xcb, 0x2f, 0x28,
0x96, 0x04, 0xd5, 0xc0, 0x7a, 0xe8, 0xec, 0xd1, 0x61, 0x1b, 0x09, 0x4b, 0x4c, 0x6e, 0x1b, 0x31, 0x98, 0x9c, 0x6d,
0xdb, 0xd0, 0xec, 0xa4, 0x0f, 0x0a, 0xe0, 0xc8, 0x35, 0xc4, 0x93, 0xdc, 0xee, 0x19, 0x00, 0x80, 0x07, 0xf5, 0xcf,
0xe0, 0x7f, 0x75, 0xf8, 0x52, 0x3a, 0xfc, 0x0d, 0x84, 0xa5, 0xc1, 0xb2, 0x1c, 0x25, 0x40, 0xc5, 0x8b, 0xb3, 0xdb,
0x7a, 0x1b, 0x44, 0xff, 0x79, 0x9a, 0x26, 0xc4, 0xe7, 0x9e, 0x78, 0x4c, 0xa5, 0x94, 0xab, 0x78, 0x34, 0x9d, 0xb5,
0xb7, 0x24, 0x26, 0xe7, 0x9a, 0xf3, 0xe5, 0x2a, 0x35, 0x4b, 0xbe, 0x74, 0xd7, 0x01, 0xbc, 0x71, 0x72, 0x03, 0x5c,
0x40, 0x24, 0x17, 0x61, 0x5b, 0x7b, 0x19, 0xc2, 0x7f, 0x4e, 0x5b, 0x24, 0xfb, 0x8b, 0xc3, 0x51, 0x00, 0x3d, 0x09,
0x04, 0x05, 0x72, 0x64, 0xf6, 0xf0, 0x6b, 0x99, 0x38, 0x93, 0x5b, 0xac, 0x0c, 0xff, 0x40, 0x7b, 0x53, 0xba, 0xab,
0x61, 0xc9, 0xa2, 0xde, 0xd6, 0x2b, 0x0c, 0xbd, 0x85, 0xac, 0x4c, 0x64, 0x8b, 0xe6, 0x69, 0x2b, 0x56, 0x10, 0x1b,
0x08, 0x59, 0x6c, 0x55, 0x0a, 0xaa, 0x93, 0x85, 0x1d, 0x45, 0xda, 0xc6, 0x39, 0xe6, 0x6a, 0xab, 0x71, 0x73, 0x46,
0x0f, 0xf7, 0xab, 0x4e, 0x88, 0xae, 0x8c, 0xe0, 0x91, 0xda, 0x35, 0x8c, 0xd3, 0x18, 0x92, 0x3d, 0xab, 0x2f, 0x0d,
0xd6, 0xc9, 0x06, 0xdb, 0xc2, 0x62, 0xcb, 0x8a, 0x23, 0x5b, 0x28, 0x2e, 0x9b, 0x96, 0xc4, 0xc1, 0x42, 0xa9, 0x8d,
0x69, 0xe5, 0x8f, 0x89, 0x12, 0x11, 0x9a, 0x56, 0x3b, 0x3c, 0x2b, 0xb4, 0xb2, 0x7b, 0xfd, 0xdb, 0x80, 0x92, 0xb4,
0xca, 0x44, 0x37, 0x8c, 0x94, 0x2f, 0x4a, 0xb4, 0xc4, 0x28, 0x83, 0x8a, 0x78, 0xcb, 0xd2, 0x5c, 0x5c, 0x35, 0x29,
0x95, 0x35, 0x2f, 0x99, 0x28, 0x4f, 0x22, 0x18, 0x70, 0x85, 0xee, 0x2c, 0xb9, 0xef, 0x14, 0x57, 0x73, 0x23, 0x45,
0xae, 0xdc, 0x54, 0x56, 0x55, 0x78, 0x56, 0xad, 0x48, 0x26, 0x27, 0xbf, 0xcb, 0xd7, 0x54, 0xa9, 0xa8, 0xd7, 0xb5,
0x71, 0x9c, 0xe7, 0x79, 0x89, 0x5c, 0xa9, 0x6a, 0x53, 0xe8, 0xfa, 0x0d, 0x69, 0x36, 0xed, 0xad, 0x33, 0xc9, 0x75,
0x17, 0xe9, 0x8f, 0x31, 0x58, 0xa6, 0x27, 0xfc, 0x79, 0xc6, 0xb6, 0xd5, 0x65, 0x90, 0x31, 0xc6, 0x9d, 0x29, 0x95,
0x83, 0xe5, 0x4e, 0xbb, 0xd7, 0xdc, 0x8e, 0xd0, 0x3a, 0x54, 0x27, 0xce, 0xf5, 0xdb, 0xbd, 0x89, 0x3c, 0x61, 0xb3,
0x71, 0x23, 0xc7, 0x55, 0x9e, 0xea, 0x50, 0xe4, 0x37, 0xae, 0x72, 0x34, 0x66, 0xb9, 0x6e, 0x2c, 0x0a, 0x69, 0x8d,
0x94, 0x8b, 0x98, 0x08, 0x05, 0x3c, 0x45, 0x45, 0xe1, 0x6c, 0x22, 0xc5, 0x8f, 0x1f, 0x9f, 0x3f, 0xd2, 0x01, 0x12,
0xec, 0x86, 0x2f, 0x87, 0x8b, 0x47, 0x30, 0xd0, 0x77, 0x77, 0x1a, 0x13, 0x2d, 0xc6, 0x31, 0x65, 0x77, 0xd8, 0x8c,
0xe7, 0xe0, 0x16, 0xf9, 0x4b, 0xd4, 0xc5, 0xa8, 0x67, 0x79, 0xe7, 0xc3, 0x07, 0x94, 0x46, 0xa3, 0x8c, 0x67, 0xd3,
0xff, 0x5f, 0x96, 0xfa, 0xed, 0xa7, 0xd3, 0xdd, 0x4f, 0x27, 0x49, 0x27, 0xcf, 0xa4, 0x02, 0xc3, 0x27, 0x3c, 0x96,
0x64, 0x24, 0x55, 0xc8, 0x36, 0x45, 0x68, 0x90, 0xea, 0x03, 0x0b, 0x46, 0xa7, 0x8d, 0xb4, 0x26, 0x91, 0x07, 0x4c,
0x80, 0x7b, 0x93, 0xa8, 0x10, 0xcb, 0x5e, 0x8d, 0x42, 0x86, 0x3e, 0x2a, 0x7c, 0x99, 0x79, 0x8e, 0xcb, 0x43, 0x28};
// Backwards compatibility alias
#define INDEX_GZ INDEX_BR
@@ -4,6 +4,7 @@
#include "esphome/core/application.h"
#include "esphome/components/wifi/wifi_component.h"
#include "captive_index.h"
#include "json_escape.h"
namespace esphome::captive_portal {
@@ -24,23 +25,30 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) {
stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str());
#endif
for (auto &scan : wifi::global_wifi_component->get_scan_result()) {
if (scan.get_is_hidden())
continue;
// An SSID can contain a " or \ that would break the JSON, so escape it before writing it out. An SSID is at most
// 32 bytes (IEEE 802.11), so this is large enough that nothing is ever dropped. Reused for every scan result.
char escaped_ssid[32 * JSON_ESCAPE_MAX_EXPANSION + 1];
{
// Invariant: only bounded in-memory work under the lock; the network send
// happens later in request->send()
wifi::ScanResultsLock lock(wifi::global_wifi_component);
for (const auto &scan : wifi::global_wifi_component->get_scan_result()) {
if (scan.get_is_hidden())
continue;
// Assumes no " in ssid, possible unicode isses?
json_escape_into_buffer(escaped_ssid, scan.get_ssid());
#ifdef USE_ESP8266
stream->print(ESPHOME_F(",{\"ssid\":\""));
stream->print(scan.get_ssid().c_str());
stream->print(ESPHOME_F("\",\"rssi\":"));
stream->print(scan.get_rssi());
stream->print(ESPHOME_F(",\"lock\":"));
stream->print(scan.get_with_auth());
stream->print(ESPHOME_F("}"));
stream->print(ESPHOME_F(",{\"ssid\":\""));
stream->print(escaped_ssid);
stream->print(ESPHOME_F("\",\"rssi\":"));
stream->print(scan.get_rssi());
stream->print(ESPHOME_F(",\"lock\":"));
stream->print(scan.get_with_auth());
stream->print(ESPHOME_F("}"));
#else
stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(),
scan.get_with_auth());
stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth());
#endif
}
}
stream->print(ESPHOME_F("]}"));
request->send(stream);
@@ -0,0 +1,85 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <span>
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
namespace esphome::captive_portal {
/// Largest number of output bytes a single input byte can expand to (a \u00XX sequence).
static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6;
/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal.
///
/// Escapes " and \ along with the control characters below 0x20, using the short forms where JSON defines one and
/// \u00XX otherwise. Bytes >= 0x20 are copied verbatim, so text containing valid UTF-8 survives intact. The result is
/// always null terminated; anything that would not fit is dropped rather than written partially. Returns buf so the
/// call can be used directly as an argument.
///
/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for
/// the null terminator.
inline const char *json_escape_into_buffer(std::span<char> buf, StringRef value) {
if (buf.empty())
return "";
// Reserve one byte for the null terminator.
const size_t limit = buf.size() - 1;
size_t pos = 0;
for (char ch : value) {
auto c = static_cast<unsigned char>(ch);
// Every short form is a backslash followed by a single character, so only that character is needed here. Keeping
// it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266.
char escape = '\0';
switch (c) {
case '"':
escape = '"';
break;
case '\\':
escape = '\\';
break;
case '\n':
escape = 'n';
break;
case '\r':
escape = 'r';
break;
case '\t':
escape = 't';
break;
case '\b':
escape = 'b';
break;
case '\f':
escape = 'f';
break;
default:
break;
}
if (escape != '\0') {
if (pos + 2 > limit)
break;
buf[pos++] = '\\';
buf[pos++] = escape;
} else if (c < 0x20) {
// Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so
// the two high hex digits are always zero.
if (pos + JSON_ESCAPE_MAX_EXPANSION > limit)
break;
buf[pos++] = '\\';
buf[pos++] = 'u';
buf[pos++] = '0';
buf[pos++] = '0';
buf[pos++] = format_hex_char(static_cast<uint8_t>(c >> 4));
buf[pos++] = format_hex_char(static_cast<uint8_t>(c & 0x0F));
} else {
if (pos + 1 > limit)
break;
buf[pos++] = static_cast<char>(c);
}
}
buf[pos] = '\0';
return buf.data();
}
} // namespace esphome::captive_portal
+3 -3
View File
@@ -145,9 +145,9 @@ float Emc2101Component::get_external_temperature() {
return NAN;
}
// join msb and lsb (5 least significant bits are not used)
uint16_t raw = (msb << 8 | lsb) >> 5;
return raw * 0.125;
// join msb and lsb (5 least significant bits are not used); msb is signed, so read as int16_t
int16_t raw = static_cast<int16_t>((msb << 8) | lsb) >> 5;
return raw * 0.125f;
}
float Emc2101Component::get_speed() {
@@ -15,7 +15,7 @@ class EpaperModel:
self,
name: str,
class_name: str,
initsequence=None,
initsequence=(),
**defaults,
):
name = name.upper()
+18 -16
View File
@@ -628,7 +628,6 @@ class NetworkSdkconfigData:
wifi_ap: bool = False # WiFi AP mode configured
ethernet: bool = False # Ethernet component active
bluetooth: bool = False # any BLE component active
ble_42: bool = False # BLE 4.2 features needed
software_coexistence: bool = False # WiFi/BT software coexistence requested
# esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset)
enable_lwip_dhcp_server: bool | None = None
@@ -654,12 +653,10 @@ def request_ethernet() -> None:
_network_sdkconfig().ethernet = True
def request_bluetooth(ble_42: bool = False) -> None:
"""Request the Bluetooth controller. Pass ble_42=True for 4.2 features."""
def request_bluetooth() -> None:
"""Request the Bluetooth controller."""
net = _network_sdkconfig()
net.bluetooth = True
if ble_42:
net.ble_42 = True
def request_software_coexistence() -> None:
@@ -814,14 +811,15 @@ def _is_framework_url(source: str) -> bool:
# The default/recommended arduino framework version
# - https://github.com/espressif/arduino-esp32/releases
ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(3, 3, 9),
"latest": cv.Version(3, 3, 9),
"dev": cv.Version(3, 3, 9),
"recommended": cv.Version(3, 3, 10),
"latest": cv.Version(3, 3, 10),
"dev": cv.Version(3, 3, 10),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
cv.Version(
4, 0, 0, "alpha1"
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(3, 3, 10): cv.Version(55, 3, 39),
cv.Version(3, 3, 9): cv.Version(55, 3, 39),
cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"),
cv.Version(3, 3, 7): cv.Version(55, 3, 37),
@@ -844,6 +842,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
# See: https://github.com/pioarduino/esp-idf/releases
ARDUINO_IDF_VERSION_LOOKUP = {
cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1),
cv.Version(3, 3, 10): cv.Version(5, 5, 5),
cv.Version(3, 3, 9): cv.Version(5, 5, 4),
cv.Version(3, 3, 8): cv.Version(5, 5, 4),
cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"),
@@ -865,9 +864,9 @@ ARDUINO_IDF_VERSION_LOOKUP = {
# The default/recommended esp-idf framework version
# - https://github.com/espressif/esp-idf/releases
ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(5, 5, 4),
"latest": cv.Version(5, 5, 4),
"dev": cv.Version(5, 5, 4),
"recommended": cv.Version(5, 5, 5),
"latest": cv.Version(5, 5, 5),
"dev": cv.Version(5, 5, 5),
}
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
@@ -877,6 +876,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(
6, 0, 0
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(5, 5, 5): cv.Version(55, 3, 39),
cv.Version(5, 5, 4): cv.Version(55, 3, 39),
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
cv.Version(5, 5, 3): cv.Version(55, 3, 37),
@@ -1026,7 +1026,9 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
def _validate_toolchain(value) -> Toolchain:
return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value))
return Toolchain(
cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value)
)
def _resolve_toolchain(value: ConfigType) -> ConfigType:
@@ -2041,12 +2043,12 @@ async def _reconcile_network_sdkconfig() -> None:
if name not in opts:
add_idf_sdkconfig_option(name, value)
# Bluetooth: only ever enable when requested. The IDF default is off and
# nothing sets these False today, so never write False here.
# Bluetooth: only ever enable when requested. The IDF default is off.
# According to the IDF docs, only one of 4.2 or 5.0 should be enabled.
if net.bluetooth:
set_opt("CONFIG_BT_ENABLED", True)
if net.ble_42:
set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
set_opt("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", False)
# WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi
# relies on the IDF default (enabled), so it is never written True here.
+1 -1
View File
@@ -604,7 +604,7 @@ async def to_code(config):
max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS)
cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections)
request_bluetooth(ble_42=True)
request_bluetooth()
# When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for
# heap allocations and use dynamic (heap-based) environment memory tables
+1
View File
@@ -58,6 +58,7 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000;
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: \
case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT
+4 -1
View File
@@ -207,7 +207,7 @@ class BLEEvent {
StatusOnlyData scan_complete; // 1 byte
// Advertising complete events all have same structure
// Used by: esp32_ble_beacon, esp32_ble server components
// ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, ADV_START, ADV_STOP
// ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, SCAN_RSP_DATA_RAW_SET, ADV_START, ADV_STOP
StatusOnlyData adv_complete; // 1 byte
// RSSI complete event
// Used by: ble_client (ble_rssi_sensor component)
@@ -324,6 +324,9 @@ class BLEEvent {
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: // Used by: esp32_ble_beacon
this->event_.gap.adv_complete.status = p->adv_data_raw_cmpl.status;
break;
case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: // Used by: raw advertisers with scan response
this->event_.gap.adv_complete.status = p->scan_rsp_data_raw_cmpl.status;
break;
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: // Used by: esp32_ble_beacon
this->event_.gap.adv_complete.status = p->adv_start_cmpl.status;
break;
@@ -86,4 +86,4 @@ async def to_code(config):
cg.add_define("USE_ESP32_BLE_ADVERTISING")
request_bluetooth(ble_42=True)
request_bluetooth()
+35 -2
View File
@@ -1,5 +1,6 @@
import logging
from pathlib import Path
import platform
import re
import subprocess
@@ -20,9 +21,15 @@ from esphome.const import (
PLATFORM_ESP8266,
ThreadModel,
)
from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority
from esphome.core import (
CORE,
CoroPriority,
EsphomeError,
Lambda,
coroutine_with_priority,
)
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed
from esphome.helpers import IS_MACOS, copy_file_if_changed
from esphome.types import ConfigType
from .boards import BOARDS, ESP8266_LD_SCRIPTS
@@ -237,6 +244,32 @@ CONFIG_SCHEMA = cv.All(
)
def check_rosetta() -> None:
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
There is no native arm64 build of the xtensa-lx106 toolchain; on Apple
Silicon it runs under Rosetta 2, which macOS updates can remove.
"""
if not IS_MACOS or platform.machine() != "arm64":
return
try:
result = subprocess.run(
["/usr/bin/arch", "-x86_64", "/usr/bin/true"],
capture_output=True,
close_fds=False,
check=False,
)
except OSError:
return # arch(1) unavailable; let the build proceed
if result.returncode != 0:
raise EsphomeError(
"ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) "
"compiler that requires Rosetta 2, which is not installed on "
"this system. Install it with:\n"
" softwareupdate --install-rosetta --agree-to-license"
)
@coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config):
cg.add(esp8266_ns.setup_preferences())
+42 -31
View File
@@ -433,37 +433,48 @@ GENERIC_SCHEMA = cv.All(
cv.only_on([Platform.ESP32]),
)
SPI_SCHEMA = cv.All(
BASE_SCHEMA.extend(
cv.Schema(
{
cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number,
cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number,
cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
cv.SplitDefault(CONF_CLOCK_SPEED, esp32="26.67MHz"): cv.All(
cv.only_on_esp32,
cv.frequency,
cv.int_range(int(8e6), int(80e6)),
),
cv.Optional(CONF_INTERFACE): cv.All(
cv.only_on_esp32,
cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True),
),
# Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate()
cv.Optional(CONF_POLLING_INTERVAL): cv.All(
cv.only_on_esp32,
cv.positive_time_period_milliseconds,
cv.Range(min=TimePeriodMilliseconds(milliseconds=1)),
),
}
def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)):
return cv.All(
BASE_SCHEMA.extend(
cv.Schema(
{
cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number,
cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(
CONF_INTERRUPT_PIN
): pins.internal_gpio_input_pin_number,
cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
cv.SplitDefault(CONF_CLOCK_SPEED, esp32=default_clock): cv.All(
cv.only_on_esp32,
cv.frequency,
cv.int_range(int(8e6), max_clock),
),
cv.Optional(CONF_INTERFACE): cv.All(
cv.only_on_esp32,
cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True),
),
# Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate()
cv.Optional(CONF_POLLING_INTERVAL): cv.All(
cv.only_on_esp32,
cv.positive_time_period_milliseconds,
cv.Range(min=TimePeriodMilliseconds(milliseconds=1)),
),
}
),
),
),
cv.only_on([Platform.ESP32, Platform.RP2]),
_validate_spi_interface,
)
cv.only_on([Platform.ESP32, Platform.RP2]),
_validate_spi_interface,
)
SPI_SCHEMA = _spi_schema()
# The ENC28J60's SCK maximum is 20 MHz, so the shared 26.67 MHz default is out
# of spec for it and makes the driver's CS hold time helper compute no hold
SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6))
CONFIG_SCHEMA = cv.All(
cv.typed_schema(
@@ -479,7 +490,7 @@ CONFIG_SCHEMA = cv.All(
"W5500": SPI_SCHEMA,
"OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])),
"DM9051": SPI_SCHEMA,
"ENC28J60": SPI_SCHEMA,
"ENC28J60": SPI_SCHEMA_ENC28J60,
"W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
"W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
"LAN8670": RMII_SCHEMA,
@@ -232,8 +232,10 @@ void EthernetComponent::ethernet_lazy_init_() {
dm9051_config.poll_period_ms = this->polling_interval_;
#endif
#elif defined(USE_ETHERNET_ENC28J60)
// ENC28J60 does not support poll_period_ms. CS must stay asserted for the chip's CS hold
// time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID")
enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000);
enc28j60_config.int_gpio_num = this->interrupt_pin_;
// ENC28J60 does not support poll_period_ms
#endif
phy_config.phy_addr = this->phy_addr_spi_;
+1 -1
View File
@@ -825,7 +825,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *
#ifdef USE_SENSOR
this->update_sub_sensor_(SubSensorType::INDOOR_COIL_TEMPERATURE, bd_packet->indoor_coil_temperature / 2.0 - 20);
this->update_sub_sensor_(SubSensorType::OUTDOOR_COIL_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_defrost_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_IN_AIR_TEMPERATURE, bd_packet->outdoor_in_air_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_OUT_AIR_TEMPERATURE, bd_packet->outdoor_out_air_temperature - 64);
this->update_sub_sensor_(SubSensorType::POWER, encode_uint16(bd_packet->power[0], bd_packet->power[1]));
@@ -488,10 +488,10 @@ template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...
body = this->body_.value(x...);
}
if (!this->json_.empty()) {
body = json::build_json([this, x...](JsonObject root) { this->encode_json_(x..., root); });
body = json::build_json([this, x...](JsonObject root) mutable { this->encode_json_(x..., root); });
}
if (this->json_func_ != nullptr) {
body = json::build_json([this, x...](JsonObject root) { this->json_func_(x..., root); });
body = json::build_json([this, x...](JsonObject root) mutable { this->json_func_(x..., root); });
}
std::vector<Header> request_headers;
request_headers.reserve(this->request_headers_.size());
@@ -19,11 +19,6 @@ namespace esphome::http_request {
static const char *const TAG = "http_request.idf";
static constexpr uint32_t ERROR_DURATION_MS = 1000;
struct UserData {
const std::vector<std::string> &lower_case_collect_headers;
std::vector<Header> &response_headers;
};
void HttpRequestIDF::dump_config() {
HttpRequestComponent::dump_config();
ESP_LOGCONFIG(TAG,
@@ -34,15 +29,15 @@ void HttpRequestIDF::dump_config() {
}
esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) {
UserData *user_data = (UserData *) evt->user_data;
auto *container = (HttpContainerIDF *) evt->user_data;
switch (evt->event_id) {
case HTTP_EVENT_ON_HEADER: {
const std::string header_name = str_lower_case(evt->header_key); // NOLINT
if (should_collect_header(user_data->lower_case_collect_headers, header_name)) {
if (should_collect_header(container->collect_headers_, header_name)) {
const std::string header_value = evt->header_value;
ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str());
user_data->response_headers.push_back({header_name, header_value});
container->response_headers_.push_back({header_name, header_value});
}
break;
}
@@ -124,8 +119,8 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
container->set_secure(secure);
auto user_data = UserData{lower_case_collect_headers, container->response_headers_};
esp_http_client_set_user_data(client, static_cast<void *>(&user_data));
container->collect_headers_ = lower_case_collect_headers;
esp_http_client_set_user_data(client, static_cast<void *>(container.get()));
for (const auto &header : request_headers) {
esp_http_client_set_header(client, header.name.c_str(), header.value.c_str());
@@ -24,6 +24,8 @@ class HttpContainerIDF : public HttpContainer {
protected:
friend class HttpRequestIDF;
esp_http_client_handle_t client_;
// Owned copy (not a reference): must outlive perform() for the response-header event handler
std::vector<std::string> collect_headers_;
};
class HttpRequestIDF final : public HttpRequestComponent {
+17 -8
View File
@@ -219,14 +219,23 @@ LightColorValues LightCall::validate_() {
this->set_flag_(FLAG_HAS_STATE);
}
// Make sure a turn-on makes the light visible: if the resulting brightness would be zero
// (e.g. restored from a brightness=0 turn-off), reset it to full brightness.
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) {
float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness();
if (brightness == 0.0f) {
this->brightness_ = 1.0f;
this->set_flag_(FLAG_HAS_BRIGHTNESS);
}
// A light without brightness control has no way to represent "on but dark", so zero
// brightness -- how effects encode their dark phase -- means the light is off. Clear the
// brightness as well, so a zero can't linger in remote_values and leave the light stuck
// off: a later turn-on can't heal it, because the capability check below drops any
// brightness this mode doesn't support. explicit_turn_off_request was captured above, so
// a running effect is not stopped by this.
if (this->has_brightness() && this->brightness_ == 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) {
this->state_ = false;
this->set_flag_(FLAG_HAS_STATE);
this->clear_flag_(FLAG_HAS_BRIGHTNESS);
}
// Make sure a simple (no specific brightness) turn-on makes the light visible
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() &&
this->parent_->remote_values.get_brightness() == 0.0f) {
this->brightness_ = 1.0f;
this->set_flag_(FLAG_HAS_BRIGHTNESS);
}
// Set color brightness to 100% if currently zero and a color is set.
+8
View File
@@ -71,6 +71,14 @@ void LightState::setup() {
break;
}
// A light coming up on boot must never end up on-but-invisible: if the resolved restore
// state is on but its brightness is zero (e.g. a stale/persisted value from before a
// forced-on restore mode, or an inverted restore flipping a dim-to-0 off state to on),
// reset it to full brightness.
if (recovered.state && recovered.brightness == 0.0f) {
recovered.brightness = 1.0f;
}
call.set_color_mode_if_supported(recovered.color_mode);
call.set_state(recovered.state);
call.set_brightness_if_supported(recovered.brightness);
+107 -25
View File
@@ -2,12 +2,15 @@ import hashlib
import json
import logging
from pathlib import Path
import re
from urllib.parse import urljoin
from esphome import automation, external_files, git
from esphome.automation import register_action, register_condition
from esphome.bundle import add_bundle_file
import esphome.codegen as cg
from esphome.components import esp32, microphone, ota, psram
from esphome.components.http_request import validate_url
import esphome.config_validation as cv
from esphome.const import (
CONF_FILE,
@@ -28,6 +31,7 @@ from esphome.const import (
TYPE_LOCAL,
)
from esphome.core import CORE, HexInt
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -207,40 +211,62 @@ def _validate_manifest_version(manifest_data):
raise cv.Invalid("Invalid manifest file, missing 'version' key")
def _process_http_source(config):
url = config[CONF_URL]
path = _compute_local_file_path(config)
HTTP_SCHEMA = cv.Schema(
{
# validate_url only accepts http(s); the shorthand validator relies
# on this branch rejecting git shorthands ("github://...") so they
# fall through to the git branch.
cv.Required(CONF_URL): validate_url,
}
)
json_path = path / "manifest.json"
json_contents = external_files.download_content(url, json_path)
def _register_local_model_file(config: ConfigType) -> ConfigType:
"""Register the model file that the manifest points to, so bundles include it.
manifest_data = json.loads(json_contents)
if not isinstance(manifest_data, dict):
raise cv.Invalid("Manifest file must contain a JSON object")
model = manifest_data[CONF_MODEL]
model_url = urljoin(url, model)
model_path = path / model
external_files.download_content(str(model_url), model_path)
The manifest names its model file relative to itself, so that path never appears
in the YAML and bundle discovery cannot find it on its own.
Problems with the manifest are logged and ignored here rather than raised. Loading
the manifest later reports them with better messages, and raising would be
swallowed by the shorthand validator, which then reports a confusing error about a
missing file in a git repository. Logging keeps the skipped registration
diagnosable if the manifest is only briefly unreadable, since the bundle would
then be built without the model file.
"""
manifest_path: Path = config[CONF_PATH]
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
model = manifest[CONF_MODEL]
except (OSError, ValueError, KeyError, TypeError) as err:
_LOGGER.debug("Not registering a model file from %s: %s", manifest_path, err)
return config
if not isinstance(model, str):
_LOGGER.debug(
"Not registering a model file from %s: 'model' is %s, expected a string",
manifest_path,
type(model).__name__,
)
return config
add_bundle_file(manifest_path.parent / model)
return config
HTTP_SCHEMA = cv.All(
{
cv.Required(CONF_URL): cv.url,
},
_process_http_source,
LOCAL_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_),
}
),
_register_local_model_file,
)
LOCAL_SCHEMA = cv.Schema(
{
cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_),
}
)
# Bare model names in the official model repository ("okay_nabu"). Must not
# overlap with local paths, http(s) urls, or git shorthands
# ("github://user/repo/file.json@ref"), which the shorthand validator tries
# next; anything containing "/", ":" or "@" is not a model name.
_MODEL_NAME_RE = re.compile(r"[A-Za-z0-9_.-]+")
def _validate_source_model_name(value):
@@ -250,6 +276,9 @@ def _validate_source_model_name(value):
if value.endswith(".json"):
raise cv.Invalid("Model name must not end with .json")
if not _MODEL_NAME_RE.fullmatch(value):
raise cv.Invalid("Model name may only contain letters, numbers, . _ -")
return MODEL_SOURCE_SCHEMA(
{
CONF_TYPE: TYPE_HTTP,
@@ -339,6 +368,58 @@ def _maybe_empty_vad_schema(value):
return VAD_MODEL_SCHEMA(value)
def _download_http_models(config: ConfigType) -> ConfigType:
"""Download every http-sourced manifest and model file in two concurrent
batches (all manifests, then all model files).
The model file's URL only becomes known once its manifest has been
fetched and parsed, so the two stages cannot be merged into one batch.
"""
model_parameters = [*config[CONF_MODELS]]
if vad := config.get(CONF_VAD):
model_parameters.append(vad)
# Keyed by cache path so a URL referenced twice is fetched and parsed once
http_models: dict[Path, str] = {
_compute_local_file_path(model_config): model_config[CONF_URL]
for parameters in model_parameters
if (model_config := parameters.get(CONF_MODEL)) is not None
and model_config.get(CONF_TYPE) == TYPE_HTTP
}
if not http_models:
return config
external_files.download_content_many(
((url, path / "manifest.json") for path, url in http_models.items()),
description="wake word manifest(s)",
)
model_files: list[tuple[str, Path]] = []
errors: list[cv.Invalid] = []
for path, url in http_models.items():
try:
manifest_data = json.loads((path / "manifest.json").read_bytes())
except (OSError, ValueError) as e:
errors.append(cv.Invalid(f"Invalid manifest file at {url}: {e}"))
continue
if not isinstance(manifest_data, dict):
errors.append(
cv.Invalid(f"Manifest file at {url} must contain a JSON object")
)
continue
model = manifest_data.get(CONF_MODEL)
if not isinstance(model, str):
errors.append(
cv.Invalid(f"Manifest file at {url} is missing the 'model' key")
)
continue
model_files.append((urljoin(url, model), path / model))
if errors:
raise cv.MultipleInvalid(errors)
external_files.download_content_many(model_files, description="wake word model(s)")
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -372,6 +453,7 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
_download_http_models,
)
+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();
+11 -1
View File
@@ -69,7 +69,12 @@ from .const import (
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
)
from .framework import check_and_install, get_build_env, get_build_paths
from .framework import (
check_and_install,
get_build_env,
get_build_paths,
setup_platformio_python_env,
)
# force import gpio to register pin schema
from .gpio import nrf52_pin_to_code # noqa: F401
@@ -514,6 +519,7 @@ def _upload_using_platformio(
) -> int | str:
from esphome.platformio import toolchain
setup_platformio_python_env()
if port is not None:
upload_args += ["--upload-port", port]
return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args)
@@ -809,6 +815,10 @@ def _copy_if_exists(src: Path, dst: Path) -> None:
def run_compile(args, config: ConfigType) -> bool:
if CORE.using_toolchain_platformio:
# The actual build is done by PlatformIO (the caller falls through to
# it when this returns False); prepare the Python environment its
# Zephyr build script expects first.
setup_platformio_python_env()
return False
if not CORE.using_toolchain_sdk_nrf:
raise EsphomeError(
+91 -1
View File
@@ -4,6 +4,7 @@ import os
from pathlib import Path
import platform
import shutil
import sys
import tempfile
import platformdirs
@@ -27,6 +28,11 @@ _LOGGER = logging.getLogger(__name__)
_REQUIREMENTS = Path(__file__).parent / "requirements.txt"
TOOLCHAIN_VERSION = "0.17.4"
# Packages the PlatformIO toolchain's Zephyr build script needs beyond west
# (which comes from requirements.txt). Keep the pin in sync with
# framework-sdk-nrf scripts/platformio/platformio-build.py.
_PLATFORMIO_PENV_REQUIREMENTS: tuple[str, ...] = ("cbor2==5.6.5",)
SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
os.environ.get(
"ESPHOME_SDK_NG_TOOLCHAIN_MIRRORS",
@@ -133,10 +139,94 @@ def get_build_env() -> dict:
env = os.environ.copy()
env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "")
env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr")
env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION) / "cmake")
# ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr documents for pointing at
# the SDK: FindZephyr-sdk.cmake reads it (from the environment, via
# zephyr_get) and passes it straight to find_package as a HINT. This
# matters because the SDK lives in the esphome cache dir, which is not on
# the module's static search path (/usr, /opt, $HOME, ...). A generic
# "Zephyr-sdk_DIR" environment hint proved unreliable here: containerized
# non-root builds failed to locate the SDK with it, while
# ZEPHYR_SDK_INSTALL_DIR fixed the same invocation.
env["ZEPHYR_SDK_INSTALL_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION))
return env
def _get_platformio_penv_path() -> Path:
return get_sdk_nrf_tools_path() / "penvs" / "platformio"
def _get_penv_site_packages(penv_path: Path) -> Path:
if os.name == "nt":
return penv_path / "Lib" / "site-packages"
python_dir = f"python{sys.version_info.major}.{sys.version_info.minor}"
return penv_path / "lib" / python_dir / "site-packages"
def _prepend_env_path(name: str, entry: str) -> None:
"""Prepend ``entry`` to the ``os.pathsep``-separated env var ``name``."""
current = os.environ.get(name, "")
entries = current.split(os.pathsep) if current else []
if entry not in entries:
os.environ[name] = os.pathsep.join([entry, *entries])
def setup_platformio_python_env() -> None:
"""Make the Zephyr build's Python packages available to PlatformIO.
The PlatformIO toolchain's Zephyr framework build script pip-installs
west and cbor2 (and pyocd on x86_64) into the Python environment running
PlatformIO whenever they are not importable. That environment is not
always writable for example the docker image run as a non-root user,
where ESPHome lives in the system Python so the install fails with
"Permission denied". Instead, pre-install those packages into a dedicated
venv under the sdk-nrf tools dir and expose it to the PlatformIO
subprocesses through the environment:
* PYTHONPATH makes the venv's packages importable from the interpreter
that runs PlatformIO/SCons, so the build script skips its installs.
* VIRTUAL_ENV redirects any install the build script still performs via
uv (pyocd is fetched on demand) into the writable venv.
* PATH exposes console scripts installed into the venv (e.g. pyocd).
"""
penv_path = _get_platformio_penv_path()
env_python_path = get_python_env_executable_path(penv_path, "python")
sentinel = penv_path / ".ready"
# Include the Python version: the venv breaks when the interpreter it
# was created from is upgraded, so it must be rebuilt.
requirements_hash = hashlib.sha256(
_REQUIREMENTS.read_bytes()
+ "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode()
+ f"python{sys.version_info.major}.{sys.version_info.minor}".encode()
).hexdigest()
if (
not sentinel.exists()
or sentinel.read_text(encoding="utf-8") != requirements_hash
):
rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment")
create_venv(penv_path, msg="PlatformIO toolchain")
_LOGGER.info("Installing PlatformIO toolchain requirements ...")
cmd = [
str(env_python_path),
"-m",
"pip",
"install",
"-r",
str(_REQUIREMENTS),
*_PLATFORMIO_PENV_REQUIREMENTS,
]
if not run_command_ok(cmd):
raise EsphomeError(
"Install requirements for PlatformIO toolchain Python environment failure"
)
sentinel.write_text(requirements_hash, encoding="utf-8")
os.environ["VIRTUAL_ENV"] = str(penv_path)
_prepend_env_path("PYTHONPATH", str(_get_penv_site_packages(penv_path)))
_prepend_env_path("PATH", str(env_python_path.parent))
def _patch_uf2conv_escape_sequences(framework_path: Path) -> None:
# SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that
# Python 3.12+ flags with SyntaxWarning (a future version will reject it).
+20 -2
View File
@@ -1,6 +1,7 @@
from collections import UserDict
from collections.abc import Callable
from functools import reduce
import logging
from pathlib import Path
from typing import Any
@@ -35,6 +36,8 @@ from esphome.const import (
)
from esphome.core import EsphomeError
_LOGGER = logging.getLogger(__name__)
DOMAIN = CONF_PACKAGES
# Guard against infinite include chains (e.g. A includes B includes A).
MAX_INCLUDE_DEPTH = 20
@@ -267,8 +270,23 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
# If loading fails, the cached checkout may be stale — revert and retry once.
try:
return {CONF_PACKAGES: get_packages(files)}
except cv.Invalid:
revert()
except cv.Invalid as err:
if not revert():
# The pre-update content is out of reach (lock timeout, the
# checkout moved, or the reset failed; see the log), so a
# retry could not see it.
raise cv.Invalid(
f"Failed to load packages and could not revert the cached "
f"checkout to retry. {err}",
path=err.path,
) from err
# If the retry succeeds this is the only trace that the
# refreshed upstream content was broken.
_LOGGER.warning(
"Loading packages failed (%s), reverted the cached checkout "
"and retrying",
err,
)
try:
return {CONF_PACKAGES: get_packages(files)}
except cv.Invalid as err:
+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);
}
}
+10 -10
View File
@@ -41,17 +41,17 @@ void I2CSSD1306::command(uint8_t value) { this->write_byte(0x00, value); }
void HOT I2CSSD1306::write_display_data() {
if (this->is_sh1106_() || this->is_sh1107_()) {
uint32_t i = 0;
// Some panels wire their visible columns to a window of the controller RAM
// that does not start at column 0 (e.g. SH1107 M5Stack Unit OLED needs offset_x: 32).
// SH1106 keeps its historical 0x02 base column on top of any offset.
uint8_t start_column = this->offset_x_;
if (this->is_sh1106_()) {
start_column += 0x02;
}
for (uint8_t page = 0; page < (uint8_t) this->get_height_internal() / 8; page++) {
this->command(0xB0 + page); // row
if (this->is_sh1106_()) {
this->command(0x02); // lower column - 0x02 is historical SH1106 value
} else {
// Other SH1107 drivers use 0x00
// Column values dont change and it seems they can be set only once,
// but we follow SH1106 implementation and resend them
this->command(0x00);
}
this->command(0x10); // higher column
this->command(0xB0 + page); // row
this->command(start_column & 0x0F); // lower column
this->command(0x10 | (start_column >> 4)); // higher column
for (uint8_t x = 0; x < (uint8_t) this->get_width_internal() / 16; x++) {
uint8_t data[16];
for (uint8_t &j : data)
@@ -38,14 +38,17 @@ void SPISSD1306::command(uint8_t value) {
}
void HOT SPISSD1306::write_display_data() {
if (this->is_sh1106_() || this->is_sh1107_()) {
// Some panels wire their visible columns to a window of the controller RAM
// that does not start at column 0 (e.g. SH1107 M5Stack Unit OLED needs offset_x: 32).
// SH1106 keeps its historical 0x02 base column on top of any offset.
uint8_t start_column = this->offset_x_;
if (this->is_sh1106_()) {
start_column += 0x02;
}
for (uint8_t y = 0; y < (uint8_t) this->get_height_internal() / 8; y++) {
this->command(0xB0 + y);
if (this->is_sh1106_()) {
this->command(0x02);
} else {
this->command(0x00);
}
this->command(0x10);
this->command(start_column & 0x0F); // lower column
this->command(0x10 | (start_column >> 4)); // higher column
this->dc_pin_->digital_write(true);
for (uint8_t x = 0; x < (uint8_t) this->get_width_internal(); x++) {
this->enable();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -381,16 +381,6 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code
#ifdef USE_WEBSERVER_AUTH_DIGEST
namespace {
// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated.
void bytes_to_hex(const uint8_t *data, size_t len, char *out) {
static const char HEX[] = "0123456789abcdef";
for (size_t i = 0; i < len; i++) {
out[i * 2] = HEX[data[i] >> 4];
out[i * 2 + 1] = HEX[data[i] & 0x0f];
}
out[len * 2] = '\0';
}
// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated
// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent.
// Only whole parameter names match, so "nc" does not match inside "cnonce".
@@ -468,7 +458,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, password, strlen(password));
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), ha1);
format_hex_to(ha1, digest, sizeof(digest));
// HA2 = MD5(method:uri) -- uses the uri the client echoed back.
char ha2[33];
@@ -477,7 +467,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, uri.c_str(), uri.size());
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), ha2);
format_hex_to(ha2, digest, sizeof(digest));
// expected = MD5(HA1:nonce:nc:cnonce:qop:HA2)
char expected[33];
@@ -494,7 +484,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, ha2, 32);
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), expected);
format_hex_to(expected, digest, sizeof(digest));
// Constant-time comparison of the two 32-char hex digests.
uint8_t result = 0;
@@ -592,9 +582,9 @@ void AsyncWebServerRequest::requestAuthentication() const {
char opaque[33];
char header[160];
esp_fill_random(random_bytes, sizeof(random_bytes));
bytes_to_hex(random_bytes, sizeof(random_bytes), nonce);
format_hex_to(nonce, random_bytes, sizeof(random_bytes));
esp_fill_random(random_bytes, sizeof(random_bytes));
bytes_to_hex(random_bytes, sizeof(random_bytes), opaque);
format_hex_to(opaque, random_bytes, sizeof(random_bytes));
snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce,
opaque);
httpd_resp_set_hdr(*this, "WWW-Authenticate", header);
+16
View File
@@ -818,6 +818,7 @@ IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners"
SCAN_RESULTS_LISTENERS_KEY = "wifi_scan_results_listeners"
CONNECT_STATE_LISTENERS_KEY = "wifi_connect_state_listeners"
POWER_SAVE_LISTENERS_KEY = "wifi_power_save_listeners"
SCAN_RESULTS_LOCK_KEY = "wifi_scan_results_lock"
def request_wifi_scan_results():
@@ -830,6 +831,19 @@ def request_wifi_scan_results():
CORE.data[KEEP_SCAN_RESULTS_KEY] = True
def request_wifi_scan_results_lock() -> None:
"""Request that scan results be guarded by a lock for cross-task readers.
Components that read WiFi scan results from a task other than the main loop
(for example a web server handler) must call this function during their code
generation, and their C++ code must hold a wifi::ScanResultsLock while
iterating get_scan_result(). On multi-threaded platforms this compiles in a
lock that scan result writers hold; on single-threaded platforms it compiles
to nothing.
"""
CORE.data[SCAN_RESULTS_LOCK_KEY] = True
def enable_runtime_power_save_control():
"""Enable runtime WiFi power save control.
@@ -891,6 +905,8 @@ async def final_step():
cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE")
if CORE.data.get(RUNTIME_ROAMING_SUPPRESSION_KEY, False):
cg.add_define("USE_WIFI_RUNTIME_ROAMING_SUPPRESSION")
if CORE.data.get(SCAN_RESULTS_LOCK_KEY):
cg.add_define("USE_WIFI_SCAN_RESULTS_LOCK")
# Generate listener defines - each listener type has its own #ifdef
ip_state_count = CORE.data.get(IP_STATE_LISTENERS_KEY, 0)
+20 -14
View File
@@ -1483,23 +1483,26 @@ void WiFiComponent::check_scanning_finished() {
}
ESP_LOGD(TAG, "Found networks:");
for (auto &res : this->scan_result_) {
for (auto &ap : this->sta_) {
if (res.matches(ap)) {
res.set_matches(true);
// Cache priority lookup - do single search instead of 2 separate searches
const bssid_t &bssid = res.get_bssid();
if (!this->has_sta_priority(bssid)) {
this->set_sta_priority(bssid, ap.get_priority());
{
ScanResultsLock lock(this);
for (auto &res : this->scan_result_) {
for (auto &ap : this->sta_) {
if (res.matches(ap)) {
res.set_matches(true);
// Cache priority lookup - do single search instead of 2 separate searches
const bssid_t &bssid = res.get_bssid();
if (!this->has_sta_priority(bssid)) {
this->set_sta_priority(bssid, ap.get_priority());
}
res.set_priority(this->get_sta_priority(bssid));
break;
}
res.set_priority(this->get_sta_priority(bssid));
break;
}
}
}
// Sort scan results using insertion sort for better memory efficiency
insertion_sort_scan_results(this->scan_result_);
// Sort scan results using insertion sort for better memory efficiency
insertion_sort_scan_results(this->scan_result_);
}
// Log matching networks (non-matching already logged at VERBOSE in scan callback)
for (auto &res : this->scan_result_) {
@@ -1885,11 +1888,13 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
// Phase-specific setup
switch (new_phase) {
#ifdef USE_WIFI_FAST_CONNECT
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: {
// Move to next configured AP - clear old scan data so new AP is tried with config only
this->selected_sta_index_++;
ScanResultsLock lock(this);
this->scan_result_.clear();
break;
}
#endif
case WiFiRetryPhase::EXPLICIT_HIDDEN:
@@ -2404,6 +2409,7 @@ void WiFiComponent::clear_roaming_state_() {
void WiFiComponent::release_scan_results_() {
if (!this->keep_scan_results_) {
ScanResultsLock lock(this);
#if defined(USE_RP2) || defined(USE_ESP32)
// std::vector - use swap trick since shrink_to_fit is non-binding
decltype(this->scan_result_)().swap(this->scan_result_);
+36
View File
@@ -187,6 +187,13 @@ template<typename T> using wifi_scan_vector_t = std::vector<T>;
template<typename T> using wifi_scan_vector_t = FixedVector<T>;
#endif
// A consumer component (e.g. the captive portal) reads scan results from another
// task; guard them with a real lock only on platforms that actually run multiple
// threads. See ScanResultsLock below the WiFiComponent class.
#if defined(USE_WIFI_SCAN_RESULTS_LOCK) && !defined(ESPHOME_THREAD_SINGLE)
#define WIFI_SCAN_RESULTS_LOCK_ENABLED
#endif
/// 20-byte string: 18 chars inline + null, heap for longer. Always null-terminated.
/// Used internally for WiFi SSID/password storage to reduce heap fragmentation.
class CompactString {
@@ -506,6 +513,9 @@ class WiFiComponent final : public Component {
const char *get_use_address() const { return this->use_address_; }
void set_use_address(const char *use_address) { this->use_address_ = use_address; }
/// Main-loop callers may read this directly. Callers on any other task must
/// hold a ScanResultsLock for the whole iteration and must call
/// wifi.request_wifi_scan_results_lock() from their code generation.
const wifi_scan_vector_t<WiFiScanResult> &get_scan_result() const { return scan_result_; }
network::IPAddress wifi_soft_ap_ip();
@@ -817,6 +827,8 @@ class WiFiComponent final : public Component {
friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data);
#endif
friend class ScanResultsLock;
#ifdef USE_RP2
static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result);
void wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result);
@@ -831,7 +843,11 @@ class WiFiComponent final : public Component {
// Large/pointer-aligned members first
FixedVector<WiFiAP> sta_;
std::vector<WiFiSTAPriority> sta_priorities_;
// Guarded by ScanResultsLock (see below this class)
wifi_scan_vector_t<WiFiScanResult> scan_result_;
#ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED
Mutex scan_result_lock_;
#endif
#ifdef USE_WIFI_AP
WiFiAP ap_;
#endif
@@ -1003,5 +1019,25 @@ class WiFiComponent final : public Component {
extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
/// Guards WiFiComponent::scan_result_. Invariant: every mutation and every read
/// from outside the main loop holds this lock, and holders only do bounded work
/// (never unbounded waits or network sends). On every platform where the lock is
/// enabled (ESP32, LibreTiny) scan-done events are drained from the event queue
/// on the main loop, so all writers are main-loop there and main-loop reads take
/// no lock. Single-threaded platforms write from driver context and the lock is
/// a no-op. Compiles to nothing unless a cross-task reader is in the build and
/// the platform is multi-threaded (WIFI_SCAN_RESULTS_LOCK_ENABLED).
class ScanResultsLock {
public:
#ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED
ScanResultsLock(WiFiComponent *parent) : guard_(parent->scan_result_lock_) {}
private:
LockGuard guard_;
#else
ScanResultsLock(WiFiComponent *) {}
#endif
};
} // namespace esphome::wifi
#endif
@@ -733,6 +733,8 @@ void WiFiComponent::s_wifi_scan_done_callback(void *arg, STATUS status) {
}
void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
// Compiles to nothing here; kept so every scan_result_ mutation holds the lock
ScanResultsLock lock(this);
this->scan_result_.clear();
if (status != OK) {
@@ -891,65 +891,65 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
const auto &it = data->data.sta_scan_done;
ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id);
scan_result_.clear();
this->scan_done_ = true;
if (it.status != 0) {
// scan error
return;
}
if (it.number == 0) {
// no results
return;
}
uint16_t number = it.number;
bool needs_full = this->needs_full_scan_results_();
{
// Mutate in place under the lock; blocking a portal request is fine and
// avoids scratch buffers
ScanResultsLock lock(this);
this->scan_result_.clear();
this->scan_done_ = true;
if (it.status != 0) {
// scan error
return;
}
// Smart reserve: full capacity if needed, small reserve otherwise
if (needs_full) {
this->scan_result_.reserve(number);
} else {
this->scan_result_.reserve(WIFI_SCAN_RESULT_FILTERED_RESERVE);
}
if (number == 0) {
// no results
return;
}
// Smart reserve: full capacity if needed, small reserve otherwise
this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE);
#ifdef USE_ESP32_HOSTED
// getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor
// Presumably an upstream bug, work-around by getting all records at once
// Use stack buffer (3904 bytes / ~80 bytes per record = ~48 records) with heap fallback
static constexpr size_t SCAN_RECORD_STACK_COUNT = 3904 / sizeof(wifi_ap_record_t);
SmallBufferWithHeapFallback<SCAN_RECORD_STACK_COUNT, wifi_ap_record_t> records(number);
err = esp_wifi_scan_get_ap_records(&number, records.get());
if (err != ESP_OK) {
esp_wifi_clear_ap_list();
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err));
return;
}
for (uint16_t i = 0; i < number; i++) {
wifi_ap_record_t &record = records.get()[i];
#else
// Process one record at a time to avoid large buffer allocation
for (uint16_t i = 0; i < number; i++) {
wifi_ap_record_t record;
err = esp_wifi_scan_get_ap_record(&record);
// getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor
// Presumably an upstream bug, work-around by getting all records at once
// Use stack buffer (3904 bytes / ~80 bytes per record = ~48 records) with heap fallback
static constexpr size_t SCAN_RECORD_STACK_COUNT = 3904 / sizeof(wifi_ap_record_t);
SmallBufferWithHeapFallback<SCAN_RECORD_STACK_COUNT, wifi_ap_record_t> records(number);
err = esp_wifi_scan_get_ap_records(&number, records.get());
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err));
esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved
break;
esp_wifi_clear_ap_list();
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err));
return;
}
for (uint16_t i = 0; i < number; i++) {
wifi_ap_record_t &record = records.get()[i];
#else
// Process one record at a time to avoid large buffer allocation
for (uint16_t i = 0; i < number; i++) {
wifi_ap_record_t record;
err = esp_wifi_scan_get_ap_record(&record);
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err));
esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved
break;
}
#endif // USE_ESP32_HOSTED
// Check C string first - avoid std::string construction for non-matching networks
const char *ssid_cstr = reinterpret_cast<const char *>(record.ssid);
// Check C string first - avoid std::string construction for non-matching networks
const char *ssid_cstr = reinterpret_cast<const char *>(record.ssid);
// Only construct std::string and store if needed
if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) {
bssid_t bssid;
std::copy(record.bssid, record.bssid + 6, bssid.begin());
this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi,
record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0');
} else {
this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary);
// Only construct std::string and store if needed
if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) {
bssid_t bssid;
std::copy(record.bssid, record.bssid + 6, bssid.begin());
this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi,
record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0');
} else {
this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary);
}
}
}
ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(),
@@ -657,44 +657,48 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
return true;
}
void WiFiComponent::wifi_scan_done_callback_() {
this->scan_result_.clear();
this->scan_done_ = true;
int16_t num = WiFi.scanComplete();
if (num < 0)
return;
bool needs_full = this->needs_full_scan_results_();
{
// Mutate in place under the lock; blocking a portal request is fine and
// avoids scratch buffers
ScanResultsLock lock(this);
this->scan_result_.clear();
this->scan_done_ = true;
// Access scan results directly via WiFi.scan struct to avoid Arduino String allocations
// WiFi.scan is public in LibreTiny for WiFiEvents & WiFiScan static handlers
auto *scan = WiFi.scan;
if (num < 0)
return;
// First pass: count matching networks
size_t count = 0;
for (int i = 0; i < num; i++) {
const char *ssid_cstr = scan->ap[i].ssid;
if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) {
count++;
// Access scan results directly via WiFi.scan struct to avoid Arduino String allocations
// WiFi.scan is public in LibreTiny for WiFiEvents & WiFiScan static handlers
auto *scan = WiFi.scan;
// First pass: count matching networks
size_t count = 0;
for (int i = 0; i < num; i++) {
const char *ssid_cstr = scan->ap[i].ssid;
if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) {
count++;
}
}
this->scan_result_.init(count); // Exact allocation
// Second pass: store matching networks
for (int i = 0; i < num; i++) {
const char *ssid_cstr = scan->ap[i].ssid;
auto &ap = scan->ap[i];
if (needs_full || this->matches_configured_network_(ssid_cstr, ap.bssid.addr)) {
this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3],
ap.bssid.addr[4], ap.bssid.addr[5]},
ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN,
ssid_cstr[0] == '\0');
} else {
this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel);
}
}
}
this->scan_result_.init(count); // Exact allocation
// Second pass: store matching networks
for (int i = 0; i < num; i++) {
const char *ssid_cstr = scan->ap[i].ssid;
if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) {
auto &ap = scan->ap[i];
this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3],
ap.bssid.addr[4], ap.bssid.addr[5]},
ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN,
ssid_cstr[0] == '\0');
} else {
auto &ap = scan->ap[i];
this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel);
}
}
ESP_LOGV(TAG, "Scan complete: %d found, %zu stored%s", num, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
WiFi.scanDelete();
@@ -193,12 +193,16 @@ void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *re
std::copy(result->bssid, result->bssid + 6, bssid.begin());
WiFiScanResult res(bssid, ssid_buf, len, result->channel, result->rssi, result->auth_mode != CYW43_AUTH_OPEN,
len == 0);
// Compiles to nothing here; kept so every scan_result_ mutation holds the lock
ScanResultsLock lock(this);
if (std::find(this->scan_result_.begin(), this->scan_result_.end(), res) == this->scan_result_.end()) {
this->scan_result_.push_back(res);
}
}
bool WiFiComponent::wifi_scan_start_(bool passive) {
// Compiles to nothing here; kept so every scan_result_ mutation holds the lock
ScanResultsLock lock(this);
this->scan_result_.clear();
this->scan_done_ = false;
s_scan_result_count = 0;
+2 -2
View File
@@ -63,11 +63,11 @@ CONFIG_SCHEMA = cv.Schema(
cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
cv.Required(CONF_ADDRESS): cv.ipv4address,
cv.Optional(CONF_NETMASK, default="255.255.255.255"): cv.ipv4address,
cv.Required(CONF_PRIVATE_KEY): _wireguard_key,
cv.Required(CONF_PRIVATE_KEY): cv.sensitive(_wireguard_key),
cv.Required(CONF_PEER_ENDPOINT): cv.string,
cv.Required(CONF_PEER_PUBLIC_KEY): _wireguard_key,
cv.Optional(CONF_PEER_PORT, default=51820): cv.port,
cv.Optional(CONF_PEER_PRESHARED_KEY): _wireguard_key,
cv.Optional(CONF_PEER_PRESHARED_KEY): cv.sensitive(_wireguard_key),
cv.Optional(CONF_PEER_ALLOWED_IPS, default=["0.0.0.0/0"]): cv.ensure_list(
_cidr_network
),
+8
View File
@@ -173,6 +173,14 @@ bool IRAM_ATTR ISRInternalGPIOPin::digital_read() {
return bool(gpio_pin_get(arg->gpio, arg->pin % arg->gpio_size) != arg->inverted);
}
void IRAM_ATTR ISRInternalGPIOPin::digital_write(bool value) {
auto *arg = (zephyr::ISRPinArg *) this->arg_;
if (arg == nullptr || arg->gpio == nullptr) {
return;
}
gpio_pin_set(arg->gpio, arg->pin % arg->gpio_size, value != arg->inverted ? 1 : 0);
}
} // namespace esphome
#endif
+28 -8
View File
@@ -422,12 +422,14 @@ class Version:
@classmethod
def parse(cls, value: str) -> Version:
match = re.match(r"^(\d+).(\d+).(\d+)[-.]?(\w*)$", value)
# The patch component is optional and defaults to 0, so "6.0" and
# "6.0-rc1" parse as 6.0.0 and 6.0.0-rc1.
match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?[-.]?(\w*)$", value)
if match is None:
raise ValueError(f"Not a valid version number {value}")
major = int(match[1])
minor = int(match[2])
patch = int(match[3])
patch = int(match[3] or 0)
extra = match[4] or ""
return Version(major=major, minor=minor, patch=patch, extra=extra)
@@ -1936,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()})."
@@ -1956,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.0"
__version__ = "2026.7.4"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+3
View File
@@ -252,6 +252,7 @@
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16
#define USE_CAPTIVE_PORTAL
#define USE_WIFI_SCAN_RESULTS_LOCK
#define USE_ESP32_BLE
#define USE_ESP32_BLE_MAX_CONNECTIONS 3
#define USE_ESP32_BLE_CLIENT
@@ -387,6 +388,7 @@
#define USE_ESP8266_CRASH_HANDLER
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 1, 2)
#define USE_CAPTIVE_PORTAL
#define USE_WIFI_SCAN_RESULTS_LOCK
#define USE_ESP8266_LOGGER_SERIAL
#define USE_ESP8266_LOGGER_SERIAL1
#define USE_ESP8266_PREFERENCES_FLASH
@@ -436,6 +438,7 @@
#ifdef USE_LIBRETINY
#define USE_CAPTIVE_PORTAL
#define USE_WIFI_SCAN_RESULTS_LOCK
#define USE_SOCKET_IMPL_LWIP_SOCKETS
#define USE_LWIP_FAST_SELECT
#define USE_WEBSERVER
+32 -3
View File
@@ -83,11 +83,23 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
Returns:
str: The complete CMakeLists.txt content as a string
"""
# Late import: this module loads with the esp32 platform on every
# validate/compile, but shlex is only needed when generating component
# CMakeLists.
import shlex
def escape_entry(p: PathType) -> str:
# In CMakeLists.txt, backslashes need to be escaped
return f'"{str(p)}"'.replace("\\", "\\\\")
def escape_path(p: PathType) -> str:
# CMake uses forward slashes for paths on every platform and treats
# backslashes as escape characters. On Windows os.path.relpath yields
# backslash paths, which break CMake's list re-parsing (e.g. "\b" in
# "src\backend" is an invalid character escape). Emit forward slashes,
# which Windows accepts too, so the generated CMakeLists is portable.
return f'"{str(p).replace(os.sep, "/")}"'
# Extract the values
build_src_dir = component.data.get("build", {}).get("srcDir", None)
if not build_src_dir:
@@ -105,6 +117,23 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
build_flags = ensure_list(
component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS)
)
# PlatformIO shell-lexes each build.flags entry, so one entry can carry a
# flag and its argument (e.g. "-include cp_custom_alloc.h"). Split the
# same way; emitting such an entry as a single quoted compile option
# hands the compiler one argv with an embedded space.
build_flags = [token for entry in build_flags for token in shlex.split(entry)]
# Re-glue bare -I/-L/-l tokens to their argument ("-I foo" -> "-Ifoo") so
# the prefix classifiers below still route them to INCLUDE_DIRS and the
# link handling.
tokens, build_flags = build_flags, []
i = 0
while i < len(tokens):
if tokens[i] in ("-I", "-L", "-l") and i + 1 < len(tokens):
build_flags.append(tokens[i] + tokens[i + 1])
i += 2
else:
build_flags.append(tokens[i])
i += 1
# List all sources files
build_src_files = collect_filtered_files(
@@ -152,10 +181,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
# Generate the component
content = "idf_component_register(\n"
if build_src_files:
str_srcs = " ".join([escape_entry(p) for p in sorted(build_src_files)])
str_srcs = " ".join([escape_path(p) for p in sorted(build_src_files)])
content += f" SRCS {str_srcs}\n"
if build_include_dirs:
str_include_dirs = " ".join([escape_entry(p) for p in build_include_dirs])
str_include_dirs = " ".join([escape_path(p) for p in build_include_dirs])
content += f" INCLUDE_DIRS {str_include_dirs}\n"
# Project-managed and built-in component lists are set per-project
# via idf_build_set_property in the top-level CMakeLists; expanded
@@ -190,7 +219,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
if link_directories:
content += "target_link_directories(${COMPONENT_LIB} INTERFACE\n"
for link_directory in link_directories:
str_build_flag = escape_entry(link_directory)
str_build_flag = escape_path(link_directory)
content += f" {str_build_flag}\n"
content += ")\n"
+269 -104
View File
@@ -1,5 +1,7 @@
"""ESP-IDF framework tools for ESPHome."""
from collections.abc import Callable
from ctypes.util import find_library
import json
import logging
import os
@@ -7,7 +9,7 @@ from pathlib import Path
import platform
import re
import shutil
import tempfile
from typing import NoReturn
import platformdirs
@@ -18,6 +20,7 @@ from esphome.framework_helpers import (
archive_extract_all,
create_venv,
download_from_mirrors,
download_with_resume,
get_python_env_executable_path,
get_system_python_path,
rmdir,
@@ -63,7 +66,7 @@ ESPHOME_IDF_FRAMEWORK_MIRRORS = str_to_lst_of_str(
os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS")
or [
"https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz",
"https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}{EXTRA}/esp-idf-v{MAJOR}.{MINOR}{EXTRA}.tar.xz",
"https://github.com/esphome-libs/esp-idf/releases/download/v{SHORT_VERSION}/esp-idf-v{SHORT_VERSION}.tar.xz",
]
)
@@ -229,6 +232,40 @@ def _write_stamp(file: PathType, data: dict[str, str]):
json.dump(data, fp)
def _run_idf_tools_script(
idf_framework_root: PathType,
script_name: str,
msg: str,
args: list[str] | None = None,
env: dict[str, str] | None = None,
) -> tuple[bool, str | None, str | None]:
"""Run one of the sibling idf_tools-backed helper scripts.
The script is executed with the framework's ``tools`` directory on
PYTHONPATH so it imports the framework's own ``idf_tools`` module.
"""
cmd = [
get_system_python_path(),
str(_SCRIPTS_DIR / script_name),
str(idf_framework_root),
*(args or []),
]
return run_command(
cmd,
msg=msg,
env=(env or os.environ)
| {"PYTHONPATH": str(Path(idf_framework_root) / "tools")},
)
def _raise_script_failure(what: str, root: PathType, stderr: str | None) -> NoReturn:
"""Raise RuntimeError for a failed helper script, appending stderr detail."""
detail = (stderr or "").strip()
raise RuntimeError(
f"Can't get {what} of {root}" + (f": {detail}" if detail else "")
)
def _get_idf_version(
idf_framework_root: PathType, env: dict[str, str] | None = None
) -> str:
@@ -246,26 +283,13 @@ def _get_idf_version(
RuntimeError: If ESP-IDF version cannot be determined
"""
cmd = [
get_system_python_path(),
str(_SCRIPTS_DIR / "get_idf_version.py"),
str(idf_framework_root),
]
success, stdout, stderr = run_command(
cmd,
msg="ESP-IDF version",
env=(env or os.environ)
| {"PYTHONPATH": str(Path(idf_framework_root) / "tools")},
success, stdout, stderr = _run_idf_tools_script(
idf_framework_root, "get_idf_version.py", "ESP-IDF version", env=env
)
if stdout:
stdout = stdout.strip()
if not success or not stdout:
detail = (stderr or "").strip()
raise RuntimeError(
f"Can't get ESP-IDF version of {idf_framework_root}"
+ (f": {detail}" if detail else "")
)
_raise_script_failure("ESP-IDF version", idf_framework_root, stderr)
return stdout
@@ -286,24 +310,11 @@ def _get_idf_tool_paths(
RuntimeError: If ESP-IDF tool paths cannot be determined
"""
cmd = [
get_system_python_path(),
str(_SCRIPTS_DIR / "get_idf_tool_paths.py"),
str(idf_framework_root),
]
success, stdout, stderr = run_command(
cmd,
msg="ESP-IDF tool paths",
env=(env or os.environ)
| {"PYTHONPATH": str(Path(idf_framework_root) / "tools")},
success, stdout, stderr = _run_idf_tools_script(
idf_framework_root, "get_idf_tool_paths.py", "ESP-IDF tool paths", env=env
)
if not success or not stdout:
detail = (stderr or "").strip()
raise RuntimeError(
f"Can't get ESP-IDF tool paths of {idf_framework_root}"
+ (f": {detail}" if detail else "")
)
_raise_script_failure("ESP-IDF tool paths", idf_framework_root, stderr)
# Extract json values
try:
@@ -386,9 +397,10 @@ def _clone_idf_with_submodules(
handles branches, tags, and SHAs uniformly (mirrors the approach in
``esphome.git.clone_or_update``).
"""
from esphome.git import run_git_command
from esphome.git import run_git_command, update_submodules
_LOGGER.info("Cloning ESP-IDF from %s%s", git_url, f"@{ref}" if ref else "")
key = f"{git_url}@{ref}" if ref else git_url
_LOGGER.info("Cloning ESP-IDF from %s", key)
run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)])
if ref:
run_git_command(
@@ -399,25 +411,14 @@ def _clone_idf_with_submodules(
["git", "reset", "--hard", "FETCH_HEAD"],
git_dir=framework_path,
)
run_git_command(
[
"git",
"submodule",
"update",
"--init",
"--recursive",
"--depth=1",
],
git_dir=framework_path,
)
update_submodules(framework_path, key)
# Sanity-check the resulting tree. run_git_command only raises when
# stderr is non-empty, so a clone that silently produces no working
# tree would otherwise be marked extracted and stuck until
# ``esphome clean``.
# Sanity-check the resulting tree: a clone can exit 0 yet produce no
# usable ESP-IDF checkout, which would otherwise be marked extracted and
# stuck until ``esphome clean``.
if not (framework_path / "tools" / "idf_tools.py").is_file():
raise RuntimeError(
f"Clone of {git_url} produced no usable ESP-IDF tree at {framework_path}"
f"Clone of {key} produced no usable ESP-IDF tree at {framework_path}"
)
@@ -466,17 +467,21 @@ _NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = {
}
def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None:
"""Inject ninja linux-arm64 entries into the framework's tools.json on aarch64.
def _patch_tools_json(
framework_path: Path,
apply_patch: Callable[[dict], bool],
patched_log: str,
) -> None:
"""Apply an in-place fixup to the framework's tools/tools.json.
Idempotent: a tools.json that already has the entry, or a host that
isn't aarch64, is a no-op. Applied unconditionally on every install
check so a build dir extracted before the backport got fixed up
without forcing a clean.
Shared plumbing for the tools.json patches below: a missing file is a
no-op, an unparseable file logs a warning and skips, and when
``apply_patch`` reports a change the file is written back atomically.
``patched_log`` is the info log line, with a single ``%s`` placeholder
for the tools.json path. Patches are idempotent and applied on every
install check, so an already-extracted framework picks them up on the
next build without forcing a clean.
"""
if platform.machine() != "aarch64":
return
tools_json = framework_path / "tools" / "tools.json"
if not tools_json.is_file():
return
@@ -484,37 +489,156 @@ def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None:
try:
with tools_json.open(encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
# apply_patch also raises inside the guard: a tools.json that is
# valid JSON but not the expected shape (e.g. a top-level list)
# must skip the patch, not crash the install check this patch is
# meant to recover.
changed = apply_patch(data)
except (json.JSONDecodeError, OSError, AttributeError, TypeError, KeyError) as e:
_LOGGER.warning(
"Could not parse %s for linux-arm64 backport (%s); "
"skipping. A clean reinstall of the framework directory "
"may be needed.",
"Could not apply tools.json patch to %s (%s); skipping. A clean "
"reinstall of the framework directory may be needed.",
tools_json,
e,
)
return
changed = False
for tool in data.get("tools", []):
if tool.get("name") != "ninja":
continue
for ver in tool.get("versions", []):
entry = _NINJA_ARM64_BACKPORT.get(ver.get("name"))
if entry is None or ver.get("linux-arm64"):
continue
ver["linux-arm64"] = entry
changed = True
if changed:
# write_file_if_changed stages a tempfile in the destination dir
# and atomically replaces — safe against mid-write interruption
# and concurrent invocations.
write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n")
_LOGGER.info(
"Patched %s to add ninja linux-arm64 download "
"(espressif/esp-idf#18272 backport).",
tools_json,
_LOGGER.info(patched_log, tools_json)
def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None:
"""Inject ninja linux-arm64 entries into the framework's tools.json on aarch64.
A tools.json that already has the entry, or a host that isn't aarch64,
is a no-op.
"""
if platform.machine() != "aarch64":
return
def apply_patch(data: dict) -> bool:
changed = False
for tool in data.get("tools", []):
if tool.get("name") != "ninja":
continue
for ver in tool.get("versions", []):
entry = _NINJA_ARM64_BACKPORT.get(ver.get("name"))
if entry is None or ver.get("linux-arm64"):
continue
ver["linux-arm64"] = entry
changed = True
return changed
_patch_tools_json(
framework_path,
apply_patch,
"Patched %s to add ninja linux-arm64 download "
"(espressif/esp-idf#18272 backport).",
)
def _patch_tools_json_demote_openocd(framework_path: Path) -> None:
"""Demote openocd-esp32 from ``install: always`` to ``install: on_request``.
``idf_tools.py install required`` installs every tool marked ``always`` in
tools.json and validates each one after extraction by running its version
command. openocd links against libusb-1.0, which minimal systems (bare LXC
containers, slim images) often lack, so that one validation aborted the
whole framework install and left it permanently retrying (#17685) — even
though ESPHome never runs openocd (it is a JTAG debugging tool). Demoting
it drops it from the ``required`` set: it is no longer downloaded or
validated, and the tool-path export treats a missing ``on_request`` tool
as fine. A user who wants it can still name ``openocd-esp32`` explicitly
in ESPHOME_IDF_DEFAULT_TOOLS; explicit names bypass install-type
filtering.
Because this runs on every install check, an install stuck in the
failing state (which never wrote its stamp file) heals on the next
build without a clean.
"""
def apply_patch(data: dict) -> bool:
changed = False
for tool in data.get("tools", []):
if tool.get("name") == "openocd-esp32" and tool.get("install") == "always":
tool["install"] = "on_request"
changed = True
return changed
_patch_tools_json(
framework_path,
apply_patch,
"Patched %s to make openocd-esp32 optional (not needed for "
"building, and its install check fails on systems without "
"libusb-1.0).",
)
def _prefetch_idf_tool_archives(
framework_path: Path,
targets_str: str,
tools: list[str],
env: dict[str, str] | None,
) -> None:
"""Pre-download the tool archives ``idf_tools.py install`` would fetch.
``idf_tools.py``'s own downloader restarts from byte zero on every retry,
which makes large archives effectively impossible to fetch on unstable
connections (#17703). This asks the framework's idf_tools (via
``get_tool_downloads.py``) which archives the coming install needs, then
downloads each into ``<IDF_TOOLS_PATH>/dist`` with
``download_with_resume``. The installer then finds the verified archives
already in place ("file ... is already downloaded") and never touches the
network.
Strictly best-effort: any failure here just logs and returns, leaving
``idf_tools.py install`` to download whatever is missing exactly as
before. Leftover ``.part`` files live in ``dist/`` and are removed by the
post-install cache prune.
"""
try:
success, stdout, stderr = _run_idf_tools_script(
framework_path,
"get_tool_downloads.py",
"ESP-IDF tool download list",
args=[targets_str, *tools],
env=env,
)
if not success or not stdout:
_LOGGER.warning(
"Could not determine ESP-IDF tool downloads: %s",
(stderr or "").strip(),
)
return
dist_path = get_idf_tools_path() / "dist"
entries = [
entry
for entry in json.loads(stdout)
if not (dist_path / entry["dest"]).is_file()
]
for index, entry in enumerate(entries, start=1):
_LOGGER.info(
"Downloading %s (%d/%d) ...", entry["name"], index, len(entries)
)
try:
download_with_resume(
entry["url"],
dist_path / entry["dest"],
sha256=entry["sha256"],
size=entry["size"],
)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Keep prefetching the remaining archives; the installer
# will retry this one itself (without resume).
_LOGGER.warning("Could not prefetch %s: %s", entry["name"], e)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# The installer downloads anything missing itself; never let the
# prefetch become a new way for the install to fail.
_LOGGER.warning("ESP-IDF tool prefetch failed: %s", e)
def _check_esphome_idf_framework_install(
@@ -536,10 +660,14 @@ def _check_esphome_idf_framework_install(
env: Optional dictionary of environment variables to set
source_url: Optional override URL for the framework tarball. Supports
the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` /
``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS
(``{EXTRA}`` includes its leading ``-``, e.g. ``-rc1``, or is empty).
When set, it replaces the default mirror list no implicit fallback,
so a misspelled URL fails loudly.
``{EXTRA}`` / ``{SHORT_VERSION}`` substitutions as
ESPHOME_IDF_FRAMEWORK_MIRRORS (``{EXTRA}`` includes its leading
``-``, e.g. ``-rc1``, or is empty; ``{SHORT_VERSION}`` is ``x.y``
plus any extra and only available for x.y.0 versions a URL
referencing it is skipped for other versions). When set, it
replaces the default mirror list no implicit fallback, so a
misspelled or skipped URL fails loudly with an EsphomeError naming
the URL.
Returns:
tuple of (framework_path, install_flag)
@@ -584,28 +712,51 @@ def _check_esphome_idf_framework_install(
git_url, ref = git_source
_clone_idf_with_submodules(framework_path, git_url, ref)
else:
# Download in temporary file
with tempfile.NamedTemporaryFile() as tmp:
_LOGGER.info("Downloading ESP-IDF %s framework ...", version)
_LOGGER.info("Downloading ESP-IDF %s framework ...", version)
# Create substitutions for the URLs
substitutions = {"VERSION": version}
try:
ver = Version.parse(version)
substitutions["MAJOR"] = str(ver.major)
substitutions["MINOR"] = str(ver.minor)
substitutions["PATCH"] = str(ver.patch)
substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else ""
except ValueError:
pass
mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS
download_from_mirrors(mirrors, substitutions, tmp.file)
_LOGGER.info("Extracting ESP-IDF %s framework ...", version)
archive_extract_all(
tmp.file, framework_path, progress_header="Extracting"
# Create substitutions for the URLs. SHORT_VERSION (x.y with
# optional -extra) is only provided for x.y.0 releases, since
# the vX.Y release tags only exist for those; templates that
# reference it are skipped for other versions by
# download_from_mirrors.
substitutions = {"VERSION": version}
try:
ver = Version.parse(version)
substitutions["MAJOR"] = str(ver.major)
substitutions["MINOR"] = str(ver.minor)
substitutions["PATCH"] = str(ver.patch)
substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else ""
if ver.patch == 0:
substitutions["SHORT_VERSION"] = (
f"{ver.major}.{ver.minor}{substitutions['EXTRA']}"
)
except ValueError:
_LOGGER.warning(
"ESP-IDF version '%s' is not a valid version number; "
"only the {VERSION} substitution is available for "
"mirror URLs",
version,
)
mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS
# Download to a persistent file in the tool download cache (not
# a temp file) so an interrupted download resumes on the next
# run; the cache is pruned after a successful install anyway.
tarball_path = get_idf_tools_path() / "dist" / f"esp-idf-{version}.tar.xz"
download_from_mirrors(mirrors, substitutions, tarball_path)
_LOGGER.info("Extracting ESP-IDF %s framework ...", version)
try:
with tarball_path.open("rb") as tarball:
archive_extract_all(
tarball, framework_path, progress_header="Extracting"
)
finally:
# Success: drop the archive rather than caching ~70MB twice.
# Failure: a corrupt archive (e.g. torn by an unclean
# shutdown) must not be reused — without a checksum only a
# failed extraction can expose it, so force a re-download.
tarball_path.unlink(missing_ok=True)
extracted_marker.touch()
# Idempotent post-extract patch: written every invocation so a build
@@ -618,6 +769,11 @@ def _check_esphome_idf_framework_install(
# a pre-patch tools.json get fixed up without forcing a clean.
_patch_tools_json_for_linux_arm64(framework_path)
# Drop openocd-esp32 from the required tool set on every invocation so
# an install that previously failed on its libusb check recovers on the
# next build.
_patch_tools_json_demote_openocd(framework_path)
# 3. Check if the framework tools are the same and correctly installed
if not install:
install = True
@@ -638,6 +794,7 @@ def _check_esphome_idf_framework_install(
if install:
_LOGGER.info("Installing ESP-IDF %s framework ...", version)
targets_str = ",".join(targets)
_prefetch_idf_tool_archives(framework_path, targets_str, tools, env)
cmd = [
get_system_python_path(),
str(idf_tools_path),
@@ -651,6 +808,14 @@ def _check_esphome_idf_framework_install(
env=env,
stream_output=True,
):
if platform.system() == "Linux" and find_library("usb-1.0") is None:
_LOGGER.error(
"libusb-1.0.so.0 was not found on this system. If the error "
"above mentions it (openocd fails its install check without "
"it), install the libusb 1.0 package, e.g. libusb-1.0-0 "
"(Debian/Ubuntu), libusb1 (Fedora) or libusb (Alpine/Arch), "
"then run the build again."
)
raise RuntimeError(f"ESP-IDF {version} framework installation failure")
_write_stamp(env_stamp_file, stamp_info)
+88
View File
@@ -0,0 +1,88 @@
"""Print JSON download info for the ESP-IDF tools an install would fetch.
Run via ``python <this file> <idf_framework_root> <targets-csv> <tool-spec>...``.
PYTHONPATH must include ``<idf_framework_root>/tools`` so ``idf_tools`` is
importable, and IDF_TOOLS_PATH must be set. Prints a JSON list of
``{name, url, size, sha256, dest}`` for every tool version that is not yet
installed, where ``dest`` is the archive filename ``idf_tools.py install``
expects to find in ``<IDF_TOOLS_PATH>/dist``. Tools with no download for the
current platform are skipped; already-installed versions are skipped so a
pruned download cache is not re-fetched.
The target/tool expansion mirrors ``idf_tools.py install`` (targets passed to
``add_and_check_targets`` accumulate with idf-env.json) but nothing is saved
or written this script only reports what the install would download.
"""
# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only
from contextlib import redirect_stdout
import json
import os
from pathlib import Path
import sys
from idf_tools import (
CURRENT_PLATFORM,
TOOLS_FILE,
IDFEnv,
ToolBinaryError,
add_and_check_targets,
expand_tools_arg,
g,
get_idf_download_url_apply_mirrors,
load_tools_info,
)
def collect_downloads() -> list[dict]:
g.idf_path = sys.argv[1]
g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH")
g.tools_json = str(Path(g.idf_path) / TOOLS_FILE)
targets = add_and_check_targets(IDFEnv.get_idf_env(), sys.argv[2])
tools_info = load_tools_info()
downloads: list[dict] = []
for name in expand_tools_arg(sys.argv[3:], tools_info, targets):
if "@" in name:
name, version = name.split("@", 1)
else:
version = None
tool = tools_info.get(name)
if tool is None or not tool.compatible_with_platform():
continue
version = version or tool.get_recommended_version()
if version is None:
continue
try:
tool.find_installed_versions()
except ToolBinaryError as e:
# A broken installed binary is idf_tools' problem to repair on
# install; note it and treat the version as not installed.
print(f"tool {name} failed its binary check: {e}", file=sys.stderr)
if version in tool.versions_installed or version not in tool.versions:
continue
download = tool.versions[version].get_download_for_platform(CURRENT_PLATFORM)
if download is None:
continue
downloads.append(
{
"name": f"{name}@{version}",
# Apply the same IDF_MIRROR_PREFIX_MAP / IDF_GITHUB_ASSETS
# rewriting the installer's own downloader applies, so users
# behind a mirror prefetch from the mirror too.
"url": get_idf_download_url_apply_mirrors(None, download.url),
"size": download.size,
"sha256": download.sha256,
"dest": download.rename_dist or Path(download.url).name,
}
)
return downloads
# idf_tools prints informational lines (e.g. mirror URL rewrites) to stdout;
# route them to stderr so stdout carries only the JSON result.
with redirect_stdout(sys.stderr):
result = collect_downloads()
print(json.dumps(result))
+13 -3
View File
@@ -10,7 +10,12 @@ import shutil
import subprocess
from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
CONF_FRAMEWORK,
CONF_SOURCE,
)
from esphome.core import CORE, EsphomeError
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
from esphome.espidf.size_summary import print_summary
@@ -147,7 +152,10 @@ def _get_idf_tool(name: str) -> str:
def run_idf_py(
*args, cwd: Path | None = None, capture_output: bool = False
*args,
cwd: Path | None = None,
capture_output: bool = False,
jobs: int | None = None,
) -> int | str:
"""Run idf.py with the given arguments."""
idf_path = _get_idf_path()
@@ -155,6 +163,8 @@ def run_idf_py(
raise EsphomeError("ESP-IDF not found")
env = _get_idf_env()
if jobs is not None:
env = {**env, "IDF_PY_BUILD_JOBS": str(jobs)}
python_executable = _get_idf_tool("python")
idf_py = idf_path / "tools" / "idf.py"
# Dispatch idf.py through esphome.espidf.runner, which wraps
@@ -384,7 +394,7 @@ def run_compile(config, verbose: bool) -> int:
args.append("build")
args.append("size")
rc = run_idf_py(*args)
rc = run_idf_py(*args, jobs=config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT))
if rc == 0:
size_json = CORE.relative_build_path("build", "esp_idf_size.json")
partitions = CORE.relative_build_path("partitions.csv")
+7 -5
View File
@@ -165,11 +165,8 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by
_LOGGER.debug("Remote file has not changed %s", url)
return path.read_bytes()
_LOGGER.debug(
"Remote file has changed, downloading from %s to %s",
url,
path,
)
_LOGGER.info("Downloading %s", url)
_LOGGER.debug("Saving to %s", path)
try:
req = requests.get(
@@ -210,9 +207,13 @@ def download_content_many(
items: Iterable[tuple[str, Path]],
timeout: int = NETWORK_TIMEOUT,
max_workers: int = DEFAULT_DOWNLOAD_WORKERS,
description: str = "remote file(s)",
) -> None:
"""Run `download_content` for each (url, path) pair concurrently.
`description` names the kind of files in the progress log line, e.g.
"wake word manifest(s)".
Wall time drops from `sum(latency)` to roughly `max(latency)` for cached
files where the HEAD round-trip dominates. All workers run to
completion before this returns; every `cv.Invalid` raised by a worker
@@ -230,6 +231,7 @@ def download_content_many(
seen: dict[Path, str] = {path: url for url, path in items}
if not seen:
return
_LOGGER.info("Checking %d %s for updates", len(seen), description)
if len(seen) == 1:
path, url = next(iter(seen.items()))
download_content(url, path, timeout)
+484 -52
View File
@@ -2,21 +2,31 @@
from collections.abc import Iterable
from contextlib import ExitStack
import hashlib
import io
import json
import logging
import os
from pathlib import Path
import subprocess
import sys
import time
from typing import IO
from typing import IO, TYPE_CHECKING
from esphome.helpers import ProgressBar, rmtree
if TYPE_CHECKING:
import requests
PathType = str | os.PathLike
_LOGGER = logging.getLogger(__name__)
# Attempts per mirror URL before falling through to the next mirror; only
# mid-stream drops retry (resuming when the server gave a validator),
# connect errors move on immediately.
_MIRROR_ATTEMPTS = 3
def get_project_link_flags() -> list[str]:
"""Return the sorted -Wl, linker flags from the current build."""
@@ -394,17 +404,23 @@ def _zip_extract_all(
progress.update(1)
def _rename_with_retry(src: Path, dst: Path, attempts: int = 5) -> None:
def _rename_with_retry(
src: Path, dst: Path, attempts: int = 5, overwrite: bool = False
) -> None:
"""Rename ``src`` to ``dst`` with backoff retries on Windows sharing violations.
Antivirus/indexer handles on freshly-written files can briefly block
``os.rename`` with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED. The
handle is released within tens of ms in practice, so exponential backoff
works.
works. With ``overwrite`` an existing ``dst`` is replaced instead of
failing.
"""
for i in range(attempts):
try:
src.rename(dst)
if overwrite:
src.replace(dst)
else:
src.rename(dst)
return
except PermissionError:
if i == attempts - 1:
@@ -525,8 +541,8 @@ def archive_extract_all(
ValueError: If archive format is unsupported
"""
# 1. Handle different archive input types
with ExitStack() as stack:
# 1. Handle different archive input types
archive_ref: io.BufferedIOBase
if isinstance(archive, (str, os.PathLike)):
archive_ref = stack.enter_context(Path(archive).open("rb"))
@@ -552,6 +568,322 @@ def archive_extract_all(
matched_fct(archive_ref, extract_dir, progress_header=progress_header)
def _open_ranged(
url: str, offset: int, timeout: int, validator: str | None = None
) -> tuple["requests.Response | None", int]:
"""Open a streaming GET, asking the server to resume at ``offset``.
``validator`` is an ETag or Last-Modified value from the interrupted
response; it is sent as ``If-Range`` so the server only honors the Range
when the content is unchanged, replying 200 (full body, restart) if the
file was replaced between requests the resumed bytes can then never be
stitched onto a different file's prefix.
Returns ``(response, effective_offset)``. The response is None when the
server answered 416 Range Not Satisfiable: the file holds every byte the
server has (a previous attempt was interrupted after the last byte), so
there is nothing to stream and the caller's verification decides whether
the file is good. The offset drops to 0 when the server ignored the
``Range`` header (no 206), meaning the caller must restart the file.
Raises on connect errors and HTTP error statuses; the response is closed
on failure.
"""
import requests
headers = {"Range": f"bytes={offset}-"} if offset else {}
if offset and validator:
headers["If-Range"] = validator
resp = requests.get(url, stream=True, timeout=timeout, headers=headers)
if offset and resp.status_code == 416:
resp.close()
return None, offset
if offset and resp.status_code != 206:
_LOGGER.debug(
"Server did not resume %s (HTTP %s), restarting", url, resp.status_code
)
offset = 0
if not resp.ok:
resp.close()
resp.raise_for_status()
if offset:
_LOGGER.info("Resuming download at %d bytes ...", offset)
return resp, offset
def _verify_file(path: Path, sha256: str | None, size: int | None) -> None:
"""Raise EsphomeError when ``path`` fails an available sha256/size check."""
from esphome.core import EsphomeError
if size is not None and path.stat().st_size != size:
raise EsphomeError(f"size mismatch: expected {size}, got {path.stat().st_size}")
if sha256 is not None:
with path.open("rb") as f:
digest = hashlib.file_digest(f, "sha256").hexdigest()
if digest != sha256:
raise EsphomeError(f"sha256 mismatch: got {digest}")
def _load_download_meta(meta: Path, url: str) -> tuple[str | None, int]:
"""Return the ``(validator, total)`` a previous run recorded for ``url``.
``(None, 0)`` when there is no sidecar, it is unreadable, or it belongs
to a different URL (e.g. a different mirror was tried last time).
"""
try:
with meta.open(encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None, 0
if not isinstance(data, dict) or data.get("url") != url:
return None, 0
validator = data.get("validator")
total = data.get("total")
return (
validator if isinstance(validator, str) else None,
total if isinstance(total, int) else 0,
)
def _write_download_meta(
meta: Path, url: str, validator: str | None, total: int
) -> None:
"""Persist resume metadata next to the part file; best-effort.
Without a validator there is nothing a later run could resume against,
so any stale sidecar is removed instead.
"""
try:
if validator is None:
meta.unlink(missing_ok=True)
else:
meta.write_text(
json.dumps({"url": url, "validator": validator, "total": total}),
encoding="utf-8",
)
except OSError as e:
_LOGGER.debug("Could not update download metadata %s: %s", meta, e)
def _content_length(resp: "requests.Response") -> int:
"""Return the response's Content-Length, or 0 when absent or malformed.
0 means "unknown", which downstream disables the progress bar and the
resume/completeness logic a garbage header from a broken proxy must
degrade to a plain single-stream download, not crash the attempt.
"""
try:
return int(resp.headers.get("content-length", 0))
except ValueError:
return 0
def _response_validator(resp: "requests.Response") -> str | None:
"""Return the response's strong validator for ``If-Range`` resumes.
Weak ETags (``W/...``) are not usable for byte-range conditionals, so
fall back to Last-Modified, or None when the server offers neither.
"""
etag = resp.headers.get("ETag")
if etag and not etag.startswith("W/"):
return etag
return resp.headers.get("Last-Modified")
def _stream_response_to_file(
resp: "requests.Response", f: IO[bytes], offset: int, size: int | None = None
) -> None:
"""Stream an open ``_open_ranged`` response body into ``f`` at ``offset``.
Truncates ``f`` to ``offset`` first, so a server-rejected resume
(effective offset 0) discards the stale bytes. ``offset`` also seeds the
progress bar so a resumed download shows overall progress. ``size`` is
the known full file size; when None it is derived from the response's
content-length, and without either there is no progress bar.
"""
f.seek(offset)
f.truncate(offset)
total_size = size or offset + _content_length(resp)
downloaded = offset
progress = ProgressBar("Downloading") if total_size > 0 else None
for chunk in resp.iter_content(chunk_size=256 * 1024):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if progress is not None:
progress.update(downloaded / total_size)
if progress is not None:
progress.update(1)
def download_with_resume(
url: str,
dest: PathType,
sha256: str | None = None,
size: int | None = None,
# More attempts than _MIRROR_ATTEMPTS: a single-URL download has no
# mirror fallback, and each retry only re-fetches the remainder.
attempts: int = 5,
timeout: int = 30,
retry_connect_errors: bool = True,
) -> None:
"""Download ``url`` to ``dest``, resuming partial downloads.
The body streams into ``<dest>.part``, which persists across attempts and
esphome runs: a mid-stream connection drop only costs one attempt and the
next continues from where it stopped, so an unstable connection converges
on a complete file instead of restarting from zero each retry (#17703).
When ``size`` / ``sha256`` are given the completed file is verified and a
mismatch restarts from scratch; success renames the part file into place.
An already-present ``dest`` that passes verification is kept as-is.
Resuming a part file from an earlier run needs proof the content is
unchanged: ``sha256`` when the caller has one, or otherwise the server's
If-Range validator recorded in a ``<dest>.part.meta`` sidecar by the run
that started the download a size alone cannot detect a same-length
content change on the server.
With ``retry_connect_errors`` disabled, a failure before any body bytes
flow (connect error, HTTP error status) propagates immediately instead
of consuming attempts for callers with their own fallback, like
``download_from_mirrors``.
Raises EsphomeError when all attempts are exhausted.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only needed
# when actually downloading a toolchain, never during config validation.
import requests
from esphome.core import EsphomeError
dest = Path(dest)
part = dest.with_name(dest.name + ".part")
meta = part.with_name(part.name + ".meta")
dest.parent.mkdir(parents=True, exist_ok=True)
last_error: Exception | None = None
# An earlier run already completed this download. Only trust it when
# there is something to verify it against; without sha/size the remote
# content may have changed (e.g. a refreshed constraints file), so
# re-download and atomically replace it.
if dest.is_file() and (sha256 is not None or size is not None):
try:
_verify_file(dest, sha256, size)
return
except EsphomeError:
dest.unlink()
# Adopt the validator/total the run that started this part file recorded,
# so an unfinished download resumes across runs even without a sha256.
validator, expected_total = _load_download_meta(meta, url)
for _ in range(attempts):
streamed = False
try:
offset = part.stat().st_size if part.is_file() else 0
# A stitched resume needs two proofs: content identity (the
# bytes being appended belong to the same file as the prefix)
# and completeness. sha256 provides both, across runs. Without
# it, identity needs this run's If-Range validator — a size
# alone cannot detect a same-length content change, so a
# leftover part file from an earlier run must restart — and
# completeness needs a known total length.
if (
offset
and sha256 is None
and (validator is None or not (size or expected_total))
):
_LOGGER.debug(
"Restarting %s from zero: cannot prove a resumed "
"file correct (no sha256, validator=%s, total=%s)",
url,
validator is not None,
size or expected_total,
)
offset = 0
if size is None or offset < size:
resp, offset = _open_ranged(url, offset, timeout, validator)
# A None response means HTTP 416: the part file already holds
# every byte the server has; fall through to verification.
if resp is not None:
with resp, part.open("ab") as f:
streamed = True
if offset == 0:
validator = _response_validator(resp)
expected_total = _content_length(resp)
# Recorded so a later run can prove an If-Range
# resume of this part file safe.
_write_download_meta(meta, url, validator, expected_total)
_stream_response_to_file(resp, f, offset, size)
# else: a previous run already wrote every byte (or more) but
# was killed before the rename below. Skip the network entirely
# — a Range request past EOF would draw HTTP 416 — and let
# verification decide whether to promote the file or discard it
# and start over.
expected_size = size if size is not None else expected_total
_verify_file(part, sha256, expected_size or None)
if not expected_size and sha256 is None:
# No sha, no size, and the server sent no usable
# content-length: nothing can prove the download complete
# (urllib3 still errors on most short bodies, but not on a
# cleanly closed chunked stream). Promote with a debug
# note rather than fail or warn: some servers (e.g. the
# Espressif constraints host) never send a length, the user
# can do nothing about it, and every current caller
# extracts or parses the file afterwards, where corruption
# fails loudly.
_LOGGER.debug(
"Downloaded %s without any way to verify completeness",
dest.name,
)
# Retry on Windows sharing violations: an antivirus handle on the
# freshly-written file must not get the verified download deleted
# as corrupt by the except clause below. If even the backoff
# retries fail, keep the verified part so the next attempt (or
# run) only has to redo the rename, not the download.
try:
_rename_with_retry(part, dest, overwrite=True)
except PermissionError as e:
_LOGGER.debug("Could not move %s into place: %s", part, e)
last_error = e
continue
meta.unlink(missing_ok=True)
return
except requests.RequestException as e:
# Network failures — including connect errors, since a single
# URL has no mirror-list fallback — keep the part file for the
# next attempt (or the next esphome run) to resume from. Checked
# before OSError: RequestException subclasses IOError.
if not retry_connect_errors and not streamed:
# The caller falls back to another URL on pre-body failures.
raise
_LOGGER.debug("Download of %s interrupted: %s", url, e)
last_error = e
except (OSError, EsphomeError) as e:
# A completed-but-corrupt file (or local disk error) can't be
# trusted for resume; start over.
_LOGGER.debug("Discarding %s: %s", part, e)
part.unlink(missing_ok=True)
meta.unlink(missing_ok=True)
last_error = e
raise EsphomeError(
f"Failed to download {url} after {attempts} attempts: "
f"{_failure_reason(last_error)}"
) from last_error
def _failure_reason(e: Exception) -> str:
"""Format a download exception for the aggregated error message.
``requests`` appends " for url: <url>" to HTTP errors; the URL is already
printed on the line above, so strip the suffix to keep lines short. Falls
back to the repr for exceptions with no message (e.g. ``TimeoutError()``)
so the line always names the failure.
"""
return str(e).split(" for url: ", maxsplit=1)[0] or repr(e)
def download_from_mirrors(
mirrors: list[str],
substitutions: dict[str, str],
@@ -570,70 +902,170 @@ def download_from_mirrors(
Returns:
The source URL.
Mirror URL templates that reference a substitution not present in
``substitutions`` are skipped, so callers can offer templates that only
apply to some downloads.
A path target downloads through ``download_with_resume``, so an
interrupted download resumes on the next esphome run; a file-like target
only resumes mid-stream drops within this call.
Raises:
ValueError: If mirrors list is empty.
Exception: If all download attempts fail.
EsphomeError: If all download attempts fail; the message lists every
attempted URL with its individual failure reason. Also raised if
no template matched the provided substitutions.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only needed
# when actually downloading a toolchain, never during config validation.
# Imported lazily: requests is a heavy import (~85ms) and is only
# needed when actually downloading, never during config validation.
import requests
# 1. Open target file for writing if path given
with ExitStack() as stack:
if isinstance(target, (str, os.PathLike)):
f = stack.enter_context(Path(target).open("wb"))
elif isinstance(target, (io.RawIOBase, io.IOBase)):
f = target
else:
raise TypeError(
f"target must be str, Path, or file-like object: {type(target)}"
)
from esphome.core import EsphomeError
# 2. Try each mirror in order
last_exception = None
# 1. Classify the target: filesystem path or open file object
path_target: Path | None = None
f: IO[bytes] | None = None
if isinstance(target, (str, os.PathLike)):
path_target = Path(target)
elif isinstance(target, (io.RawIOBase, io.IOBase)):
f = target
else:
raise TypeError(
f"target must be str, Path, or file-like object: {type(target)}"
)
for mirror in mirrors:
# 3. Apply substitutions to URL
# 2. Try each mirror in order
failures: list[tuple[str, Exception]] = []
skipped: list[tuple[str, str]] = []
for mirror in mirrors:
# 3. Apply substitutions to URL
try:
url = mirror.format(**substitutions)
except KeyError as e:
# The template references a substitution not provided for
# this download (e.g. SHORT_VERSION only exists for x.y.0
# versions) - expected, the template just doesn't apply.
_LOGGER.debug("Skipping mirror %s: %s not available", mirror, e)
skipped.append((mirror, f"not applicable ({e.args[0]} not available)"))
continue
except (IndexError, ValueError) as e:
# A malformed template (unbalanced braces, bad format spec)
# is an authoring error, not an expected fallthrough - warn
# even if a later mirror succeeds.
_LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e)
skipped.append((mirror, f"skipped ({e!r})"))
continue
_LOGGER.debug("Trying downloading from %s", url)
_LOGGER.debug("Trying to download from %s", url)
# Path targets delegate to download_with_resume so a partial
# download persists (and resumes) across esphome runs.
if path_target is not None:
try:
download_with_resume(
url,
path_target,
attempts=_MIRROR_ATTEMPTS,
timeout=timeout,
# Pre-body failures (connect/HTTP errors) fall to the
# next mirror immediately; only mid-stream drops
# retry-with-resume on the same URL.
retry_connect_errors=False,
)
return url
except (requests.RequestException, OSError, EsphomeError) as e:
# Everything download_with_resume classifies as a download
# failure; programming errors propagate.
_LOGGER.debug("Failed to download %s: %s", url, str(e))
failures.append((url, e))
continue
# 4. Download; mid-stream failures retry the same mirror with
# resume (see download_with_resume) instead of starting over.
# There is no checksum to verify a resumed file against, so a
# stitch is only trusted when the server proves consistency: the
# If-Range validator guarantees 206 only for unchanged content,
# and the expected total length (when the first response carried
# one) guards against short or shifted bodies. Without a
# validator the retry restarts from zero.
offset = 0
expected_total = 0
validator = None
for attempt in range(_MIRROR_ATTEMPTS):
try:
resp, offset = _open_ranged(url, offset, timeout, validator)
except (requests.RequestException, OSError) as e:
# Connect/HTTP error, no bytes flowed — next mirror.
_LOGGER.debug("Failed to download %s: %s", url, str(e))
failures.append((url, e))
break
try:
# 4. Reset file pointer and download
f.seek(0)
f.truncate(0)
# A None response means HTTP 416: the file already holds
# every byte the server has (a drop after the last byte);
# only the length check below remains.
if resp is not None:
with resp:
if offset == 0:
validator = _response_validator(resp)
expected_total = _content_length(resp)
_stream_response_to_file(resp, f, offset)
with requests.get(url, stream=True, timeout=timeout) as r:
r.raise_for_status()
total_size = int(r.headers.get("content-length", 0))
downloaded = 0
progress = ProgressBar("Downloading") if total_size > 0 else None
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if progress is not None:
progress.update(downloaded / total_size)
if progress is not None:
progress.update(1)
if expected_total and f.tell() != expected_total:
raise EsphomeError(
f"size mismatch: expected {expected_total}, got {f.tell()}"
)
if not expected_total:
# Same trust decision as download_with_resume's
# unverifiable promotion; surface it at the same level.
_LOGGER.debug(
"Downloaded %s without any way to verify completeness",
url,
)
_LOGGER.debug("Downloaded successfully from: %s", url)
# 6. Reset file pointer and return
# 5. Reset file pointer and return
f.seek(0)
return url
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
except (requests.RequestException, OSError, EsphomeError) as e:
# Mid-stream drop: keep the received bytes and retry this
# mirror from the current position — but only when the
# server gave a validator to resume against safely AND a
# total length to prove the stitched file complete (the
# length check above is the only verification here).
_LOGGER.debug("Failed to download %s: %s", url, str(e))
last_exception = e
if validator and expected_total:
offset = f.tell()
else:
_LOGGER.debug(
"Restarting %s from zero: cannot prove a "
"resumed file complete (validator=%s, total=%s)",
url,
validator is not None,
expected_total,
)
offset = 0
if attempt == _MIRROR_ATTEMPTS - 1:
failures.append((url, e))
# 7. Raise last exception if all mirrors failed
if last_exception:
raise last_exception
raise ValueError("download_from_mirrors called with an empty mirrors list")
# 6. Report every attempted URL if all mirrors failed. Falling back
# past an early mirror is normal (e.g. only one of the framework URL
# templates matches a given version's tag), so raising only the last
# error would hide the failure that actually matters.
if failures:
attempts = "".join(
f"\n {url}\n {_failure_reason(e)}" for url, e in failures
)
attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped)
raise EsphomeError(
f"Failed to download from all mirrors:{attempts}"
) from failures[0][1]
if skipped:
details = "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped)
raise EsphomeError(
f"No mirror URL template matched the provided substitutions:{details}"
)
raise ValueError("download_from_mirrors called with an empty mirrors list")
+546 -79
View File
@@ -1,23 +1,72 @@
from collections.abc import Callable
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum, auto
import errno
import hashlib
import logging
import os
from pathlib import Path
import re
import subprocess
import sys
import time
from typing import TYPE_CHECKING
import urllib.parse
import esphome.config_validation as cv
from esphome.core import CORE, TimePeriodSeconds
from esphome.helpers import rmtree
from esphome.core import CORE, EsphomeError, TimePeriodSeconds
from esphome.helpers import add_git_ceiling_directory, rmtree, write_file
if TYPE_CHECKING:
from filelock import FileLock
_LOGGER = logging.getLogger(__name__)
# Special value to indicate never refresh
NEVER_REFRESH = TimePeriodSeconds(seconds=-1)
# revert() runs on an already-failing path; bound its wait for the cache
# entry lock so that recovery cannot hang forever behind another process.
_REVERT_LOCK_TIMEOUT_SECONDS = 60
# When a complete cache entry already exists, a caller does not wait forever
# behind another process's stalled clone or update (git sets no network
# timeouts): after this bound it uses the existing clone without refreshing
# it. With no complete entry there is nothing to fall back to, so the wait
# is unbounded.
_COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS = 60
# Written inside .git only while the entry is a complete, quiescent
# checkout: after every clone step (clone, ref fetch, reset, submodule init)
# has finished, and removed for the duration of a refresh's rewrite
# (stash/fetch/reset). A directory without it is an interrupted clone or
# update (e.g. the process was killed mid-clone) and must be re-cloned;
# without this check such a directory would be trusted forever when the
# caller uses NEVER_REFRESH, and the bounded-wait fallback would hand a
# mid-rewrite tree to a timed-out peer. Lives in .git so
# stash/reset/checkout can never touch it and it does not pollute the
# worktree.
_CLONE_COMPLETE_MARKER = "esphome_clone_complete"
# Environment variables that scope git to a specific repository. Git hooks and
# some CI wrappers export these; if they leak into the git commands run here,
# git binds to the caller's repository instead of the one being managed. The
# effects range from loud (`git clone` producing a bare-style directory with
# no working tree) to silent (an ambient GIT_INDEX_FILE makes
# `git submodule update --init` exit 0 without initializing anything).
_GIT_REPO_SCOPING_ENV = frozenset(
{
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_COMMON_DIR",
"GIT_NAMESPACE",
}
)
class GitException(cv.Invalid):
"""Base exception for git-related errors."""
@@ -35,32 +84,61 @@ class GitRepositoryError(GitException):
"""Exception raised when a git repository is in an invalid state."""
def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str:
if git_dir is not None:
_LOGGER.debug(
"Running git command with repository isolation: %s (git_dir=%s)",
" ".join(cmd),
git_dir,
)
else:
_LOGGER.debug("Running git command: %s", " ".join(cmd))
def _redact_url_credentials(text: str) -> str:
"""Mask userinfo in any URLs embedded in ``text``.
# Set up environment for repository isolation if git_dir is provided
# Force git to only operate on this specific repository by setting
# GIT_DIR and GIT_WORK_TREE. This prevents git from walking up the
# directory tree to find parent repositories when the target repo's
# .git directory is corrupt. Without this, commands like 'git stash'
# could accidentally operate on parent repositories (e.g., the main
# ESPHome repo) instead of failing, causing data loss.
env: dict[str, str] | None = None
cwd: str | None = None
Users can put credentials directly in a git URL, and log output is
routinely pasted into public issues.
"""
return re.sub(r"://[^/@\s]+@", "://***@", text)
def run_git_command(
cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None
) -> str:
"""Run a git command and return its stdout.
The repository-scoping environment variables in ``_GIT_REPO_SCOPING_ENV``
are always stripped. ``git_dir`` additionally pins GIT_DIR/GIT_WORK_TREE
to that repository and runs the command there; ``cwd`` alone runs the
command in that directory with GIT_CEILING_DIRECTORIES capping repository
discovery at its parent.
"""
# Every invocation starts from an environment with the repository-scoping
# variables stripped (see _GIT_REPO_SCOPING_ENV) so a git hook or CI
# wrapper invoking ESPHome can never redirect these commands to its own
# repository or index.
#
# ``git_dir`` then re-adds GIT_DIR and GIT_WORK_TREE pointing at the
# managed repository. This prevents git from walking up the directory
# tree to find parent repositories when the target repo's .git directory
# is corrupt. Without this, commands like 'git stash' could accidentally
# operate on parent repositories (e.g., the main ESPHome repo) instead of
# failing, causing data loss.
#
# ``cwd`` (without ``git_dir``) runs the command in that directory
# without GIT_DIR/GIT_WORK_TREE. The ``git submodule`` porcelain needs
# this: on some installations (e.g. Windows setups where a shim hands
# git untranslated paths) it refuses to run when GIT_DIR/GIT_WORK_TREE
# are set, failing with "cannot be used without a working tree".
# GIT_CEILING_DIRECTORIES (which git only honors as an absolute path)
# keeps the parent-repo-walk protection instead: if the repo's .git is
# missing or corrupt, git fails rather than discovering an enclosing
# repository.
env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_SCOPING_ENV}
if git_dir is not None:
env = {
**subprocess.os.environ,
"GIT_DIR": str(Path(git_dir) / ".git"),
"GIT_WORK_TREE": str(git_dir),
}
cwd = str(git_dir)
env["GIT_DIR"] = str(Path(git_dir) / ".git")
env["GIT_WORK_TREE"] = str(git_dir)
cwd = git_dir
elif cwd is not None:
add_git_ceiling_directory(env, Path(cwd).absolute().parent)
_LOGGER.debug(
"Running git command: %s (cwd=%s, isolated=%s)",
_redact_url_credentials(" ".join(cmd)),
cwd,
git_dir is not None,
)
try:
ret = subprocess.run(
@@ -78,16 +156,31 @@ def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str:
"for installation instructions."
) from err
if ret.returncode != 0 and ret.stderr:
err_str = ret.stderr.decode("utf-8")
lines = [x.strip() for x in err_str.splitlines()]
if lines[-1].startswith("fatal:"):
raise GitCommandError(lines[-1][len("fatal: ") :])
raise GitCommandError(err_str)
if ret.returncode != 0:
if ret.stderr:
err_str = ret.stderr.decode("utf-8")
lines = [x.strip() for x in err_str.splitlines()]
if lines[-1].startswith("fatal:"):
raise GitCommandError(lines[-1][len("fatal: ") :])
raise GitCommandError(err_str)
raise GitCommandError(
f"git exited with code {ret.returncode}: "
f"{_redact_url_credentials(' '.join(cmd))}"
)
return ret.stdout.decode("utf-8").strip()
def _cache_key(url: str, ref: str | None) -> str:
"""Cache key identifying one repository checkout.
The lock path and the entry directory both hash this, keeping them in
agreement. (micro_wake_word still rebuilds the format by hand to locate
manifests; fold it in here if the format ever changes.)
"""
return f"{url}@{ref}"
def _compute_destination_path(key: str, domain: str) -> Path:
base_dir = Path(CORE.data_dir) / domain
h = hashlib.new("sha256")
@@ -95,6 +188,220 @@ def _compute_destination_path(key: str, domain: str) -> Path:
return base_dir / h.hexdigest()[:8]
def _repo_entry_dir(key: str, domain: str, subpath: Path | None) -> Path:
"""Worktree directory of one cache entry: the hash dir plus optional subpath."""
repo_dir = _compute_destination_path(key, domain)
if subpath:
repo_dir = repo_dir / subpath
return repo_dir
def _repo_lock_path(key: str, domain: str) -> Path:
"""Path of the lock file serializing all work on one cache entry.
Lives next to the hash directory, never inside it, so the removal of a
broken or incomplete clone can never delete a lock another process holds.
"""
repo_dir = _compute_destination_path(key, domain)
return repo_dir.parent / f"{repo_dir.name}.lock"
class _LockStatus(Enum):
ACQUIRED = auto()
# A bounded wait expired while another process held the lock.
TIMEOUT = auto()
# The lock could not be taken at all; callers proceed unlocked,
# matching the behavior before the lock existed.
UNAVAILABLE = auto()
# Errnos that mean the filesystem genuinely cannot take file locks (NFS
# without a lock daemon, some FUSE mounts). Any other OSError (permissions,
# read-only volume, full disk) is a cache directory problem, which the git
# commands themselves report clearly when it actually matters. EPERM is
# deliberately absent: it usually means a permissions problem, so it takes
# the generic message that names no cause. On Linux ENOTSUP and EOPNOTSUPP
# are the same value; the set folds them.
_NO_LOCK_SUPPORT_ERRNOS = frozenset(
{errno.ENOLCK, errno.ENOSYS, errno.EOPNOTSUPP, errno.ENOTSUP}
)
def _acquire_repo_lock(
lock: "FileLock",
safe_key: str,
timeout: float,
wait_message: str = "Waiting for another process to finish updating %s",
) -> _LockStatus:
"""Acquire ``lock``, logging ``wait_message`` when a wait actually begins.
``timeout`` of -1 waits forever; a positive value bounds the wait and
can yield ``TIMEOUT``.
"""
from filelock import Timeout
try:
try:
lock.acquire(blocking=False)
except Timeout:
# Waiting on another process's clone or update can take
# minutes; say so instead of appearing hung.
_LOGGER.info(wait_message, safe_key)
lock.acquire(timeout=timeout)
except Timeout:
return _LockStatus.TIMEOUT
except OSError as err:
if err.errno in _NO_LOCK_SUPPORT_ERRNOS:
_LOGGER.warning(
"The filesystem does not support locking the cache entry for "
"%s (%s), continuing without a lock",
safe_key,
err,
)
else:
# Not a locking problem (permissions, read-only volume, full
# disk). Still continue unlocked: a pre-seeded read-only cache
# with refresh disabled only reads and must keep working, and
# in every other case the git commands fail with the real error.
_LOGGER.warning(
"Could not take the cache entry lock for %s (%s), "
"continuing without a lock",
safe_key,
err,
)
return _LockStatus.UNAVAILABLE
return _LockStatus.ACQUIRED
@contextmanager
def _repo_cache_lock(
key: str, domain: str, repo_dir: Path
) -> Iterator[tuple[bool, "FileLock | None"]]:
"""Hold the cache entry lock for ``key`` over the with block.
Yields ``(use_existing, lock)``. ``use_existing`` is True when the lock
could not be acquired within the bounded wait but ``repo_dir`` is a
complete cache entry; the caller should use it as-is and do nothing
else. Otherwise ``lock`` is the held lock, released when the block
exits, or ``None`` when the lock could not be taken at all and the
caller proceeds unlocked.
"""
# Lazy import: keeps filelock off the CLI startup import path.
from filelock import FileLock
safe_key = _redact_url_credentials(key)
# acquire() creates the lock file's directory itself; git clone later
# creates the hash directory next to it. fallback_to_soft would silently
# downgrade ENOSYS to a SoftFileLock, whose stale existence marker from
# another host on a shared cache could hang the unbounded wait forever;
# routing it through the OSError handler runs unlocked instead.
lock: FileLock | None = FileLock(
str(_repo_lock_path(key, domain)), fallback_to_soft=False
)
status = _acquire_repo_lock(lock, safe_key, _COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS)
if status is _LockStatus.TIMEOUT:
if _clone_complete_marker_path(repo_dir).is_file():
# Mutual exclusion matters most while no complete entry exists
# (initial clone, recovery re-clone); with one on disk, reading
# it beats hanging behind a stalled holder.
_LOGGER.warning(
"Timed out waiting for another process updating %s, proceeding "
"with the existing clone, which that process may still be "
"changing",
safe_key,
)
yield True, None
return
# Nothing to fall back to; the holder is producing the clone this
# caller needs.
status = _acquire_repo_lock(
lock,
safe_key,
timeout=-1,
wait_message="Still waiting for the clone of %s, "
"there is no existing clone to fall back on",
)
if status is not _LockStatus.ACQUIRED:
lock = None
try:
yield False, lock
finally:
if lock is not None:
lock.release()
def _clone_complete_marker_path(repo_dir: Path) -> Path:
return repo_dir / ".git" / _CLONE_COMPLETE_MARKER
def _clear_clone_complete_marker(repo_dir: Path) -> None:
"""Best-effort removal of the completion marker.
If the unlink fails (e.g. a file lock on Windows), the marker stays and
the entry keeps its previous trust level; every consumer of the marker
tolerates that.
"""
try:
_clone_complete_marker_path(repo_dir).unlink(missing_ok=True)
except OSError as err:
_LOGGER.debug("Could not delete clone completion marker: %s", err)
def _write_clone_complete_marker(
repo_dir: Path, key: str, hash_dir_name: str, safe_key: str
) -> None:
"""Mark the entry as a complete, quiescent checkout.
The key and hash dir name are recorded purely to make cache debugging
easier. The marker is only a validity signal, so a failed write must not
fail an otherwise complete clone or update: the only cost is a re-clone
on the next run.
"""
try:
write_file(
_clone_complete_marker_path(repo_dir),
f"key={key}\nhash={hash_dir_name}\n",
)
except EsphomeError as err:
_LOGGER.warning(
"Could not write clone completion marker for %s: %s", safe_key, err
)
def _remove_repo_dir(repo_dir: Path) -> None:
"""Remove a repo directory, deleting the completion marker first.
Marker-first ordering guarantees an interrupted removal can never leave a
marker behind next to a partially deleted worktree. The unlink is best
effort: if it fails, rmtree below still gets the chance to remove the
directory, marker included.
"""
_clear_clone_complete_marker(repo_dir)
if repo_dir.is_dir():
rmtree(repo_dir)
def update_submodules(repo_dir: Path, key: str) -> None:
"""Initialize/update every submodule the repository declares, recursively,
matching how PlatformIO clones libraries.
Most repositories declare no submodules, so this does nothing when there
is no ``.gitmodules`` file. Which submodules get populated is git's own
policy (``update = none``, ``submodule.active``, sparse checkouts);
git's exit code is the error signal.
Runs with plain ``cwd`` rather than ``git_dir`` isolation, which the
``git submodule`` porcelain does not tolerate (see ``run_git_command``).
"""
if not (repo_dir / ".gitmodules").is_file():
return
_LOGGER.info("Updating submodules for %s", _redact_url_credentials(key))
run_git_command(
["git", "submodule", "update", "--init", "--recursive", "--depth=1"],
cwd=repo_dir,
)
def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None:
"""Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub.
@@ -184,28 +491,106 @@ def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None:
def clone_or_update(
*,
url: str,
ref: str = None,
ref: str | None = None,
refresh: TimePeriodSeconds | None,
domain: str,
username: str = None,
password: str = None,
submodules: list[str] | None = None,
username: str | None = None,
password: str | None = None,
init_submodules: bool = False,
subpath: Path | None = None,
_recover_broken: bool = True,
) -> tuple[Path, Callable[[], None] | None]:
key = f"{url}@{ref}"
) -> tuple[Path, Callable[[], bool] | None]:
"""Clone a repository into the cache, or refresh an existing clone.
All work runs under a per-cache-entry inter-process file lock, so
concurrent resolutions of the same repository (two esphome processes, or
a subprocess plus an in-process load) serialize instead of interleaving.
Without the lock, ``repo_dir.is_dir()`` is true from the instant
``git clone`` creates the directory: a second caller could read a half
populated worktree, or see the missing completion marker and delete the
clone in progress out from under the first caller.
The lock guards mutation of the cache entry only; it is released when
this function returns, so a caller still reading the worktree can
overlap a later refresh by another process. That residual window is
narrow (the refresh interval is re-checked under the lock) and predates
the lock.
Locking is best effort: on a filesystem that cannot take file locks a
warning is logged and the work proceeds unlocked, matching the behavior
before the lock existed. A complete cache entry also caps the wait: if
the holder is still busy after a bounded time (e.g. stalled on the
network), the existing clone is used without refreshing it, so a stuck
process cannot hang every peer that already has a good entry.
"""
key = _cache_key(url, ref)
repo_dir = _repo_entry_dir(key, domain, subpath)
with _repo_cache_lock(key, domain, repo_dir) as (use_existing, lock):
if use_existing:
return repo_dir, None
return _clone_or_update_locked(
url=url,
ref=ref,
refresh=refresh,
domain=domain,
username=username,
password=password,
init_submodules=init_submodules,
subpath=subpath,
lock=lock,
)
def _clone_or_update_locked(
*,
url: str,
ref: str | None,
refresh: TimePeriodSeconds | None,
domain: str,
username: str | None,
password: str | None,
init_submodules: bool,
subpath: Path | None,
lock: "FileLock | None",
_recover_broken: bool = True,
) -> tuple[Path, Callable[[], bool] | None]:
"""Body of ``clone_or_update``; the caller holds ``lock``.
Split out because the broken-repository recovery below re-enters this
function: re-acquiring the already-held lock would deadlock, since OS
file locks taken on separate file descriptors conflict even within one
process. ``lock`` is only re-acquired by the returned ``revert``
callback, which runs after the wrapper's ``finally`` has released it.
``lock`` is ``None`` when the filesystem cannot take file locks and the
wrapper fell back to running unlocked.
"""
key = _cache_key(url, ref)
# The user may have embedded credentials in the URL itself; log this
# instead of key.
safe_key = _redact_url_credentials(key)
# Keep the caller's URL for the recovery re-clone below: rewriting the
# rewritten URL would double the userinfo, and the recursive call must
# compute the same cache key as this one.
original_url = url
if username is not None and password is not None:
url = url.replace(
"://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@"
)
repo_dir = _compute_destination_path(key, domain)
if subpath:
repo_dir = repo_dir / subpath
hash_dir_name = _compute_destination_path(key, domain).name
repo_dir = _repo_entry_dir(key, domain, subpath)
if repo_dir.is_dir() and not _clone_complete_marker_path(repo_dir).is_file():
# The last clone never finished (killed process, container stop) or
# predates the marker; either way it cannot be trusted, especially
# with NEVER_REFRESH where it would otherwise be reused forever.
_LOGGER.warning(
"Removing incomplete clone of %s at %s, will re-clone", safe_key, repo_dir
)
_remove_repo_dir(repo_dir)
if not repo_dir.is_dir():
_LOGGER.info("Cloning %s", key)
_LOGGER.info("Cloning %s", safe_key)
_LOGGER.debug("Location: %s", repo_dir)
try:
cmd = ["git", "clone", "--depth=1"]
@@ -224,33 +609,35 @@ def clone_or_update(
["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir
)
if submodules is not None:
_LOGGER.info(
"Initializing submodules (%s) for %s", ", ".join(submodules), key
)
run_git_command(
["git", "submodule", "update", "--init", "--depth=1", "--"]
+ submodules,
git_dir=repo_dir,
)
if init_submodules:
update_submodules(repo_dir, key)
except GitException:
# Remove incomplete clone to prevent stale state. Without this,
# a failed ref fetch leaves a clone on the default branch, and
# subsequent calls skip the update due to the refresh window.
if repo_dir.is_dir():
rmtree(repo_dir)
_remove_repo_dir(repo_dir)
raise
# Every git step succeeded.
_write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key)
else:
if refresh == NEVER_REFRESH or CORE.skip_external_update:
_LOGGER.debug("Skipping update for %s (refresh disabled)", key)
_LOGGER.debug("Skipping update for %s (refresh disabled)", safe_key)
return repo_dir, None
file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD")
# On first clone, FETCH_HEAD does not exist
if not file_timestamp.exists():
file_timestamp = Path(repo_dir / ".git" / "HEAD")
age_seconds = time.time() - file_timestamp.stat().st_mtime
try:
age_seconds = time.time() - file_timestamp.stat().st_mtime
except OSError:
# A .git with neither FETCH_HEAD nor HEAD is corrupt (e.g. a
# partially deleted clone). Force the update path so the
# broken-repository recovery below removes and re-clones it.
age_seconds = float("inf")
if refresh is None or age_seconds > refresh.total_seconds:
# Try to update the repository, recovering from broken state if needed
old_sha: str | None = None
@@ -261,9 +648,16 @@ def clone_or_update(
["git", "rev-parse", "HEAD"], git_dir=repo_dir
)
_LOGGER.info("Updating %s", key)
_LOGGER.info("Updating %s", safe_key)
_LOGGER.debug("Location: %s", repo_dir)
# The entry is about to be rewritten; drop the marker so a
# timed-out peer's fallback and the incomplete-entry check
# can tell a quiescent complete entry from one mid-rewrite,
# and so an update interrupted by a crash re-clones instead
# of being trusted.
_clear_clone_complete_marker(repo_dir)
# Stash local changes (if any)
# Use git_dir to ensure this only affects the specific repo
run_git_command(
@@ -287,53 +681,126 @@ def clone_or_update(
["git", "reset", "--hard", "FETCH_HEAD"],
git_dir=repo_dir,
)
# Inside the try so a submodule failure routes through the
# recovery re-clone below instead of leaving a repo that the
# refresh window would silently accept on the next run.
if init_submodules:
update_submodules(repo_dir, key)
# Recorded so revert() can tell whether the checkout is
# still the one this update produced.
new_sha = run_git_command(
["git", "rev-parse", "HEAD"], git_dir=repo_dir
)
# The rewrite finished; the entry is trustworthy again.
_write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key)
except GitException as err:
# Repository is in a broken state or update failed
# Only attempt recovery once to prevent infinite recursion
if not _recover_broken:
_LOGGER.error(
"Repository %s recovery failed, cannot retry (already attempted once)",
key,
safe_key,
)
raise
_LOGGER.warning(
"Repository %s has issues (%s), attempting recovery",
key,
safe_key,
err,
)
_LOGGER.info("Removing broken repository at %s", repo_dir)
rmtree(repo_dir)
_remove_repo_dir(repo_dir)
_LOGGER.info("Successfully removed broken repository, re-cloning...")
# Recursively call clone_or_update to re-clone
# Set _recover_broken=False to prevent infinite recursion
result = clone_or_update(
url=url,
# Re-clone while still holding the lock; going through the
# public wrapper would try to re-acquire it and deadlock.
# Set _recover_broken=False to prevent infinite recursion.
result = _clone_or_update_locked(
url=original_url,
ref=ref,
refresh=refresh,
domain=domain,
username=username,
password=password,
submodules=submodules,
init_submodules=init_submodules,
subpath=subpath,
lock=lock,
_recover_broken=False,
)
_LOGGER.info("Repository %s successfully recovered", key)
_LOGGER.info("Repository %s successfully recovered", safe_key)
return result
if submodules is not None:
_LOGGER.info(
"Updating submodules (%s) for %s", ", ".join(submodules), key
)
run_git_command(
["git", "submodule", "update", "--init", "--depth=1", "--"]
+ submodules,
git_dir=repo_dir,
)
def revert() -> bool:
"""Reset the checkout to the pre-update SHA.
def revert():
_LOGGER.info("Reverting changes to %s -> %s", key, old_sha)
run_git_command(["git", "reset", "--hard", old_sha], git_dir=repo_dir)
Returns False when the revert did not happen: the cache
entry lock could not be acquired in time, the checkout
moved since this update (another process refreshed it), or
the reset itself failed. A retry cannot reach the
pre-update content then.
"""
if lock is None:
# The wrapper already warned about the unlockable
# filesystem; revert unlocked like everything else.
status = _LockStatus.UNAVAILABLE
else:
status = _acquire_repo_lock(
lock, safe_key, _REVERT_LOCK_TIMEOUT_SECONDS
)
if status is _LockStatus.TIMEOUT:
# revert() only runs on an already-failing path; skip
# rather than hang so the original error can surface.
_LOGGER.warning(
"Could not lock %s to revert to %s, skipping revert; "
"the cached checkout keeps the un-reverted content "
"until its next refresh",
safe_key,
old_sha,
)
return False
try:
# Anything can happen between the wrapper releasing the
# lock and revert() re-acquiring it; only undo this
# process's own update, never a peer's newer refresh.
head = run_git_command(
["git", "rev-parse", "HEAD"], git_dir=repo_dir
)
if head != new_sha:
_LOGGER.warning(
"Not reverting %s: the checkout moved since this "
"update (another process refreshed it)",
safe_key,
)
return False
# Announced only once every skip check has passed, so
# the log says exactly one thing per outcome.
_LOGGER.info("Reverting changes to %s -> %s", safe_key, old_sha)
run_git_command(
["git", "reset", "--hard", old_sha], git_dir=repo_dir
)
except GitException as err:
# GitException is a cv.Invalid; letting it escape would
# replace the caller's original error with a bare git
# message. Report the failed reset like the skip above,
# and drop the marker: an entry whose reset fails cannot
# be trusted, so the next use re-clones it instead of
# the refresh window silently accepting it.
_LOGGER.warning(
"Could not revert %s to %s (%s), the entry will be "
"re-cloned on next use",
safe_key,
old_sha,
err,
)
_clear_clone_complete_marker(repo_dir)
return False
finally:
if status is _LockStatus.ACQUIRED:
lock.release()
return True
return repo_dir, revert
+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:
+56 -13
View File
@@ -25,7 +25,7 @@ from pathlib import Path
import re
import tempfile
from typing import Any
from urllib.parse import urlparse, urlsplit, urlunsplit
from urllib.parse import urlsplit, urlunsplit
from esphome import git
from esphome.core import CORE, Library
@@ -134,7 +134,7 @@ class GitSource(Source):
ref=self.ref,
refresh=git.NEVER_REFRESH if not force else None,
domain=domain,
submodules=[],
init_submodules=True,
subpath=Path(dir_suffix),
)
return path
@@ -292,6 +292,12 @@ def collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[st
for root, _, files in os.walk(item):
matched.extend([str(Path(root) / f) for f in files])
# glob keeps the pattern's literal separators for non-wildcard path
# components, so on Windows the same file can surface with different
# separators depending on where the wildcards sit; normalize so the
# include/exclude set operations below compare equal paths.
matched = [os.path.normpath(m) for m in matched]
# FILTER_REGEX only ever captures "+" or "-", so the else is the "-" case.
if sign == "+":
selected.update(matched)
@@ -517,6 +523,17 @@ class _LibNode:
edges: set[str] = field(default_factory=set)
def _url_or_none(value: Any) -> str | None:
"""Return ``value`` if it parses as a URL (scheme and host), else None."""
if not value or not isinstance(value, str):
return None
try:
parsed = urlsplit(value)
except ValueError:
return None
return value if parsed.scheme and parsed.netloc else None
def _node_key(
name: str | None, version: str | None, repository: str | None
) -> tuple[str, bool, tuple[str | None, str | None]]:
@@ -527,9 +544,23 @@ def _node_key(
inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps
to distinct keys and isn't deduplicated; ``convert_libraries`` warns about
that after resolution rather than merging the nodes.
PlatformIO's Library Manager also accepted a git URL in the *name*
position (``add_library("https://github.com/x/y", None)``), including the
``git+`` VCS prefix and the ``CustomName=URL`` form; recognize those here
so such specs resolve as git sources instead of failing a registry lookup.
"""
if not repository and name and "://" in name:
# Try the whole name first so a bare URL whose query contains ``=``
# stays intact; fall back to the ``CustomName=URL`` form, where the
# key derives from the URL path and the custom name is irrelevant.
repository = _url_or_none(name) or _url_or_none(name.split("=", 1)[-1])
if repository is None:
# Anything with ``://`` was meant to be a URL; failing it fast
# beats a confusing registry "package not found" error.
raise RuntimeError(f"Invalid PIO library URL: {name}")
if repository:
split_result = urlsplit(repository)
split_result = urlsplit(repository.removeprefix("git+"))
key = str(split_result.path).strip("/").removesuffix(".git")
ref = split_result.fragment.strip() or None
url = urlunsplit(split_result._replace(fragment=""))
@@ -638,14 +669,30 @@ def convert_libraries(
library_json_path = component.path / "library.json"
library_properties_path = component.path / "library.properties"
if library_json_path.is_file():
has_json = library_json_path.is_file()
has_properties = library_properties_path.is_file()
if not has_json and not has_properties:
# The shared cache can hold a broken copy (e.g. a clone or an
# extraction interrupted by a killed process). Force one
# re-download so a bad cache entry self-heals instead of failing
# every build until the user runs a full clean.
_LOGGER.warning(
"Library %s at %s is missing library.json and library.properties; "
"re-downloading",
key,
component.path,
)
component.download(force=True, salt=salt, namespace=backend.cache_key)
has_json = library_json_path.is_file()
has_properties = library_properties_path.is_file()
if has_json:
component.data = _parse_library_json(library_json_path)
elif library_properties_path.is_file():
elif has_properties:
component.data = _parse_library_properties(library_properties_path)
else:
raise RuntimeError(
f"Invalid PIO library {key}: missing library.json and "
"library.properties"
f"library.properties in {component.path}"
)
try:
@@ -681,13 +728,9 @@ def convert_libraries(
continue
# The version field may actually be a URL (git/archive dependency).
dep_version = dependency["version"]
dep_url = None
try:
parsed = urlparse(dep_version)
if all([parsed.scheme, parsed.netloc]):
dep_url, dep_version = dep_version, None
except (TypeError, ValueError):
pass
dep_url = _url_or_none(dep_version)
if dep_url is not None:
dep_version = None
dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key)
worklist.append(dep_key)
+186 -1
View File
@@ -1,17 +1,35 @@
from collections.abc import Iterable
import json
import logging
import os
from pathlib import Path
import re
import sys
from typing import TYPE_CHECKING
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
from esphome.core import CORE, EsphomeError
from esphome.helpers import add_git_ceiling_directory
from esphome.helpers import add_git_ceiling_directory, rmtree, write_file
from esphome.util import FlashImage, run_external_process
if TYPE_CHECKING:
from platformio.project.config import ProjectConfig
_LOGGER = logging.getLogger(__name__)
# PlatformIO cache subdirs resolved via ProjectConfig. A full ``clean-all`` wipes
# these plus the whole ``core_dir``; a Python-version heal wipes these plus the
# penv while keeping ``core_dir`` (so the sibling stamp/lock survive).
_PIO_CACHE_DIRS = ("cache_dir", "packages_dir", "platforms_dir")
# Marker recording the Python major.minor the PlatformIO cache was provisioned
# under, plus the lock guarding the check/wipe. Both live in the dir resolved
# by ``_pio_stamp_dir`` (NOT wiped by the heal), so they survive the wipe and
# are rewritten after it.
_PIO_PYTHON_STAMP_FILE = ".esphome.pio.stamp.json"
_PIO_PYTHON_STAMP_LOCK = ".esphome.pio.stamp.lock"
_PIO_PYTHON_STAMP_SCHEMA = "0"
def _strip_win_long_path_prefix(path: str) -> str:
r"""Strip the Windows extended-length path prefix from ``path``.
@@ -44,7 +62,174 @@ def _strip_win_long_path_prefix(path: str) -> str:
return path
def get_platformio_config() -> "ProjectConfig | None":
"""Return PlatformIO's ``ProjectConfig``, or None when PlatformIO is absent."""
try:
from platformio.project.config import ProjectConfig
except ImportError:
return None
return ProjectConfig.get_instance()
def _pio_stamp_dir(config: "ProjectConfig") -> Path:
"""Return the persistent home for the python-version stamp and lock.
The parent of ``platforms_dir``, not ``core_dir``: the container/add-on
images relocate the platform/package caches to a persistent volume while
``core_dir`` stays at the ephemeral default (its ``appstate.json`` must not
move), so a stamp under ``core_dir`` would be wiped on every image update
while the stale cache it guards survives. Everywhere else ``platforms_dir``
sits inside ``core_dir`` and this resolves to ``core_dir``.
"""
return Path(config.get("platformio", "platforms_dir")).parent
def _delete_platformio_dirs(config: "ProjectConfig", pio_dirs: Iterable[str]) -> None:
"""Delete each named PlatformIO dir resolved from *config*."""
for pio_dir in pio_dirs:
path = Path(config.get("platformio", pio_dir))
if path.is_dir():
_LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path)
rmtree(path)
def clean_platformio_cache() -> None:
"""Wipe the whole PlatformIO cache (cache/packages/platforms/core).
The full set ``clean-all`` (Reset Build Environment) clears. No-op when
PlatformIO is unavailable.
"""
config = get_platformio_config()
if config is None:
return
_delete_platformio_dirs(config, [*_PIO_CACHE_DIRS, "core_dir"])
def _clean_platformio_python_env(config: "ProjectConfig", core_dir: Path) -> None:
"""Wipe the cache subdirs + penv for a Python-version change.
Keeps ``core_dir`` itself (and the stamp/lock siblings under it); otherwise
the same cache set ``clean-all`` clears.
"""
_delete_platformio_dirs(config, _PIO_CACHE_DIRS)
penv = core_dir / "penv"
if penv.is_dir():
_LOGGER.info("Deleting PlatformIO penv %s", penv)
rmtree(penv)
def _current_python_minor() -> str:
"""Return the running interpreter's ``major.minor`` (e.g. ``3.13``)."""
return f"{sys.version_info.major}.{sys.version_info.minor}"
def _read_pio_stamp_python(stamp_file: Path) -> str | None:
"""Return the ``python_version`` recorded in *stamp_file*, or None."""
try:
with stamp_file.open(encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return None
except (json.JSONDecodeError, OSError) as err:
# A present-but-unreadable stamp is a distinct signal from an absent
# one, and it drives a cache clean; surface why at normal verbosity.
_LOGGER.warning("Could not read %s: %s", stamp_file, err)
return None
if not isinstance(data, dict):
return None
version = data.get("python_version")
return version if isinstance(version, str) else None
def _write_pio_stamp_python(stamp_file: Path, python_version: str) -> None:
"""Atomically write the PlatformIO python-version stamp."""
write_file(
stamp_file,
json.dumps(
{
"schema_version": _PIO_PYTHON_STAMP_SCHEMA,
"python_version": python_version,
}
),
)
def heal_platformio_python_env() -> None:
"""Wipe the PlatformIO cache unless it is stamped for the running Python.
A PlatformIO platform/tool package pins the Python versions it accepts when
it is provisioned, and ESPHome pins platforms to exact, immutable versions,
so a later interpreter bump (a container upgrading its base Python) leaves
the cached platform rejecting the new interpreter ("Python version must be
between ...") until the cache is wiped. A stamp records the ``major.minor``
the cache was provisioned for; when it doesn't match the running
interpreter (or has never been written for an existing cache), the same
PlatformIO dirs ``clean-all`` wipes are cleaned so PlatformIO
re-provisions, matching Reset Build Environment automatically. The native
ESP-IDF toolchain already self-heals through its own stamp; this covers the
PlatformIO path. No-op when PlatformIO is unavailable.
"""
config = get_platformio_config()
if config is None:
return
try:
_check_platformio_python_stamp(config)
except (EsphomeError, OSError) as err:
# The check is a best-effort repair; a full or read-only cache volume
# must not abort a build that might otherwise work. The stamp write
# surfaces as EsphomeError (write_file wraps OSError).
_LOGGER.warning("PlatformIO build environment check failed: %s", err)
def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
"""Compare the stamp to the running interpreter; wipe and restamp on mismatch."""
current = _current_python_minor()
stamp_dir = _pio_stamp_dir(config)
# Host the stamp/lock even before PlatformIO's first run creates the dir.
stamp_dir.mkdir(parents=True, exist_ok=True)
stamp_file = stamp_dir / _PIO_PYTHON_STAMP_FILE
from filelock import FileLock
with FileLock(str(stamp_dir / _PIO_PYTHON_STAMP_LOCK)):
provisioned = _read_pio_stamp_python(stamp_file)
if provisioned == current:
return
core_dir = Path(config.get("platformio", "core_dir"))
has_cache = (
any(
Path(config.get("platformio", pio_dir)).is_dir()
for pio_dir in _PIO_CACHE_DIRS
)
or (core_dir / "penv").is_dir()
)
if has_cache:
if provisioned is None:
# An existing cache with no stamp predates the stamp: its
# provisioning interpreter is unknown, so clean once rather
# than leave a possibly-stale cache failing every build.
_LOGGER.info(
"Cleaning the PlatformIO build environment once so it "
"re-provisions for Python %s",
current,
)
else:
_LOGGER.info(
"Python version changed (%s -> %s); cleaning PlatformIO "
"build environment so it re-provisions for the new "
"interpreter",
provisioned,
current,
)
_clean_platformio_python_env(config, core_dir)
_write_pio_stamp_python(stamp_file, current)
def run_platformio_cli(*args, **kwargs) -> str | int:
# Re-provision the PlatformIO cache if the interpreter's major.minor changed
# since it was last built; a stale platform otherwise rejects the new Python
# with "Python version must be between ..." until Reset Build Environment.
heal_platformio_python_env()
os.environ["PLATFORMIO_FORCE_COLOR"] = "true"
os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute())
os.environ.setdefault(
+3 -12
View File
@@ -670,18 +670,9 @@ def clean_all(configuration: list[str]):
rmtree(install_path)
# Clean PlatformIO project files
try:
from platformio.project.config import ProjectConfig
except ImportError:
# PlatformIO is not available, skip cleaning
pass
else:
config = ProjectConfig.get_instance()
for pio_dir in ["cache_dir", "packages_dir", "platforms_dir", "core_dir"]:
path = Path(config.get("platformio", pio_dir))
if path.is_dir():
_LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path)
rmtree(path)
from esphome.platformio.toolchain import clean_platformio_cache
clean_platformio_cache()
GITIGNORE_CONTENT = """# Gitignore settings for ESPHome
+3 -3
View File
@@ -143,8 +143,8 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script
extends = common:arduino
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip
platform_packages =
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.10/esp32-core-3.3.10.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz
framework = arduino, espidf ; Arduino as an ESP-IDF component
lib_deps =
@@ -180,7 +180,7 @@ extra_scripts =
extends = common:idf
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip
platform_packages =
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz
framework = espidf
lib_deps =
+7 -3
View File
@@ -1,4 +1,7 @@
cryptography==49.0.0
# cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there.
# Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs.
cryptography==49.0.0; platform_system != "Darwin" or platform_machine != "x86_64"
cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64"
voluptuous==0.16.0
PyYAML==6.0.3
paho-mqtt==1.6.1
@@ -9,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.3.1
click==8.3.3
aioesphomeapi==45.6.0
aioesphomeapi==45.7.0
zeroconf==0.150.0
puremagic==2.2.0
ruamel.yaml==0.19.1 # dashboard_import
@@ -23,7 +26,8 @@ bleak==2.1.1
smpclient==7.2.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.10.0 # native esp-idf toolchain global cache dir
platformdirs==4.11.0 # native esp-idf toolchain global cache dir
filelock==3.32.0 # 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
pyparsing >= 3.3.2
@@ -0,0 +1,26 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: esp-idf
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
display:
- platform: epaper_spi
id: epaper_display
model: t133a01
dc_pin: GPIO21
reset_pin: GPIO38
cs_pin: GPIO10
cs1_pin: GPIO2
busy_pin: GPIO13
update_interval: never
dimensions:
width: 200
height: 200
@@ -462,3 +462,24 @@ def test_enable_pin_code_generation(
# Both pin objects must be passed to the display via set_enable_pins() as a
# std::vector initializer list, in the configured order.
assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp
def test_model_with_no_default_init_sequence_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that code generation succeeds for a model with no default init sequence.
The base "t133a01" model (used directly, not via one of its `.extend()`
variants) doesn't override `get_init_sequence()` or pass `initsequence` to
its constructor, and the user didn't supply `init_sequence:` either.
`EpaperModel.get_init_sequence()` used to default to `None` in this case,
which made `flatten_sequence()` raise a `TypeError` during code
generation. Regression test for that crash.
"""
main_cpp = generate_main(component_config_path("t133a01_no_init_sequence.yaml"))
# The generated constructor call takes (name, width, height, init_sequence,
# init_sequence_length, ...); a length of 0 confirms the empty init
# sequence array was generated instead of raising during code generation.
assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp)
+46 -10
View File
@@ -108,6 +108,24 @@ def test_esp32_default_toolchain_is_esp_idf(
assert CORE.toolchain == expected
@pytest.mark.parametrize(
"config_toolchain",
[Toolchain.SDK_NRF.value, "nonsense"],
)
def test_esp32_rejects_unsupported_toolchains(
set_core_config: SetCoreConfigCallable,
config_toolchain: str,
) -> None:
"""Toolchains esp32 does not support are rejected at validation time."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
CORE.toolchain = None
with pytest.raises(cv.Invalid, match="Unknown value"):
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
@pytest.mark.parametrize(
("config", "error_match"),
[
@@ -454,26 +472,18 @@ def test_flash_mode_unset_leaves_defaults(
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(
wifi=True, bluetooth=True, ble_42=True, software_coexistence=True
),
NetworkSdkconfigData(wifi=True, bluetooth=True, software_coexistence=True),
{},
{
"CONFIG_BT_ENABLED": True,
"CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True,
"CONFIG_BT_BLE_50_FEATURES_SUPPORTED": False,
"CONFIG_SW_COEXIST_ENABLE": True,
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_wifi_ble_tracker_coexistence",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(bluetooth=True),
{},
{"CONFIG_BT_ENABLED": True},
id="idf_ble_server_only_no_ble42",
),
# --- IDF: user sdkconfig_options always win ---
pytest.param(
PlatformFramework.ESP32_IDF,
@@ -594,6 +604,7 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_BT_ENABLED") is True
assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True
assert sdkconfig.get("CONFIG_BT_BLE_50_FEATURES_SUPPORTED") is False
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
@@ -740,3 +751,28 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
with pytest.raises(cv.Invalid, match=match):
_validate_signed_ota_keys(config)
@pytest.mark.parametrize(
("value", "expected"),
[
# Full x.y.z versions are rewritten into pioarduino release URLs
(
"55.3.30",
"https://github.com/pioarduino/platform-espressif32/releases/download/55.03.30/platform-espressif32.zip",
),
(
"55.3.31-2",
"https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-2/platform-espressif32.zip",
),
# Non-version values pass through untouched
(
"https://github.com/pioarduino/platform-espressif32.git#develop",
"https://github.com/pioarduino/platform-espressif32.git#develop",
),
],
)
def test_parse_pio_platform_version(value: str, expected: str) -> None:
from esphome.components.esp32 import _parse_pio_platform_version
assert _parse_pio_platform_version(value) == expected
@@ -0,0 +1,110 @@
"""Tests for micro_wake_word local model validation."""
import json
import logging
from pathlib import Path
from typing import Any
import pytest
from esphome.components.micro_wake_word import LOCAL_SCHEMA
from esphome.core import CORE
MANIFEST: dict[str, Any] = {
"type": "micro",
"model": "hey_jarvis.tflite",
"author": "someone",
"version": 2,
"wake_word": "hey jarvis",
"trained_languages": ["en"],
"micro": {
"feature_step_size": 10,
"tensor_arena_size": 30000,
"probability_cutoff": 0.97,
"sliding_window_size": 5,
"minimum_esphome_version": "2024.7.0",
},
}
def _registered_files() -> list[Path]:
"""Files components registered for bundling this run."""
data = CORE.data.get("bundle")
return list(data.extra_files) if data else []
@pytest.fixture
def config_dir(tmp_path: Path) -> Path:
"""A config dir holding a manifest and its model file."""
(tmp_path / "models").mkdir()
(tmp_path / "models" / "hey_jarvis.tflite").write_bytes(b"fake model")
(tmp_path / "models" / "hey_jarvis.json").write_text(json.dumps(MANIFEST))
CORE.config_path = tmp_path / "test.yaml"
return tmp_path
def test_local_schema_registers_model_file(config_dir: Path) -> None:
"""The model file named by the manifest is registered so bundles include it."""
LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"]
def test_local_schema_registers_model_file_in_subdirectory(config_dir: Path) -> None:
"""The model reference is resolved relative to the manifest, not the config dir."""
nested = config_dir / "models" / "nested"
nested.mkdir()
(nested / "model.tflite").write_bytes(b"fake model")
(config_dir / "models" / "nested.json").write_text(
json.dumps({**MANIFEST, "model": "nested/model.tflite"})
)
LOCAL_SCHEMA({"path": "models/nested.json"})
assert _registered_files() == [nested / "model.tflite"]
def test_local_schema_leaves_config_untouched(config_dir: Path) -> None:
"""Registration is a side effect; the model file is not a config key."""
config = LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
assert config == {"path": config_dir / "models" / "hey_jarvis.json"}
def test_local_schema_missing_model_file_still_validates(config_dir: Path) -> None:
"""A model file that does not exist is registered, not rejected.
Raising here would be swallowed by the shorthand validator, which would then
report a confusing error about a missing file in a git repository.
"""
(config_dir / "models" / "hey_jarvis.tflite").unlink()
LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"]
@pytest.mark.parametrize(
"contents",
[
pytest.param("{not valid json", id="malformed"),
pytest.param(json.dumps({"type": "micro"}), id="no_model_key"),
pytest.param(json.dumps(["a", "list"]), id="not_an_object"),
pytest.param(json.dumps({"model": 42}), id="model_not_a_string"),
],
)
def test_local_schema_bad_manifest_does_not_raise(
config_dir: Path, contents: str, caplog: pytest.LogCaptureFixture
) -> None:
"""Manifest problems are left to later stages, which report them better.
The skipped registration is logged so a bundle built without the model file can
be diagnosed.
"""
(config_dir / "models" / "hey_jarvis.json").write_text(contents)
with caplog.at_level(logging.DEBUG):
LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
assert _registered_files() == []
assert "Not registering a model file" in caplog.text
@@ -1264,6 +1264,75 @@ def test_remote_packages_no_revert(
]
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
def test_remote_packages_skipped_revert_does_not_retry(
mock_clone_or_update, mock_is_file, mock_load_yaml
) -> None:
"""When revert() reports the rollback was skipped, the load is not
retried (the checkout is unchanged) and the error says so."""
mock_revert = MagicMock(return_value=False)
mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert)
mock_is_file.return_value = True
mock_load_yaml.side_effect = cv.Invalid("bad yaml")
config = {
CONF_PACKAGES: {
"pkg": {
CONF_URL: "https://github.com/esphome/repo",
CONF_REF: "main",
CONF_FILES: [{CONF_PATH: "file.yaml"}],
CONF_REFRESH: "1d",
}
}
}
with pytest.raises(cv.Invalid, match="could not revert the cached checkout"):
packages_pass(config)
assert mock_revert.call_count == 1
assert mock_load_yaml.call_count == 1
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
def test_remote_packages_successful_revert_retries(
mock_clone_or_update, mock_is_file, mock_load_yaml, caplog: pytest.LogCaptureFixture
) -> None:
"""A successful revert retries the load against the reverted checkout and
logs the original error, the only trace that upstream was broken."""
mock_revert = MagicMock(return_value=True)
mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert)
mock_is_file.return_value = True
mock_load_yaml.side_effect = [
cv.Invalid("bad yaml"),
OrderedDict(
{CONF_SENSOR: [{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}]}
),
]
config = {
CONF_PACKAGES: {
"pkg": {
CONF_URL: "https://github.com/esphome/repo",
CONF_REF: "main",
CONF_FILES: [{CONF_PATH: "file.yaml"}],
CONF_REFRESH: "1d",
}
}
}
with caplog.at_level(logging.WARNING):
actual = packages_pass(config)
assert actual[CONF_SENSOR] == [
{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}
]
assert mock_revert.call_count == 1
assert mock_load_yaml.call_count == 2
assert any("reverted the cached checkout" in r.getMessage() for r in caplog.records)
def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None:
"""Test that CORE.raw_config contains esphome section from merged package.
@@ -0,0 +1 @@
"""Tests for the wireguard component."""
@@ -0,0 +1,44 @@
"""Tests for the wireguard component schema."""
import pytest
from esphome.components.wireguard import CONFIG_SCHEMA
from esphome.const import PlatformFramework
from esphome.yaml_util import SensitiveStr
from tests.component_tests.types import SetCoreConfigCallable
# Any 42 base64 chars plus a valid terminator satisfies _WG_KEY_REGEX.
PRIVATE_KEY = "a" * 42 + "A="
PEER_PUBLIC_KEY = "b" * 42 + "A="
PEER_PRESHARED_KEY = "c" * 42 + "A="
@pytest.mark.parametrize(
("field", "value", "sensitive"),
[
("private_key", PRIVATE_KEY, True),
("peer_preshared_key", PEER_PRESHARED_KEY, True),
("peer_public_key", PEER_PUBLIC_KEY, False),
],
)
def test_key_sensitivity(
field: str,
value: str,
sensitive: bool,
set_core_config: SetCoreConfigCallable,
) -> None:
"""The private and preshared keys are secrets and must be tagged so dump
tooling redacts them deterministically; the peer's public key is not a
secret and must stay readable in redacted dumps (see issue #17718)."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(
{
"address": "10.0.0.2",
"private_key": PRIVATE_KEY,
"peer_endpoint": "wg.example.com",
"peer_public_key": PEER_PUBLIC_KEY,
"peer_preshared_key": PEER_PRESHARED_KEY,
}
)
assert isinstance(config[field], SensitiveStr) == sensitive
assert config[field] == value
@@ -0,0 +1,23 @@
"""Test-manifest overrides for the captive_portal C++ unit tests.
``json_escape`` lives in a standalone, dependency-free header
(``esphome/components/captive_portal/json_escape.h``). The rest of the
captive_portal component and its auto-loaded dependencies (``web_server_base``,
``ota.web_server``) do not build for the ``host`` platform that the C++ unit
test harness targets. Strip those away and replace the real schema -- which is
restricted to non-host platforms via ``cv.only_on`` and requires a
``web_server_base`` instance via ``use_id`` -- with an empty one so the host
test config validates. ``to_code`` stays suppressed (the default), so
``USE_CAPTIVE_PORTAL`` is never defined and ``captive_portal.cpp`` compiles to an
empty translation unit; only ``json_escape.h`` is exercised by the test.
"""
import esphome.config_validation as cv
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.auto_load = []
manifest.dependencies = []
manifest.config_schema = cv.Schema({})
manifest.final_validate_schema = None
@@ -0,0 +1,107 @@
#include <gtest/gtest.h>
#include <string>
#include "esphome/components/captive_portal/json_escape.h"
namespace esphome::captive_portal::testing {
namespace {
// Large enough that none of the inputs below are ever dropped.
constexpr size_t TEST_BUFFER_SIZE = 64 * JSON_ESCAPE_MAX_EXPANSION + 1;
// Escape into a stack buffer and return the result as a string so the expectations stay readable.
std::string escape(const std::string &value) {
char buf[TEST_BUFFER_SIZE];
return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size()));
}
} // namespace
// Plain ASCII with no special characters is passed through unchanged.
TEST(CaptivePortalJsonEscape, PlainStringUnchanged) {
EXPECT_EQ(escape("MyNetwork"), "MyNetwork");
EXPECT_EQ(escape(""), "");
}
// A double quote is escaped so it does not terminate the surrounding JSON string.
TEST(CaptivePortalJsonEscape, EscapesDoubleQuote) {
EXPECT_EQ(escape("a\"b"), "a\\\"b");
// A double quote followed by other characters stays inside the JSON string.
EXPECT_EQ(escape("\">end"), "\\\">end");
}
// A backslash is doubled so it does not start an escape sequence in the output.
TEST(CaptivePortalJsonEscape, EscapesBackslash) {
EXPECT_EQ(escape("a\\b"), "a\\\\b");
// A trailing backslash must not escape the closing quote of the JSON string.
EXPECT_EQ(escape("net\\"), "net\\\\");
}
// The control characters with short JSON forms use those forms.
TEST(CaptivePortalJsonEscape, EscapesShortFormControls) {
EXPECT_EQ(escape("\n"), "\\n");
EXPECT_EQ(escape("\r"), "\\r");
EXPECT_EQ(escape("\t"), "\\t");
EXPECT_EQ(escape("\b"), "\\b");
EXPECT_EQ(escape("\f"), "\\f");
}
// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex.
TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) {
EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000");
EXPECT_EQ(escape("\x01"), "\\u0001");
EXPECT_EQ(escape("\x10"), "\\u0010");
EXPECT_EQ(escape("\x1f"), "\\u001f");
// 0x7f (DEL) is >= 0x20, so it is NOT escaped by this helper.
EXPECT_EQ(escape("\x7f"), "\x7f");
}
// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim.
TEST(CaptivePortalJsonEscape, PassesThroughUtf8) {
// "café" in UTF-8 (é == 0xC3 0xA9).
EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9");
// Emoji (📶, 4-byte UTF-8) survives unchanged.
EXPECT_EQ(escape("\xf0\x9f\x93\xb6"), "\xf0\x9f\x93\xb6");
}
// A mix of special and normal characters is escaped in place without disturbing the rest.
TEST(CaptivePortalJsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); }
// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly.
TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) {
constexpr size_t input_len = 8;
char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1];
const std::string input(input_len, '\x01');
std::string expected;
for (size_t i = 0; i < input_len; i++)
expected += "\\u0001";
EXPECT_EQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), expected);
}
// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null
// terminated.
TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) {
// Room for one \u00XX sequence plus the null terminator, but two are requested.
char buf[JSON_ESCAPE_MAX_EXPANSION + 1];
const std::string input(2, '\x01');
const std::string result = json_escape_into_buffer(buf, StringRef(input.c_str(), input.size()));
EXPECT_EQ(result, "\\u0001");
EXPECT_EQ(buf[JSON_ESCAPE_MAX_EXPANSION], '\0');
}
// Plain characters are truncated at the buffer size, leaving room for the null terminator.
TEST(CaptivePortalJsonEscape, TruncatesPlainInput) {
char buf[5];
const std::string input(20, 'a');
EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa");
}
// A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing.
TEST(CaptivePortalJsonEscape, EmptyBufferIsSafe) {
const std::string input("test");
EXPECT_STREQ(json_escape_into_buffer(std::span<char>(), StringRef(input.c_str(), input.size())), "");
}
} // namespace esphome::captive_portal::testing
@@ -59,6 +59,24 @@ esphome:
id: test_regression_light
brightness: 100%
effect: "None"
- http_request.get:
url: https://esphome.io
capture_response: true
on_response:
then:
# Regression test: http_request.post with json: (dict variant) inside
# on_response of a capture_response: true request puts std::string&
# (body) into the nested action's Ts..., which exposes a
# const-correctness bug in HttpRequestSendAction::play() where
# encode_json_ receives const copies of non-const reference args.
- http_request.post:
url: https://esphome.io
json:
status: "ok"
# Same with json: lambda variant, exercises json_func_ path
- http_request.post:
url: https://esphome.io
json: !lambda "root[\"status\"] = \"ok\";"
http_request:
useragent: esphome/tagreader
@@ -0,0 +1,120 @@
#include <gtest/gtest.h>
#include "esphome/components/light/light_call.h"
#include "esphome/components/light/light_output.h"
#include "esphome/components/light/light_state.h"
namespace esphome::light::testing {
namespace {
// A light that only supports ON_OFF, like the `binary` platform and `status_led`.
class OnOffOutput : public LightOutput {
public:
LightTraits get_traits() override {
LightTraits traits;
traits.set_supported_color_modes({ColorMode::ON_OFF});
return traits;
}
void write_state(LightState *state) override {}
};
// A dimmable light, like the `monochromatic` platform.
class BrightnessOutput : public LightOutput {
public:
LightTraits get_traits() override {
LightTraits traits;
traits.set_supported_color_modes({ColorMode::BRIGHTNESS});
return traits;
}
void write_state(LightState *state) override {}
};
// validate_() is where zero brightness is resolved against the light's capabilities.
class TestableLightCall : public LightCall {
public:
using LightCall::LightCall;
using LightCall::validate_;
};
bool as_binary(const LightColorValues &values) {
bool binary;
values.as_binary(&binary);
return binary;
}
} // namespace
// An ON/OFF light has no "on but dark" state, so a zero brightness -- how effects encode
// their dark phase -- must turn the light off. Regression test for
// https://github.com/esphome/esphome/issues/17873.
TEST(LightCallOnOff, ZeroBrightnessTurnsOutputOff) {
OnOffOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(true).set_brightness(0.0f);
auto values = call.validate_();
EXPECT_FALSE(as_binary(values));
}
// The zero must not be stored, or no later turn-on could clear it: the capability check in
// validate_() drops any brightness an ON/OFF light doesn't support, so a stored zero would
// leave the light permanently off.
TEST(LightCallOnOff, ZeroBrightnessIsNotStored) {
OnOffOutput output;
LightState state(&output);
TestableLightCall dark_call(&state);
dark_call.set_state(true).set_brightness(0.0f);
state.remote_values = dark_call.validate_();
EXPECT_FLOAT_EQ(state.remote_values.get_brightness(), 1.0f);
// A plain turn-on afterwards must switch the light back on.
TestableLightCall on_call(&state);
on_call.set_state(true);
auto values = on_call.validate_();
EXPECT_TRUE(as_binary(values));
}
// A plain turn-on with no brightness must still light up.
TEST(LightCallOnOff, PlainTurnOnIsVisible) {
OnOffOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(true);
auto values = call.validate_();
EXPECT_TRUE(as_binary(values));
}
TEST(LightCallOnOff, TurnOffTurnsOutputOff) {
OnOffOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(false);
auto values = call.validate_();
EXPECT_FALSE(as_binary(values));
}
// A dimmable light can represent "on but dark", so zero brightness must be kept as-is and
// must not be rewritten into a turn-off.
TEST(LightCallBrightness, ZeroBrightnessStaysOnButDark) {
BrightnessOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(true).set_brightness(0.0f);
auto values = call.validate_();
EXPECT_TRUE(values.is_on());
EXPECT_FLOAT_EQ(values.get_brightness(), 0.0f);
}
} // namespace esphome::light::testing
+1
View File
@@ -42,4 +42,5 @@ sensor:
auto_cleaning_interval: 604800s
acceleration_mode: low
store_baseline: true
model: sen55
address: 0x69
@@ -1,2 +1,8 @@
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: digest
@@ -0,0 +1,29 @@
esphome:
name: light-binary-effect-off
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: binary_output
type: binary
write_action:
- logger.log:
format: "BINARY_OUTPUT:%s"
args: [YESNO(state)]
light:
- platform: binary
name: "Test Binary Light"
id: test_binary_light
output: binary_output
effects:
- strobe:
name: "Fast Strobe"
colors:
- state: true
duration: 50ms
- state: false
duration: 50ms
@@ -0,0 +1,21 @@
esphome:
name: light-binary-zero-bright
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: binary_output
type: binary
write_action:
- logger.log:
format: "BINARY_OUTPUT:%s"
args: [YESNO(state)]
light:
- platform: binary
name: "Test Binary Light"
id: test_binary_light
output: binary_output
@@ -0,0 +1,35 @@
esphome:
name: light-effect-zero-bright
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: pulse_output
type: float
write_action:
- logger.log:
format: "PULSE_OUTPUT:%.4f"
args: [state]
light:
- platform: monochromatic
name: "Test Pulse Light"
id: test_pulse_light
output: pulse_output
effects:
- pulse:
name: "Fast Pulse"
transition_length: 20ms
update_interval: 50ms
min_brightness: 0%
max_brightness: 100%
- strobe:
name: "Fast Strobe"
colors:
- state: true
duration: 50ms
- state: false
duration: 50ms
@@ -21,6 +21,11 @@ output:
type: float
write_action:
- lambda: ""
- platform: template
id: test_restore_and_on_output
type: float
write_action:
- lambda: ""
light:
- platform: rgb
@@ -37,3 +42,16 @@ light:
red: 1.0
green: 0.5
blue: 0.0
- platform: monochromatic
name: "Test Restore And On Light"
id: test_restore_and_on_light
output: test_restore_and_on_output
restore_mode: RESTORE_AND_ON
# Simulates a stale/persisted zero brightness: RESTORE_AND_ON always forces the light
# on at boot regardless of the recovered state, so a leftover brightness of 0 must not
# leave the light on-but-invisible.
initial_state:
color_mode: BRIGHTNESS
state: false
brightness: 0%
@@ -0,0 +1,207 @@
"""Integration test verifying the off phase of an effect reaches an ON/OFF-only light.
Regression test for https://github.com/esphome/esphome/issues/17873. A strobe effect
encodes its dark phase as `brightness = 0` while keeping `state = true`, so that the
effect keeps running instead of being stopped by an explicit turn-off. On a dimmable
light that works, because the output is driven by `state * brightness`. On a binary
light the dark phase used to be dropped, so the output stayed on forever.
Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with
every frame), so the effect's actual output can't be observed via API state broadcasts.
Instead, this test reads the output component's log lines, which are written on every
update regardless of the publish flag.
The output log line is emitted strictly after the API state response: `perform()`
publishes inline, but the write is deferred to the next `LightState::loop()` iteration
and then has to cross the subprocess stdout pipe. So a future is armed *before* each
command and awaited afterwards, rather than reading the last observed value.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from aioesphomeapi import EntityState, LightState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
OUTPUT_PATTERN = re.compile(r"BINARY_OUTPUT:(YES|NO)")
@pytest.mark.asyncio
async def test_light_binary_effect_off_phase(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A strobe effect must drive a binary light's output both on and off."""
loop = asyncio.get_running_loop()
observed: list[bool] = []
pending: list[asyncio.Future[bool]] = []
def on_log_line(line: str) -> None:
if match := OUTPUT_PATTERN.search(line):
value = match.group(1) == "YES"
observed.append(value)
while pending:
future = pending.pop(0)
if not future.done():
future.set_result(value)
break
def arm_output() -> asyncio.Future[bool]:
"""Arm a future for the next output write, before sending the command."""
future: asyncio.Future[bool] = loop.create_future()
pending.append(future)
return future
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
light = next(e for e in entities if e.object_id == "test_binary_light")
state_futures: dict[int, asyncio.Future[LightState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
# ESPHome sends the current state of every entity right after connecting; drain
# that initial burst so it can't be mistaken for the response to a command below.
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
"""Send a light command and wait for the matching state response."""
state_futures[light.key] = loop.create_future()
client.light_command(key=light.key, **kwargs)
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
# A plain turn-on must drive the output on -- brightness defaults to 100% and
# must not be mistaken for a dark phase.
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True, (
"Plain turn-on did not switch the output on"
)
# Run the strobe effect; both phases must reach the output.
observed.clear()
state = await send_and_wait(effect="Fast Strobe")
assert state.effect == "Fast Strobe"
# Let several effect cycles run (each phase is 50ms in the fixture).
await asyncio.sleep(1.0)
assert True in observed, (
f"Strobe effect never switched the output on -- got {observed}"
)
assert False in observed, (
f"Strobe effect never switched the output off; its dark phase was lost -- "
f"got {observed}"
)
# Stopping the effect must leave the light usable.
state = await send_and_wait(effect="None")
assert state.effect == "None"
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True, (
"Light stayed off after the effect stopped"
)
# An explicit turn-off still switches the output off.
output = arm_output()
state = await send_and_wait(state=False)
assert state.state is False
assert await asyncio.wait_for(output, timeout=5.0) is False, (
"Turn-off did not switch the output off"
)
@pytest.mark.asyncio
async def test_light_binary_zero_brightness_is_recoverable(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Zero brightness on an ON/OFF light must not leave it permanently stuck off.
An ON/OFF light has no brightness capability, so `turn_on` with 0% brightness has
no representable "on but dark" state. It must switch the output off and report the
light as off, and a later plain turn-on must bring it back.
"""
loop = asyncio.get_running_loop()
pending: list[asyncio.Future[bool]] = []
def on_log_line(line: str) -> None:
if match := OUTPUT_PATTERN.search(line):
value = match.group(1) == "YES"
while pending:
future = pending.pop(0)
if not future.done():
future.set_result(value)
break
def arm_output() -> asyncio.Future[bool]:
future: asyncio.Future[bool] = loop.create_future()
pending.append(future)
return future
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
light = next(e for e in entities if e.object_id == "test_binary_light")
state_futures: dict[int, asyncio.Future[LightState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
state_futures[light.key] = loop.create_future()
client.light_command(key=light.key, **kwargs)
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True
# Turning on at 0% brightness has no representable "on but dark" state here,
# so the light must switch off and report itself as off.
output = arm_output()
state = await send_and_wait(state=True, brightness=0.0)
assert await asyncio.wait_for(output, timeout=5.0) is False, (
"Zero brightness did not switch the output off"
)
assert state.state is False, (
"Light reported itself as on while its output was off"
)
# A plain turn-on must recover -- the stored zero brightness must not persist.
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True, (
"Light was left permanently off by a zero-brightness turn-on"
)
+5 -5
View File
@@ -341,14 +341,14 @@ async def test_light_calls(
assert state.state is True
assert state.brightness == pytest.approx(1.0)
# Test 31b: An explicit turn-on with brightness 0 still resets to full
# brightness - a turn-on must never leave the light on-but-invisible. This
# is the same path the restore logic exercises (set_state(true) +
# set_brightness(0) from a persisted brightness=0 turn-off).
# Test 31b: An explicit turn-on with brightness 0 respects the explicit value and
# stays dark. Only a turn-on with no brightness specified (Test 31) restores
# visibility -- an explicit brightness request (e.g. from a light effect's dark
# phase) is never overridden.
client.light_command(key=rgbcw_light.key, state=True, brightness=0.0)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is True
assert state.brightness == pytest.approx(1.0)
assert state.brightness == pytest.approx(0.0)
# Test 32: Turning a light on when it already has nonzero brightness leaves
# the brightness unchanged (the reset only happens when brightness is 0).
@@ -0,0 +1,91 @@
"""Integration test verifying light effects can dim to 0% brightness while staying on.
Regression test for https://github.com/esphome/esphome/issues/17639, where PR #17103's
"make turn-on visible" logic in LightCall::validate_() also clobbered brightness set by a
running effect (e.g. pulse, strobe), forcing it back to 100% and breaking the dark phase
of those effects.
Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with
every frame), so the effect's actual output can't be observed via API state broadcasts.
Instead, this test reads the output component's log lines, which are written on every
update regardless of the publish flag.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from aioesphomeapi import EntityState, LightState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_light_effect_zero_brightness(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Pulse and strobe effects must be able to reach 0% brightness while the light stays on."""
output_pattern = re.compile(r"PULSE_OUTPUT:([\d.]+)")
observed: list[float] = []
def on_log_line(line: str) -> None:
match = output_pattern.search(line)
if match:
observed.append(float(match.group(1)))
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
light = next(e for e in entities if e.object_id == "test_pulse_light")
state_futures: dict[int, asyncio.Future[LightState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
# ESPHome sends the current state of every entity right after connecting; drain
# that initial burst so it can't be mistaken for the response to a command below.
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
"""Send a light command and wait for the matching state response."""
state_futures[light.key] = asyncio.get_running_loop().create_future()
client.light_command(key=light.key, **kwargs)
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
# Turn the light on first so the effect starts from a known, visible state.
state = await send_and_wait(state=True, brightness=1.0)
assert state.state is True
assert state.brightness == pytest.approx(1.0)
for effect_name in ("Fast Pulse", "Fast Strobe"):
observed.clear()
state = await send_and_wait(effect=effect_name)
assert state.effect == effect_name
# Let several effect cycles run (update_interval/duration is 50ms in the fixture).
await asyncio.sleep(1.0)
assert observed, f"No output observed while running effect {effect_name!r}"
assert min(observed) == pytest.approx(0.0, abs=0.01), (
f"Effect {effect_name!r} never dimmed to 0% brightness while the light "
f"stayed on -- got min={min(observed):.4f} (values: {observed})"
)
assert max(observed) > 0.5, (
f"Effect {effect_name!r} never reached full brightness -- "
f"got max={max(observed):.4f}"
)
client.light_command(key=light.key, effect="None")
@@ -11,6 +11,14 @@ from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left
behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs,
keyed only by device name)."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
@pytest.mark.asyncio
async def test_light_initial_state(
yaml_config: str,
@@ -36,3 +44,10 @@ async def test_light_initial_state(
assert state.red == pytest.approx(1.0, abs=0.01)
assert state.green == pytest.approx(0.5, abs=0.01)
assert state.blue == pytest.approx(0.0, abs=0.01)
# Regression test: RESTORE_AND_ON always forces the light on at boot, even when
# the recovered/initial brightness was 0 -- it must never come up on-but-invisible.
restore_and_on_light = require_entity(entities, "test_restore_and_on_light")
restore_and_on_state = helper.initial_states[restore_and_on_light.key]
assert restore_and_on_state.state is True
assert restore_and_on_state.brightness == pytest.approx(1.0)
+12
View File
@@ -184,6 +184,18 @@ def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> Non
assert "-Wl,--gc-sections" in content
def test_get_component_cmakelists_globs_alternate_cpp_extensions() -> None:
"""Both app_sources glob variants include .cc/.cxx/.c++ so vendored sources
are compiled, matching the extensions PlatformIO's builder globs by default."""
CORE.build_flags = set()
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
for ext in ("cc", "cxx", "c++"):
assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/*.{ext}"') == 2
assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.{ext}"') == 2
def test_get_project_cmakelists_emits_managed_components_property(
tmp_path: Path,
) -> None:
+32 -2
View File
@@ -2,11 +2,14 @@
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
from esphome.components import esp32
from esphome.components.api import client as api_client
from esphome.core import EsphomeError
from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM
from esphome.core import CORE, EsphomeError
def test_decoder_swallows_esphome_error() -> None:
@@ -112,3 +115,30 @@ def test_decoder_uses_platform_handler_when_provided() -> None:
assert calls == [(config, "BT0: 0x4010496e", False)]
assert mock_generic.called is False
assert processor.backtrace_state is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("extra_config", "expected_deep_sleep"),
[({"deep_sleep": {}}, True), ({}, False)],
)
async def test_async_run_logs_passes_deep_sleep(
extra_config: dict, expected_deep_sleep: bool
) -> None:
"""async_run_logs tells async_run whether the device deep sleeps, from the config."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config}
# async_run blocks forever after connecting; raise to unwind async_run_logs
# once we have captured how it was called.
sentinel = RuntimeError("stop the wait")
with (
patch.object(
api_client, "async_run", AsyncMock(side_effect=sentinel)
) as mock_run,
patch.object(api_client, "APIClient"),
pytest.raises(RuntimeError, match="stop the wait"),
):
await api_client.async_run_logs(config, ["1.2.3.4"])
assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep
@@ -0,0 +1,169 @@
"""Tests for the micro_wake_word model source validation and downloads."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.components import micro_wake_word as mww
import esphome.config_validation as cv
from esphome.const import (
CONF_FILE,
CONF_MODEL,
CONF_PATH,
CONF_REF,
CONF_TYPE,
CONF_URL,
)
@pytest.fixture
def mock_download_content_many() -> MagicMock:
"""Patch the concurrent download helper so no network is involved."""
with patch(
"esphome.components.micro_wake_word.external_files.download_content_many"
) as m:
yield m
def test_shorthand_model_name_resolves_without_network(
mock_download_content_many: MagicMock,
) -> None:
config = mww._validate_source_shorthand("okay_nabu")
assert config[CONF_TYPE] == mww.TYPE_HTTP
assert config[CONF_URL] == (
"https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/okay_nabu.json"
)
mock_download_content_many.assert_not_called()
def test_shorthand_git_with_ref_not_captured_as_model_name(
setup_core: Path, tmp_path: Path
) -> None:
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "model.json").write_text("{}")
with patch(
"esphome.components.micro_wake_word.git.clone_or_update",
return_value=(repo_dir, None),
):
config = mww._validate_source_shorthand("github://user/repo/model.json@main")
assert config[CONF_TYPE] == "git"
assert config[CONF_URL] == "https://github.com/user/repo.git"
assert config[CONF_FILE] == "model.json"
assert config[CONF_REF] == "main"
def test_shorthand_local_path_not_captured_as_model_name(
setup_core: Path, tmp_path: Path
) -> None:
manifest = tmp_path / "model.json"
manifest.write_text("{}")
config = mww.MODEL_SOURCE_SCHEMA(str(manifest))
assert config[CONF_TYPE] == "local"
assert Path(config[CONF_PATH]) == manifest
@pytest.mark.parametrize(
"value", ["some/path/file", "name@ref", "bad:name", "okay_nabu\n", "héllo"]
)
def test_model_name_rejects_non_identifiers(value: str) -> None:
with pytest.raises(cv.Invalid):
mww._validate_source_model_name(value)
def _http_model(name: str) -> dict:
return {
CONF_MODEL: {
CONF_TYPE: mww.TYPE_HTTP,
CONF_URL: f"https://example.com/models/{name}.json",
}
}
def _write_manifest(model_config: dict, contents: str) -> Path:
path = mww._compute_local_file_path(model_config[CONF_MODEL])
path.mkdir(parents=True, exist_ok=True)
manifest = path / "manifest.json"
manifest.write_text(contents)
return path
def test_download_http_models_batches_manifests_then_models(
setup_core: Path, mock_download_content_many: MagicMock
) -> None:
names = ("okay_nabu", "hey_mycroft", "vad")
models = {name: _http_model(name) for name in names}
paths = {
name: _write_manifest(models[name], json.dumps({"model": f"{name}.tflite"}))
for name in names
}
config = {
mww.CONF_MODELS: [
models["okay_nabu"],
models["hey_mycroft"],
# non-http sources must be ignored
{CONF_MODEL: {CONF_TYPE: "local", CONF_PATH: "x"}},
],
mww.CONF_VAD: models["vad"],
}
assert mww._download_http_models(config) is config
assert mock_download_content_many.call_count == 2
manifest_items = list(mock_download_content_many.call_args_list[0].args[0])
assert manifest_items == [
(f"https://example.com/models/{name}.json", paths[name] / "manifest.json")
for name in names
]
model_items = list(mock_download_content_many.call_args_list[1].args[0])
assert model_items == [
(f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite")
for name in names
]
def test_download_http_models_no_http_sources_skips_download(
mock_download_content_many: MagicMock,
) -> None:
config = {mww.CONF_MODELS: [{CONF_MODEL: {CONF_TYPE: "local", CONF_PATH: "x"}}]}
assert mww._download_http_models(config) is config
mock_download_content_many.assert_not_called()
@pytest.mark.parametrize(
("contents", "message"),
[
("not json", "Invalid manifest file"),
("[1, 2]", "must contain a JSON object"),
("{}", "missing the 'model' key"),
],
)
def test_download_http_models_bad_manifest_raises(
setup_core: Path,
mock_download_content_many: MagicMock,
contents: str,
message: str,
) -> None:
model = _http_model("okay_nabu")
config = {mww.CONF_MODELS: [model]}
_write_manifest(model, contents)
with pytest.raises(cv.Invalid, match=message):
mww._download_http_models(config)
# manifests were still fetched in one batch; the model batch never ran
assert mock_download_content_many.call_count == 1
def test_download_http_models_collects_all_manifest_errors(
setup_core: Path, mock_download_content_many: MagicMock
) -> None:
models = {name: _http_model(name) for name in ("one", "two")}
config = {mww.CONF_MODELS: list(models.values())}
_write_manifest(models["one"], "not json")
_write_manifest(models["two"], "[1]")
with pytest.raises(cv.MultipleInvalid) as excinfo:
mww._download_http_models(config)
assert len(excinfo.value.errors) == 2
+59 -2
View File
@@ -1,9 +1,15 @@
"""Tests for ESP8266 component."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import MagicMock, patch
import pytest
from esphome.components.esp8266 import lambdas_use_scanf_float
from esphome.core import Lambda
from esphome.components import esp8266
from esphome.components.esp8266 import check_rosetta, lambdas_use_scanf_float
from esphome.core import EsphomeError, Lambda
from esphome.types import ConfigType
@@ -60,3 +66,54 @@ def test_lambdas_use_scanf_float_nested() -> None:
"""Test detection in deeply nested config."""
config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}}
assert lambdas_use_scanf_float(config) is True
@pytest.fixture
def apple_silicon_run(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock]:
"""Simulate an Apple Silicon Mac and yield the mocked subprocess.run."""
monkeypatch.setattr(esp8266, "IS_MACOS", True)
with (
patch("esphome.components.esp8266.platform.machine", return_value="arm64"),
patch("esphome.components.esp8266.subprocess.run") as mock_run,
):
yield mock_run
@pytest.mark.parametrize(
("is_macos", "machine"),
[
(False, "arm64"),
(True, "x86_64"),
],
)
def test_check_rosetta_skips_other_systems(
monkeypatch: pytest.MonkeyPatch, is_macos: bool, machine: str
) -> None:
"""The check only probes on Apple Silicon Macs."""
monkeypatch.setattr(esp8266, "IS_MACOS", is_macos)
with (
patch("esphome.components.esp8266.platform.machine", return_value=machine),
patch("esphome.components.esp8266.subprocess.run") as mock_run,
):
check_rosetta()
mock_run.assert_not_called()
def test_check_rosetta_installed(apple_silicon_run: MagicMock) -> None:
"""No error when the x86_64 probe succeeds (Rosetta present)."""
apple_silicon_run.return_value = MagicMock(returncode=0)
check_rosetta()
apple_silicon_run.assert_called_once()
def test_check_rosetta_missing(apple_silicon_run: MagicMock) -> None:
"""A failing x86_64 probe raises an actionable error."""
apple_silicon_run.return_value = MagicMock(returncode=1)
with pytest.raises(EsphomeError, match="softwareupdate --install-rosetta"):
check_rosetta()
def test_check_rosetta_arch_unavailable(apple_silicon_run: MagicMock) -> None:
"""The build proceeds when arch(1) cannot be executed."""
apple_silicon_run.side_effect = OSError("no such file")
check_rosetta()
@@ -0,0 +1,120 @@
"""Minimal idf_tools stand-in for get_tool_downloads.py tests."""
from collections.abc import Iterable
import os
CURRENT_PLATFORM = "linux-amd64"
TOOLS_FILE = "tools/tools.json"
class ToolBinaryError(RuntimeError):
pass
class _G:
idf_path: str | None = None
idf_tools_path: str | None = None
tools_json: str | None = None
g = _G()
class IDFEnv:
@classmethod
def get_idf_env(cls) -> "IDFEnv":
return cls()
def add_and_check_targets(idf_env_obj: IDFEnv, targets_str: str) -> list[str]:
return targets_str.split(",")
class _Download:
def __init__(self, url: str, size: int, sha256: str, rename_dist: str = "") -> None:
self.url = url
self.size = size
self.sha256 = sha256
self.rename_dist = rename_dist
class _Version:
def __init__(self, download: _Download | None) -> None:
self._download = download
def get_download_for_platform(self, platform_name: str) -> _Download | None:
return self._download
class _Tool:
def __init__(
self,
versions: dict[str, _Version],
recommended: str | None,
installed: Iterable[str] = (),
broken: bool = False,
) -> None:
self.versions = versions
self._recommended = recommended
self.versions_installed = list(installed)
self._broken = broken
def compatible_with_platform(self) -> bool:
return True
def get_recommended_version(self) -> str | None:
return self._recommended
def find_installed_versions(self) -> None:
if self._broken:
raise ToolBinaryError("broken binary")
_TOOLS = {
"cmake": _Tool(
{"3.30.2": _Version(_Download("https://gh.test/cmake.tar.gz", 11, "aa"))},
"3.30.2",
),
"ninja": _Tool(
{
"1.12.1": _Version(
_Download("https://gh.test/ninja-mac.zip", 22, "bb", "ninja-v1.zip")
)
},
"1.12.1",
),
"installed-tool": _Tool(
{"1.0": _Version(_Download("https://gh.test/x.tar.gz", 33, "cc"))},
"1.0",
installed=["1.0"],
),
"broken-tool": _Tool(
{"2.0": _Version(_Download("https://gh.test/y.tar.gz", 44, "dd"))},
"2.0",
broken=True,
),
"no-recommended-tool": _Tool({"3.0": _Version(None)}, None),
"no-download-tool": _Tool({"4.0": _Version(None)}, "4.0"),
}
def load_tools_info() -> dict[str, _Tool]:
return _TOOLS
def expand_tools_arg(
tools_spec: list[str], overall_tools: dict[str, _Tool], targets: list[str]
) -> list[str]:
if "required" in tools_spec:
return list(overall_tools)
return [t for t in tools_spec if "@" not in t] + [t for t in tools_spec if "@" in t]
def get_idf_download_url_apply_mirrors(
args: object = None, download_url: str = ""
) -> str:
print(f"Changed download URL: {download_url}") # noise on stdout, like idf_tools
prefix = os.environ.get("TEST_MIRROR_PREFIX")
if prefix:
return prefix + download_url
return download_url
+333 -2
View File
@@ -22,10 +22,12 @@ from esphome.bundle import (
_add_bytes_to_tar,
_default_target_dir,
_find_used_secret_keys,
add_bundle_file,
extract_bundle,
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
@@ -477,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"},
)
@@ -488,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:
@@ -507,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()
# ---------------------------------------------------------------------------
@@ -611,6 +877,70 @@ def test_discover_files_includes_config(tmp_path: Path) -> None:
assert "test.yaml" in paths
def test_discover_files_includes_registered_files(tmp_path: Path) -> None:
"""Files registered with add_bundle_file() are included.
The config does not name them, so discovery cannot find them on its own.
"""
config_dir = _setup_config_dir(
tmp_path,
files={"models/model.tflite": "fake model data"},
)
add_bundle_file(config_dir / "models" / "model.tflite")
creator = ConfigBundleCreator({})
files = creator.discover_files()
paths = [f.path for f in files]
assert "models/model.tflite" in paths
def test_discover_files_registered_relative_file(tmp_path: Path) -> None:
"""A relative registered path is taken as relative to the config directory.
Not the working directory, which is where Path.resolve() would put it.
"""
_setup_config_dir(
tmp_path,
files={"models/model.tflite": "fake model data"},
)
add_bundle_file(Path("models/model.tflite"))
creator = ConfigBundleCreator({})
files = creator.discover_files()
paths = [f.path for f in files]
assert "models/model.tflite" in paths
def test_discover_files_registered_file_outside_config_dir(tmp_path: Path) -> None:
"""A registered file outside the config directory is skipped, not bundled."""
_setup_config_dir(tmp_path)
outside = tmp_path / "outside.tflite"
outside.write_text("fake model data")
add_bundle_file(outside)
creator = ConfigBundleCreator({})
files = creator.discover_files()
assert [f.path for f in files] == ["test.yaml"]
def test_discover_files_registered_file_deduplicated(tmp_path: Path) -> None:
"""Registering the same file twice adds it once."""
config_dir = _setup_config_dir(
tmp_path,
files={"models/model.tflite": "fake model data"},
)
add_bundle_file(config_dir / "models" / "model.tflite")
add_bundle_file(config_dir / "models" / "model.tflite")
creator = ConfigBundleCreator({})
files = creator.discover_files()
assert [f.path for f in files].count("models/model.tflite") == 1
def test_discover_files_finds_path_objects(tmp_path: Path) -> None:
"""Path objects in validated config are discovered."""
config_dir = _setup_config_dir(
@@ -1196,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()
@@ -1204,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]
+111 -2
View File
@@ -1,3 +1,4 @@
import json
from pathlib import Path
import string
@@ -1436,9 +1437,41 @@ def test_version_parse_with_extra() -> None:
assert version.extra == "dev20240101"
def test_version_parse_invalid() -> None:
def test_version_parse_without_patch() -> None:
"""A two-part version parses with patch defaulting to 0, so framework
shorthands like '6.0' and '6.0-rc1' are accepted."""
version = cv.Version.parse("6.0")
assert (version.major, version.minor, version.patch, version.extra) == (
6,
0,
0,
"",
)
version = cv.Version.parse("6.0-rc1")
assert (version.major, version.minor, version.patch, version.extra) == (
6,
0,
0,
"rc1",
)
def test_version_parse_numeric_extra() -> None:
"""Four-part versions keep the trailing component as extra (pioarduino
packaging revisions, e.g. 5.5.3.1)."""
version = cv.Version.parse("5.5.3.1")
assert (version.major, version.minor, version.patch, version.extra) == (
5,
5,
3,
"1",
)
@pytest.mark.parametrize("value", ["not.a.version", "6", "a.b", ""])
def test_version_parse_invalid(value: str) -> None:
with pytest.raises(ValueError, match="Not a valid version number"):
cv.Version.parse("not.a.version")
cv.Version.parse(value)
def test_version_is_beta() -> None:
@@ -2880,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")

Some files were not shown because too many files have changed in this diff Show More