mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 22:56:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d6d8f5ca | ||
|
|
e2ca80ec41 | ||
|
|
920ff9c25f | ||
|
|
56a90d2b25 | ||
|
|
b4166a883e | ||
|
|
b8703a8a1b | ||
|
|
57bb4e4e77 | ||
|
|
de93815c6b | ||
|
|
d4372ed008 | ||
|
|
a199ac41ee | ||
|
|
c0f494450d | ||
|
|
6b6903e568 | ||
|
|
d0f68802b9 | ||
|
|
7862520450 | ||
|
|
cbdddc8020 | ||
|
|
7d1317ad53 | ||
|
|
985a08e247 | ||
|
|
4f3db4c15a | ||
|
|
56512abb7e | ||
|
|
71349a6feb | ||
|
|
489e3d17ca | ||
|
|
d93772fed6 | ||
|
|
50f02aa523 | ||
|
|
5a87ad8fc0 | ||
|
|
e40579ad93 | ||
|
|
37fe59dc37 | ||
|
|
3829d368ff | ||
|
|
013c5d7217 | ||
|
|
e38ae51de2 | ||
|
|
b832e135cb | ||
|
|
66615a24a7 | ||
|
|
52ace9c448 | ||
|
|
e1cedaba87 | ||
|
|
f7bf4e9727 | ||
|
|
e46bdf9e18 | ||
|
|
a08070a836 | ||
|
|
18f93c0e41 | ||
|
|
bbc5669efa | ||
|
|
718b04c15a | ||
|
|
24a8634a4b | ||
|
|
fc6664d737 | ||
|
|
388e411469 | ||
|
|
a8ebdcb8f2 | ||
|
|
f5f1c48f51 | ||
|
|
7c55de311f | ||
|
|
307faa6c38 | ||
|
|
5f2adcf9b3 | ||
|
|
7738464f0b | ||
|
|
5a86e26f68 | ||
|
|
786b47d8c2 | ||
|
|
83092ea05c | ||
|
|
629afd38f6 | ||
|
|
3ffc3a9610 | ||
|
|
9de7bd7461 | ||
|
|
5df922e0df | ||
|
|
b571d2a5ab | ||
|
|
231a2897c0 | ||
|
|
2223b14794 | ||
|
|
3d340f4d90 | ||
|
|
2f99466f3a | ||
|
|
f05e15522c | ||
|
|
f37dad683b | ||
|
|
a0fb14bf54 | ||
|
|
d4443be0c1 | ||
|
|
0dfb573e18 | ||
|
|
572fc033cd | ||
|
|
4062f0a323 | ||
|
|
cd40fb1c68 | ||
|
|
7afe7750cd | ||
|
|
e08bdf8cca | ||
|
|
b3172ecae8 | ||
|
|
2b4d9c0af9 | ||
|
|
3a10d2c187 | ||
|
|
05e2c6b133 | ||
|
|
2797349c75 | ||
|
|
95a01ac2ab | ||
|
|
ea01c909b7 | ||
|
|
d3655597ea |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(®, 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")
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user