diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index bb85ccd681..1d76c18be8 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -70,6 +70,7 @@ async function isStackedPr(github, context) { async function detectMergeBranch(github, context) { const labels = new Set(); const baseRef = context.payload.pull_request.base.ref; + const defaultBranch = context.payload.repository.default_branch; if (baseRef === 'release') { labels.add('merging-to-release'); @@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) { } else if (await isStackedPr(github, context)) { // GitHub manages the merge order for a stack, so these are not blocked. labels.add('stacked-pr'); - } else if (baseRef !== 'dev') { + } else if (baseRef !== defaultBranch) { // A chain built by hand: it must not merge until its base branch does. labels.add('chained-pr'); } diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index f30ceff8c1..be239e2f1b 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; // Builds a fresh context for detectMergeBranch tests instead of mutating the // shared CONTEXT fixture above (which other describe blocks rely on). -function makeMergeContext(baseRef, { stack } = {}) { +function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) { const pull_request = { number: 1, base: { ref: baseRef } }; if (stack !== undefined) { pull_request.stack = stack; } return { repo: { owner: 'esphome', repo: 'esphome' }, - payload: { pull_request } + payload: { pull_request, repository: { default_branch: defaultBranch } } }; } @@ -136,6 +136,21 @@ describe('detectMergeBranch', () => { assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); assert.equal(state.calls, 1); }); + + it('base ref matches default branch adds no labels', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('other', { defaultBranch: 'other' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), []); + }); + + it('base ref dev when the default branch is main adds chained-pr', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('dev', { defaultBranch: 'main' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + }); + }); // --------------------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e695bb46b..026c2ba27a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,7 @@ jobs: . venv/bin/activate script/ci-custom.py script/build_codeowners.py --check + script/build_alias_registry.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2-boards.py --check @@ -444,8 +445,12 @@ jobs: - common - determine-jobs if: >- - (github.event_name == 'push' && github.ref_name == 'dev') || - (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') + github.repository == 'esphome/esphome' && ( + (github.event_name == 'push' && github.ref_name == 'dev') || + (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') + ) + # CodSpeed benchmarks require a CodSpeed account linked to the repository to run + # (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself. steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/Doxyfile b/Doxyfile index 3bb08e5b06..8f6048b4d8 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0-dev +PROJECT_NUMBER = 2026.9.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/docker/Dockerfile b/docker/Dockerfile index a4f5d3c3a6..a62eb59a58 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 RUN \ platformio settings set enable_telemetry No \ diff --git a/esphome/__main__.py b/esphome/__main__.py index c4ba6b54d7..c1e05d2ea7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -10,7 +10,7 @@ from pathlib import Path import re import sys import time -from typing import Protocol +from typing import TYPE_CHECKING, Protocol # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting @@ -21,7 +21,6 @@ from esphome.const import ( ARGUMENT_HELP_DEVICE, BUNDLE_EXTENSION, CONF_API, - CONF_AUTH, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, @@ -29,6 +28,7 @@ from esphome.const import ( CONF_DISCOVER_IP, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -42,7 +42,7 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, @@ -71,6 +71,9 @@ from esphome.util import ( safe_print, ) +if TYPE_CHECKING: + import threading + # Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this # module's top level. Every `esphome` invocation — including fast paths # like `esphome version` — pays the cost of what's imported here before @@ -273,8 +276,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: if purpose == Purpose.LOGGING and not has_api(): return ( "Cannot view logs over the network: no 'api:' component is " - "configured. Network log streaming requires the native API; add " - "an 'api:' component, enable MQTT logging, or view logs over USB." + "configured. Add an 'api:' component, enable MQTT logging, add a " + "'web_server:' component, or view logs over USB." ) if purpose == Purpose.UPLOADING and not has_ota(): return ( @@ -314,9 +317,12 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": + # Logs can stream over a network transport via the native API + # or the web_server HTTP SSE feed. + network_logging = has_api() or has_web_server_logging() # ensure IP adresses are used first if is_ip_address(CORE.address) and ( - (purpose == Purpose.LOGGING and has_api()) + (purpose == Purpose.LOGGING and network_logging) or (purpose == Purpose.UPLOADING and has_ota()) ): resolved.extend(_resolve_with_cache(CORE.address, purpose)) @@ -328,7 +334,11 @@ def choose_upload_log_host( if has_mqtt_logging(): resolved.append("MQTT") - if has_api() and has_non_ip_address() and has_resolvable_address(): + if ( + network_logging + and has_non_ip_address() + and has_resolvable_address() + ): resolved.extend(_ota_hostnames_for_default(purpose)) elif purpose == Purpose.UPLOADING: @@ -390,7 +400,7 @@ def choose_upload_log_host( mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if has_api(): + if has_api() or has_web_server_logging(): add_ota_options() elif purpose == Purpose.UPLOADING and has_ota(): @@ -483,6 +493,21 @@ def has_web_server_ota() -> bool: ) +def has_web_server_logging() -> bool: + """Check if logs can be streamed over the web_server HTTP SSE endpoint. + + The ``web_server`` component exposes a ``/events`` Server-Sent Events + stream that carries ``event: log`` frames. This requires version 2+ (the + v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default). + """ + web_conf = CORE.config.get(CONF_WEB_SERVER) + if web_conf is None: + return False + if web_conf.get(CONF_VERSION, 2) == 1: + return False + return web_conf.get(CONF_LOG, True) + + def has_mqtt_ip_lookup() -> bool: """Check if MQTT is available and IP lookup is supported.""" if CONF_MQTT not in CORE.config: @@ -545,11 +570,48 @@ def has_name_add_mac_suffix() -> bool: def mqtt_get_ip( - config: ConfigType, username: str, password: str, client_id: str + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, ) -> list[str]: from esphome import mqtt - return mqtt.get_esphome_device_ip(config, username, password, client_id) + return mqtt.get_esphome_device_ip( + config, username, password, client_id, stop_event=stop_event + ) + + +def _add_network_device(device: str, network_devices: list[str]) -> None: + """Append a device to the list, expanding it through ``CORE.address_cache``. + + If the hostname is already in the address cache (e.g. populated by mDNS + discovery), substitute the cached IPs so aioesphomeapi doesn't open its + own Zeroconf to re-resolve it. Duplicates are dropped. + """ + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): + network_devices.extend(addr for addr in cached if addr not in network_devices) + elif device not in network_devices: + network_devices.append(device) + + +def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]: + """Split the device list into direct addresses and an MQTT-lookup flag. + + Direct addresses are expanded through ``CORE.address_cache`` and deduped + the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings + are not resolved, only reported via the returned bool so the caller can + defer the broker lookup. + """ + network_devices: list[str] = [] + has_mqtt_lookup = False + for device in devices: + if get_port_type(device) in _MQTT_PORT_TYPES: + has_mqtt_lookup = True + else: + _add_network_device(device, network_devices) + return network_devices, has_mqtt_lookup def _resolve_network_devices( @@ -582,40 +644,44 @@ def _resolve_network_devices( if port_type in _MQTT_PORT_TYPES: # Only resolve MQTT once, even if multiple MQTT entries if not mqtt_resolved: - try: - mqtt_ips = mqtt_get_ip( - config, args.username, args.password, args.client_id - ) - # pylint can't infer mqtt_get_ip's return through its - # lazy ``from esphome import mqtt`` import, so it flags - # the genexpr below. - network_devices.extend( - addr - for addr in mqtt_ips # pylint: disable=not-an-iterable - if addr not in network_devices - ) - except EsphomeError as err: - _LOGGER.warning( - "MQTT IP discovery failed (%s), will try other devices if available", - err, - ) + mqtt_ips = _mqtt_get_ip_or_warn( + config, args.username, args.password, args.client_id + ) + network_devices.extend( + addr for addr in mqtt_ips if addr not in network_devices + ) mqtt_resolved = True continue - # If the hostname is already in the address cache (e.g. populated by - # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't - # open its own Zeroconf to re-resolve it. - if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): - network_devices.extend( - addr for addr in cached if addr not in network_devices - ) - elif device not in network_devices: - # Regular network address or IP - add if not already present - network_devices.append(device) + _add_network_device(device, network_devices) return network_devices +def _mqtt_get_ip_or_warn( + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, +) -> list[str]: + """Look up the device IP via MQTT, returning [] with a warning on failure. + + This owns the failure policy for MQTT IP discovery on paths that have + other addresses to fall back on: a broker problem must not abort the + operation. Also used as the deferred resolver handed to ``run_logs``, + where it runs in a worker thread. + """ + try: + return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event) + except EsphomeError as err: + _LOGGER.warning( + "MQTT IP discovery failed (%s), will try other devices if available", + err, + ) + return [] + + def run_miniterm(config: ConfigType, port: str, args) -> int: from datetime import datetime @@ -1291,25 +1357,23 @@ def _upload_via_native_api( def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: - web_conf = config.get(CONF_WEB_SERVER) - if not web_conf: - raise EsphomeError( - f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component " - f"is not configured." - ) - - remote_port = int(web_conf[CONF_PORT]) - auth = web_conf.get(CONF_AUTH) or {} - username = auth.get(CONF_USERNAME) - password = auth.get(CONF_PASSWORD) - from esphome import web_server_ota + from esphome.web_server_helpers import get_web_server_connection + remote_port, username, password = get_web_server_connection(config) return web_server_ota.run_ota( network_devices, remote_port, username, password, binary ) +def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int: + from esphome import web_server_logs + from esphome.web_server_helpers import get_web_server_connection + + port, username, password = get_web_server_connection(config) + return web_server_logs.run_logs(network_devices, port, username, password) + + # Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a # 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as # bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the @@ -1418,17 +1482,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_miniterm(config, port, args) # Check if we should use API for logging - # Resolve MQTT magic strings to actual IP addresses - if has_api() and ( - network_devices := _resolve_network_devices(devices, config, args) - ): - from esphome.api_client import run_logs + if has_api(): + network_devices, has_mqtt_lookup = _split_network_devices(devices) + mqtt_resolver = None + if has_mqtt_lookup: + if network_devices: + # Addresses are already known, so don't block startup on the + # MQTT broker lookup; hand it to run_logs as a deferred + # resolver that runs in the background and feeds discovered + # addresses into the running log client, keeping MQTT as a + # fallback for when the known addresses are stale (e.g. DHCP + # reassigned the IP). + mqtt_resolver = functools.partial( + _mqtt_get_ip_or_warn, + config, + args.username, + args.password, + args.client_id, + ) + else: + # The MQTT lookup is the only way to find the device; resolve + # it up front since the client needs an address to start with. + network_devices = _resolve_network_devices(devices, config, args) + if network_devices: + from esphome.api_client import run_logs - return run_logs( - config, - network_devices, - subscribe_states=_should_subscribe_states(args), - ) + return run_logs( + config, + network_devices, + subscribe_states=_should_subscribe_states(args), + mqtt_resolver=mqtt_resolver, + ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): from esphome import mqtt @@ -1437,6 +1521,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int config, args.topic, args.username, args.password, args.client_id ) + # Fall back to the web_server HTTP SSE log stream for devices that have + # web_server: but no api: (the logging counterpart to web_server OTA). + if has_web_server_logging() and ( + network_devices := _resolve_network_devices(devices, config, args) + ): + return _show_logs_via_web_server(config, network_devices) + raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") @@ -2641,7 +2732,8 @@ def run_esphome(argv): conf_path.name, ) - if config is None: + cache_missed = config is None + if cache_missed: from esphome.config import read_config config = read_config( @@ -2650,26 +2742,25 @@ def run_esphome(argv): # Snapshot only needed by `esphome config --no-defaults`. snapshot_user_config=getattr(args, "no_defaults", False), ) - # Refresh the cache so the next upload/logs hits the fast path - # instead of re-running read_config. Skip when the storage - # sidecar is absent (no compile has run): the cache would - # never be loaded back, so writing secrets to disk is wasted. - if cache_eligible and config is not None: - from esphome.compiled_config import save_compiled_config - from esphome.storage_json import ext_storage_path - - if ext_storage_path(conf_path.name).exists(): - save_compiled_config(config) - if config is None: - return 2 + if config is None: + return 2 CORE.config = config # Fallback for platforms whose validators didn't set the toolchain # (only the esp32 component reads esp32.framework.toolchain). All - # other platforms only support PlatformIO today. + # other platforms only support PlatformIO today. Must run before the + # cache refresh below so its sidecar records the same toolchain a + # compile would. if CORE.toolchain is None: CORE.toolchain = Toolchain.PLATFORMIO + # Refresh the cache so the next upload/logs hits the fast path + # instead of re-running read_config. + if cache_eligible and cache_missed: + from esphome.compiled_config import save_compiled_config_and_sidecar + + save_compiled_config_and_sidecar(config) + if args.command not in POST_CONFIG_ACTIONS: safe_print(f"Unknown command {args.command}") return 1 diff --git a/esphome/api_client.py b/esphome/api_client.py index a75f219b17..fb41075de8 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress import logging +import threading from typing import TYPE_CHECKING, Any import warnings @@ -20,6 +21,8 @@ from esphome.stacktrace import LogLineProcessor from esphome.util import safe_print if TYPE_CHECKING: + from collections.abc import Callable + from aioesphomeapi.api_pb2 import ( SubscribeLogsResponse, # pylint: disable=no-name-in-module ) @@ -32,8 +35,18 @@ async def async_run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: - """Run the logs command in the event loop.""" + """Run the logs command in the event loop. + + If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt + has no asyncio support on Windows) concurrently with the connection + attempts to ``addresses``, and any addresses it discovers are fed into + the running client. It owns its own failure handling (returning [] when + discovery fails) and must honor the ``threading.Event`` it is passed so + teardown is not delayed by the lookup's wait window; the initial broker + connect itself is only bounded by the socket timeout. + """ from datetime import datetime conf = config["api"] @@ -60,6 +73,41 @@ async def async_run_logs( # Decoder resolution policy lives in LogLineProcessor. processor = LogLineProcessor(config, CORE.target_platform) + mqtt_task: asyncio.Task[None] | None = None + mqtt_stop_event = threading.Event() + + def _cancel_mqtt_discovery() -> None: + """Stop the broker lookup once a connection has been established. + + Its answer is only useful while still disconnected: after that it + either duplicates the connected address or arrives too late to + matter, so don't keep an idle broker session open for it. + """ + mqtt_stop_event.set() + if mqtt_task is not None and not mqtt_task.done(): + mqtt_task.cancel() + + async def _resolve_mqtt_addresses() -> None: + """Discover the device address via the MQTT broker in the background.""" + try: + mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event) + if not mqtt_ips: + _LOGGER.debug( + "MQTT discovery %s", + "aborted" if mqtt_stop_event.is_set() else "found no addresses", + ) + return + if cli.add_addresses(mqtt_ips): + _LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips)) + else: + _LOGGER.debug( + "MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips) + ) + except Exception: # pylint: disable=broad-except + # A background task failure would otherwise stay invisible for + # the whole session and only re-raise at teardown + _LOGGER.exception("MQTT address discovery failed") + def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" time_ = datetime.now().astimezone() @@ -98,20 +146,53 @@ async def async_run_logs( # 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, + on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None, ) try: + # Don't start (or keep) the broker lookup if a connection already + # succeeded; the stop event doubles as the not-needed-anymore latch + # and get_esphome_device_ip returns immediately when it is set. + if mqtt_resolver is not None and not mqtt_stop_event.is_set(): + mqtt_task = asyncio.create_task(_resolve_mqtt_addresses()) await asyncio.Event().wait() finally: - await stop() + try: + if mqtt_task is not None: + # Unblock the worker thread first so it can't hold up + # loop.shutdown_default_executor() for the full lookup timeout. + mqtt_stop_event.set() + # Give the worker a moment to exit through its own error + # handling; cancelling first would race out a late failure. + done, _ = await asyncio.wait([mqtt_task], timeout=1.0) + if not done: + mqtt_task.cancel() + # return_exceptions keeps a CancelledError from the cancel() + # above from re-raising here and jumping over the stop() below. + # The task handles Exception itself, so only a BaseException + # escape (e.g. SystemExit from the worker) can land here. + (result,) = await asyncio.gather(mqtt_task, return_exceptions=True) + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + _LOGGER.error("MQTT address discovery failed", exc_info=result) + finally: + # Must run even if a second cancellation lands mid-cleanup above + await stop() def run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: """Run the logs command.""" with suppress(KeyboardInterrupt): asyncio.run( - async_run_logs(config, addresses, subscribe_states=subscribe_states) + async_run_logs( + config, + addresses, + subscribe_states=subscribe_states, + mqtt_resolver=mqtt_resolver, + ) ) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 303af99e66..be03eea965 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -18,9 +18,9 @@ from pathlib import Path from typing import Any from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, Lambda +from esphome.core import CORE, EsphomeError, Lambda from esphome.helpers import write_file -from esphome.storage_json import StorageJSON, ext_storage_path +from esphome.storage_json import StorageJSON, ext_storage_path, storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,71 @@ def save_compiled_config(config: ConfigType) -> None: # non-basic dict key), so every upload/logs pays the slow path. _LOGGER.warning("Cannot cache the validated config: %s", err) except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - _LOGGER.debug("Skipping compiled config cache write: %s", err) + # Likely persistent (permissions, full disk): every upload/logs + # pays the slow path until it clears, so surface it. + _LOGGER.warning("Skipping compiled config cache write: %s", err) + + +def save_compiled_config_and_sidecar(config: ConfigType) -> None: + """Refresh the cache from the upload/logs fallback (CORE.config must be set). + + The cache is only written when a complete sidecar is on disk: + load_compiled_config can't use it otherwise, and it holds resolved + secrets. + """ + if _refresh_sidecar(): + save_compiled_config(config) + + +def _refresh_sidecar() -> bool: + """Ensure a complete sidecar is on disk; True when one is. + + Writes one (without claiming a build) when missing or wizard-only. + Failures are non-fatal; the next upload/logs pays the slow path again. + """ + try: + path = storage_path() + try: + old = StorageJSON.load_strict(path) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # Present but unreadable: it may hold a real build's metadata, + # and a fresh rewrite would also stop the next compile from + # cleaning a possibly incoherent build tree. + _LOGGER.warning( + "Not caching: storage sidecar %s is unreadable (%s)", path, err + ) + return False + if old is not None and old.can_apply_to_core(): + # Compile-written; nothing to refresh. + return True + if CORE.build_path is not None and CORE.build_path.exists(): + # An unvalidated build tree: its absent or mismatched sidecar + # is what makes the next compile wipe it, so don't vouch for + # a build this run never saw. + _LOGGER.warning( + "Not caching: build tree %s has no matching sidecar; " + "'esphome compile' will settle it", + CORE.build_path, + ) + return False + new = StorageJSON.from_esphome_core(CORE, old, claim_build=False) + if not new.can_apply_to_core(): + _LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete") + return False + new.save(path) + return True + except (OSError, EsphomeError) as err: + # write_file wraps OSError into EsphomeError. Persistent + # (unwritable storage dir), so surface that every upload/logs + # pays the slow path. + _LOGGER.warning("Could not refresh the storage sidecar: %s", err) + except Exception: # noqa: BLE001 # pylint: disable=broad-except + # A structural bug; keep the traceback so it isn't mistaken + # for the I/O failure above. + _LOGGER.warning( + "Unexpected error refreshing the storage sidecar", exc_info=True + ) + return False def load_compiled_config(conf_path: Path) -> ConfigType | None: @@ -98,11 +162,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) - if storage is None: - return None - # apply_to_core assumes a real compile wrote the sidecar; wizard-only - # sidecars leave both of these unset and can't drive upload/logs. - if not storage.core_platform and not storage.target_platform: + if storage is None or not storage.can_apply_to_core(): + _LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete") return None storage.apply_to_core() return config diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py new file mode 100644 index 0000000000..e701bd98d4 --- /dev/null +++ b/esphome/component_aliases.py @@ -0,0 +1,10 @@ +"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "rp2040": ("rp2", "2027.7.0"), +} diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 0df7c56313..6da5268432 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -13,8 +13,13 @@ import esphome.components.image as espImage import esphome.config_validation as cv +from . import image as animation_image from .image import ANIMATION_CONFIG_SCHEMA, setup_animation +# The deprecated top-level `animation:` shim gets the same batched +# downloads as the `image:` platform form. +PREFETCH_FILES = animation_image.PREFETCH_FILES + AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 95875fe2b0..73d428bd20 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_LOOP +from esphome.components.file import image as file_image from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv @@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] + +# The animation platform shares the file platform's remote file handling, +# including its batch-download hook. +PREFETCH_FILES = file_image.PREFETCH_FILES AUTO_LOAD = ["file"] DEPENDENCIES = ["display"] diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2954206e90..0af733d716 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -448,7 +448,7 @@ void APIConnection::on_disconnect_response() { uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif @@ -459,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { // Set common fields that are shared by all entity types - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); if (entity->has_own_name()) { msg.name = entity->get_name(); @@ -1149,7 +1149,7 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_entity_key(); + msg.key = camera::Camera::instance()->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 4da7d48882..15a8b08139 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -37,7 +37,7 @@ from esphome.const import ( CONF_INTERVAL, KEY_TARGET_PLATFORM, ) -from esphome.core import CORE, ID, KEY_CORE +from esphome.core import CORE, ID, KEY_CORE, TimePeriod from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] @@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: return config +# The historical scan window default shared by the trackers that do not pin +# their own; also the fallback for esp32's conditional default. +DEFAULT_SCAN_WINDOW = "30ms" + + def scan_parameters_schema( interval_default: str, *, - window_default: str = "30ms", + window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). The `active` option (default on) is - unconditional: active scanning is part of the tracker contract — every - current proxy client assumes it, so a passive-only tracker must not share - this schema. + LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg + callable evaluated per validation when the user omits the key (esp32 uses + this to record that the window was defaulted, so a later validation step + can adjust it once sibling keys are resolved). The `active` option + (default on) is unconditional: active scanning is part of the tracker + contract — every current proxy client assumes it, so a passive-only + tracker must not share this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 63f63c5da2..c12eb39d2d 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -1,4 +1,3 @@ -import hashlib from pathlib import Path from esphome import core, external_files @@ -12,6 +11,8 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, ) +from esphome.external_files import RemoteFile +from esphome.types import ConfigType CODEOWNERS = ["@neffs", "@kbx81"] CONFLICTS_WITH = ["bme680_bsec"] @@ -74,11 +75,7 @@ VOLTAGE_FILE_NAME = { def _compute_local_file_path(url: str) -> Path: - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def _compute_url(config: dict) -> str: @@ -105,6 +102,42 @@ def download_bme68x_blob(config): return config +# Shared by the schema and the prefetch hook so they cannot drift. +_MODEL_VALIDATOR = cv.one_of(*MODEL_OPTIONS, lower=True) +_ALGORITHM_OUTPUT_VALIDATOR = cv.enum(ALGORITHM_OUTPUT_OPTIONS, lower=True) +# Key -> (validator, default) for the defaulted options that select the blob. +_BLOB_OPTIONS = { + CONF_OPERATING_AGE: (cv.enum(OPERATING_AGE_OPTIONS, lower=True), "28d"), + CONF_SAMPLE_RATE: (cv.enum(SAMPLE_RATE_OPTIONS, upper=True), "LP"), + CONF_SUPPLY_VOLTAGE: (cv.enum(VOLTAGE_OPTIONS, upper=True), "3.3V"), +} + + +def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None: + """Raw entry to its BSEC2 blob; None when a value is unrecognized. + + Applies the schema defaults and validators read-only; skipped entries + are left to the schema validator. + """ + try: + spec = { + key: validator(str(entry.get(key, default))) # pylint: disable=not-callable + for key, (validator, default) in _BLOB_OPTIONS.items() + } + spec[CONF_MODEL] = _MODEL_VALIDATOR(str(entry.get(CONF_MODEL, ""))) + if (algorithm_output := entry.get(CONF_ALGORITHM_OUTPUT)) is not None: + spec[CONF_ALGORITHM_OUTPUT] = _ALGORITHM_OUTPUT_VALIDATOR( + str(algorithm_output) + ) + except cv.Invalid: + return None + url = _compute_url(spec) + return RemoteFile(url, _compute_local_file_path(url)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref) + + def validate_bme68x(config): if CONF_ALGORITHM_OUTPUT not in config: return config @@ -128,19 +161,12 @@ CONFIG_SCHEMA_BASE = ( { cv.GenerateID(): cv.declare_id(BME68xBSEC2Component), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.Required(CONF_MODEL): cv.one_of(*MODEL_OPTIONS, lower=True), - cv.Optional(CONF_ALGORITHM_OUTPUT): cv.enum( - ALGORITHM_OUTPUT_OPTIONS, lower=True - ), - cv.Optional(CONF_OPERATING_AGE, default="28d"): cv.enum( - OPERATING_AGE_OPTIONS, lower=True - ), - cv.Optional(CONF_SAMPLE_RATE, default="LP"): cv.enum( - SAMPLE_RATE_OPTIONS, upper=True - ), - cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum( - VOLTAGE_OPTIONS, upper=True - ), + cv.Required(CONF_MODEL): _MODEL_VALIDATOR, + cv.Optional(CONF_ALGORITHM_OUTPUT): _ALGORITHM_OUTPUT_VALIDATOR, + **{ + cv.Optional(key, default=default): validator + for key, (validator, default) in _BLOB_OPTIONS.items() + }, cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, cv.Optional( CONF_STATE_SAVE_INTERVAL, default="6hours" diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index c8ca0ba022..dacd4e32ad 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import bme68x_bsec2, i2c from esphome.components.bme68x_bsec2 import ( CONFIG_SCHEMA_BASE, BME68xBSEC2Component, @@ -13,6 +13,11 @@ AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] MULTI_CONF = True +# The user-facing domain is this module (the base component only appears +# via AUTO_LOAD), so the batch-download hook must be re-exported here to +# take effect. +PREFETCH_FILES = bme68x_bsec2.PREFETCH_FILES + bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c") BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_( "BME68xBSEC2I2CComponent", BME68xBSEC2Component, i2c.I2CDevice diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 44878274d6..3ba89d2838 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -22,6 +22,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_NOX_INDEX = "nox_index" @@ -35,6 +36,7 @@ CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SHA256 = "sha256" +CONF_SLOT = "slot" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 9125c43f0c..2120abe5f7 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -3,6 +3,7 @@ import re from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import CONF_LABEL from esphome.components.number import Number from esphome.components.select import Select from esphome.components.switch import Switch @@ -30,7 +31,6 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base") CONF_ROTARY = "rotary" CONF_JOYSTICK = "joystick" -CONF_LABEL = "label" CONF_MENU = "menu" CONF_BACK = "back" CONF_SELECT = "select" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2e72c78974..7263571d69 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -570,6 +570,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Factory format (Previously Modern)", @@ -3280,27 +3283,45 @@ def copy_files(): __version__, ) + # Remote extra build files are fetched into the shared download cache in + # one parallel batch (conditional requests skip unchanged files), then + # copied into the build tree like their local counterparts. + sources: dict[str, Path] = {} + remote: list[tuple[str, str]] = [] for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): name: str = file[KEY_NAME] path: Path = file[KEY_PATH] if str(path).startswith("http"): - import requests - - from esphome.happy_eyeballs import ensure_happy_eyeballs - - ensure_happy_eyeballs() - - try: - req = requests.get(path, timeout=30) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise EsphomeError( - f"Could not download extra build file {path}: {e}" - ) from e - CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - CORE.relative_build_path(name).write_bytes(req.content) + remote.append((name, str(path))) else: - copy_file_if_changed(path, CORE.relative_build_path(name)) + sources[name] = path + if remote: + # Imported lazily: requests (via external_files) is a heavy import + # and remote extra build files are rare. + from esphome import external_files + + downloads: list[external_files.RemoteFile] = [] + for name, url in remote: + cache_path = external_files.compute_local_file_path(KEY_ESP32, url) + # Unverifiable bytes: an unrevalidated copy is an error, matching + # the old always-download behavior on network failure. + downloads.append( + external_files.RemoteFile(url, cache_path, allow_stale=False) + ) + sources[name] = cache_path + try: + external_files.download_content_many( + downloads, description="extra build file(s)" + ) + except cv.MultipleInvalid as e: + details = "; ".join(str(err) for err in e.errors) + raise EsphomeError( + f"Could not download extra build file(s): {details}" + ) from e + except cv.Invalid as e: + raise EsphomeError(f"Could not download extra build file(s): {e}") from e + for name, source in sources.items(): + copy_file_if_changed(source, CORE.relative_build_path(name)) def _decode_pc(config, addr): diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 16501ef3b2..e2d79173ff 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -648,6 +648,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm + case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init + case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init return; default: diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 634b8c3bef..28c8c7fcf1 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +from dataclasses import dataclass import logging from esphome import automation @@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, + idf_version, request_bluetooth, request_software_coexistence, ) @@ -35,10 +38,12 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType +DOMAIN = "esp32_ble_tracker" + AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] @@ -125,10 +130,71 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config +# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far +# longer than the configured window (espressif/esp-idf#18931). Before the fix, +# the default 30 ms window in a 320 ms interval effectively scanned at a much +# higher duty cycle than requested; with the fix, that same default only +# listens 9.4 % of the time and misses most advertisements when wifi shares +# the radio. Espressif recommends setting the window equal to the interval in +# that case: the coexistence arbiter still shares the radio with wifi, and +# BLE uses the airtime wifi does not claim. +IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5) + + +@dataclass +class TrackerData: + """Per-run validation state, namespaced under DOMAIN in CORE.data.""" + + scan_window_defaulted: bool = False + + +def _get_data() -> TrackerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TrackerData() + return CORE.data[DOMAIN] + + +def _scan_window_default() -> TimePeriod: + """Schema default for the scan window. + + Records that the user did not set a window, so _raise_defaulted_scan_window + can tell a defaulted 30 ms from an explicit one; the raise itself must wait + for the outer schema because it depends on software_coexistence, a sibling + key not yet resolved here. + """ + _get_data().scan_window_defaulted = True + return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW) + + +def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: + """Raise a defaulted scan window to the interval where that is safe. + + Only when the coexistence arbiter is compiled in (software_coexistence, + present iff wifi is configured and not disabled by the user) and the IDF + honors the window strictly (>= 5.5.5); without the arbiter a full-duty + scan would starve wifi outright, and a user-set window is never touched. + Raising to the interval cannot invalidate the already-validated + parameters, so no re-validation is needed. + """ + if ( + _get_data().scan_window_defaulted + and config.get(CONF_SOFTWARE_COEXISTENCE) + and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION + ): + params = config[CONF_SCAN_PARAMETERS] + # Copy so the config dump shows a plain value instead of a YAML + # anchor/alias pair pointing at the interval. + params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) + return config + + # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") +# The window default is conditional (see _scan_window_default above). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "320ms", window_default=_scan_window_default +) # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. @@ -183,6 +249,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_max_connections_deprecated, + _raise_defaulted_scan_window, ) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index b15ae53711..c6a714aace 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -3,7 +3,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -33,7 +33,6 @@ CONF_DATA_READY_PIN = "data_ready_pin" CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high" CONF_HANDSHAKE_PIN = "handshake_pin" CONF_SDIO_FREQUENCY = "sdio_frequency" -CONF_SLOT = "slot" CONF_SPI_MODE = "spi_mode" # Shared fields for both transport modes diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 1f7159919d..2161a902cb 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -113,6 +113,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Standard format", diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 8bdd536ffb..f3c77baaae 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -355,7 +355,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.9.0.", + "Removal scheduled for 2026.11.0.", config[CONF_CLK_MODE], mode, pin, diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 9a7c762a79..b54c3f2adf 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -1,7 +1,6 @@ from __future__ import annotations import contextlib -import hashlib import io import logging from pathlib import Path @@ -43,15 +42,13 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, MockObjClass +from esphome.external_files import RemoteFile from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - SOURCE_LOCAL = "local" SOURCE_WEB = "web" @@ -65,16 +62,16 @@ MDI_SOURCES = { SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", } +# Shared by the schema validator and the prefetch extractor so they cannot +# drift. +_MDI_ICON_RE = re.compile(r"^[a-zA-Z0-9\-]+$") -def compute_local_image_path(value) -> Path: + +def compute_local_image_path(value: str | ConfigType) -> Path: url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] # Downloaded files are cached under the shared `image` domain directory so # the cache location is unaffected by which platform requested the file. - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def local_path(value): @@ -83,16 +80,20 @@ def local_path(value): def download_file(url, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) + # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be + # silently ignored on a per-run memo hit anyway (memos key by path). + external_files.download_content(url, path) return str(path) -def download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value +def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" + return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" - url = MDI_SOURCES[source] + mdi_id + ".svg" + +def download_gh_svg(value: str | ConfigType, source: str) -> str: + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) @@ -101,17 +102,53 @@ def download_image(value): return download_file(value, compute_local_image_path(value)) -def validate_file_shorthand(value): - value = cv.string_strict(value) +def _parse_remote_shorthand(value: str) -> RemoteFile | None: + """Parse a string `file:` shorthand to its remote file; None if local. + + Raises cv.Invalid for a malformed icon name. Shared by the schema + validator and the prefetch extractor so they cannot drift. + """ parts = value.strip().split(":") if len(parts) == 2 and parts[0] in MDI_SOURCES: - match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) - if match is None: + if _MDI_ICON_RE.match(parts[1]) is None: raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") - return download_gh_svg(parts[1], parts[0]) - + return RemoteFile(*_gh_svg_url_path(parts[1], parts[0])) if value.startswith(("http://", "https://")): - return download_image(value) + return RemoteFile(value, compute_local_image_path(value)) + return None + + +def _extract_file_ref(value: object) -> RemoteFile | None: + """Map a raw, pre-schema `file:` value to its remote file. + + Returns None for local files and anything it does not recognize; the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + return _parse_remote_shorthand(value) + except cv.Invalid: + return None + if isinstance(value, dict): + source = value.get(CONF_SOURCE) + if source == SOURCE_WEB and isinstance(url := value.get(CONF_URL), str): + return RemoteFile(url, compute_local_image_path(url)) + if source in MDI_SOURCES and isinstance(icon := value.get(CONF_ICON), str): + return RemoteFile(*_gh_svg_url_path(icon, source)) + return None + + +def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: + return _extract_file_ref(entry.get(CONF_FILE)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) + + +def validate_file_shorthand(value): + value = cv.string_strict(value) + if (remote := _parse_remote_shorthand(value)) is not None: + return download_file(remote.url, remote.path) value = cv.file_(value) return local_path(value) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 5872b607f1..918fde5dbd 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -1,6 +1,5 @@ -from collections.abc import MutableMapping +from collections.abc import Iterable, MutableMapping import functools -import hashlib from itertools import accumulate import logging from pathlib import Path @@ -17,7 +16,6 @@ from freetype import ( FT_Exception, ft_pixel_mode_mono, ) -import requests from esphome import external_files import esphome.codegen as cg @@ -36,7 +34,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt -from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.external_files import RemoteFile from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -296,46 +294,80 @@ def validate_weight_name(value): return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)] -def _compute_local_font_path(value: dict) -> Path: - url = value[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - _LOGGER.debug("_compute_local_font_path: %s", base_dir / key) - return base_dir / key +def _web_font_path(value: dict) -> Path: + return external_files.compute_local_file_path(DOMAIN, value[CONF_URL]) / "font.ttf" -def download_gfont(value): +def _gfonts_css_url(value: dict) -> str: + return ( + f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}" + f":ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" + ) + + +def _gfonts_cache_path(value: dict, suffix: str) -> Path: + name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1" + return external_files.compute_local_file_dir(DOMAIN) / f"{name}.{suffix}" + + +def _gfonts_ttf_path(value: dict) -> Path: + return _gfonts_cache_path(value, "ttf") + + +def _gfonts_css_path(value: dict) -> Path: + return _gfonts_cache_path(value, "css") + + +def _parse_gfonts_css(css: str) -> str | None: + """Extract the truetype URL from a Google Fonts CSS response.""" + match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", css) + return match.group(1) if match else None + + +def download_gfont(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value - name = ( - f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" - ) - url = f"https://fonts.googleapis.com/css2?family={name}" - path = ( - external_files.compute_local_file_dir(DOMAIN) - / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" - ) + path = _gfonts_ttf_path(value) if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) + url = _gfonts_css_url(value) + css_path = _gfonts_css_path(value) try: - ensure_happy_eyeballs() - req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) - req.raise_for_status() - except requests.exceptions.RequestException as e: + css_bytes = external_files.download_content(url, css_path) + except cv.Invalid as e: raise cv.Invalid( f"Could not download font at {url}, please check the fonts exists " f"at google fonts ({e})" ) from e - match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) - if match is None: + if not ( + external_files.is_fresh_this_run(css_path) or CORE.skip_external_update + ): + # Same rule as PREFETCH_FILES stage two: a CSS body that could + # not be revalidated may name a rotated ttf URL. Use the cached + # font instead (the failed check already warned). + if path.exists(): + FONT_CACHE[value] = path + return value raise cv.Invalid( - f"Could not extract ttf file from gfonts response for {name}, " - f"please report this." + f"Could not refresh the Google Fonts CSS for " + f"{value[CONF_FAMILY]} and no cached font is available" + ) + try: + css = css_bytes.decode("utf-8") + except UnicodeDecodeError as e: + # Do not leave an unusable body in the cache to be served again. + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Bad response from Google Fonts for {value[CONF_FAMILY]}: " + f"not a text document" + ) from e + ttf_url = _parse_gfonts_css(css) + if ttf_url is None: + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Could not extract ttf file from gfonts response for " + f"{value[CONF_FAMILY]}, please report this." ) - - ttf_url = match.group(1) _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) external_files.download_content(ttf_url, path) @@ -346,11 +378,11 @@ def download_gfont(value): return value -def download_web_font(value): +def download_web_font(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value url = value[CONF_URL] - path = _compute_local_font_path(value) / "font.ttf" + path = _web_font_path(value) external_files.download_content(url, path) _LOGGER.debug("download_web_font: path=%s", path) @@ -358,13 +390,18 @@ def download_web_font(value): return value +# Shared by the schema and the prefetch extractor so they cannot drift. +_DEFAULT_WEIGHT = "regular" +_DEFAULT_ITALIC = False +_DEFAULT_REFRESH = "1d" +_WEIGHT_VALIDATOR = cv.Any(cv.int_, validate_weight_name) +_REFRESH_VALIDATOR = cv.All(cv.string, cv.source_refresh) + EXTERNAL_FONT_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEIGHT, default="regular"): cv.Any( - cv.int_, validate_weight_name - ), - cv.Optional(CONF_ITALIC, default=False): cv.boolean, - cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh), + cv.Optional(CONF_WEIGHT, default=_DEFAULT_WEIGHT): _WEIGHT_VALIDATOR, + cv.Optional(CONF_ITALIC, default=_DEFAULT_ITALIC): cv.boolean, + cv.Optional(CONF_REFRESH, default=_DEFAULT_REFRESH): _REFRESH_VALIDATOR, } ) @@ -387,36 +424,123 @@ WEB_FONT_SCHEMA = cv.All( ) -def validate_file_shorthand(value): - value = cv.string_strict(value) +_GFONTS_SHORTHAND_RE = re.compile(r"^gfonts://([^@]+)(@.+)?$") + + +def _shorthand_to_file_dict(value: str) -> ConfigType | None: + """Typed-dict form of a remote font shorthand. + + Shared by the schema validator and the prefetch extractor so the two + cannot drift. Returns None for values that are not remote shorthand + (i.e. local paths); raises cv.Invalid for a malformed gfonts shorthand. + """ if value.startswith("gfonts://"): - match = re.match(r"^gfonts://([^@]+)(@.+)?$", value) - if match is None: + if (match := _GFONTS_SHORTHAND_RE.match(value)) is None: raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it") - family = match.group(1) - weight = match.group(2) - data = { + data = {CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: match.group(1)} + if match.group(2): + data[CONF_WEIGHT] = match.group(2)[1:] + return data + if value.startswith(("http://", "https://")): + return {CONF_TYPE: TYPE_WEB, CONF_URL: value} + return None + + +def _extract_remote_font(value: object) -> ConfigType | None: + """Map a raw, pre-schema font `file:` value to a normalized remote spec. + + Read-only mirror of `validate_file_shorthand` / `TYPED_FILE_SCHEMA` for + the prefetch hooks; returns None for local fonts and anything it does + not recognize. A wrong answer only wastes or misses a prefetch, the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + value = _shorthand_to_file_dict(value) + except cv.Invalid: + return None + if not isinstance(value, dict): + return None + font_type = value.get(CONF_TYPE) + if font_type == TYPE_WEB and isinstance(url := value.get(CONF_URL), str): + return {CONF_TYPE: TYPE_WEB, CONF_URL: url} + if font_type == TYPE_GFONTS and isinstance(family := value.get(CONF_FAMILY), str): + try: + italic = cv.boolean(value.get(CONF_ITALIC, _DEFAULT_ITALIC)) + weight = _WEIGHT_VALIDATOR(value.get(CONF_WEIGHT, _DEFAULT_WEIGHT)) + refresh = _REFRESH_VALIDATOR(value.get(CONF_REFRESH, _DEFAULT_REFRESH)) + except cv.Invalid: + return None + return { CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: family, + CONF_WEIGHT: weight, + CONF_ITALIC: italic, + CONF_REFRESH: refresh, } - if weight is not None: - data[CONF_WEIGHT] = weight[1:] - return font_file_schema(data) + return None - if value.startswith(("http://", "https://")): - return font_file_schema( - { - CONF_TYPE: TYPE_WEB, - CONF_URL: value, - } - ) - return font_file_schema( - { - CONF_TYPE: TYPE_LOCAL, - CONF_PATH: value, - } - ) +def _iter_remote_specs(entries: list[ConfigType]) -> Iterable[ConfigType]: + """Yield the remote spec of every `file:` value, including extras.""" + for entry in entries: + values = [entry.get(CONF_FILE)] + extras = entry.get(CONF_EXTRAS) + if isinstance(extras, dict): + # The schema runs cv.ensure_list on extras, so a bare mapping + # is valid raw config; mirror that normalization here. + extras = [extras] + if isinstance(extras, list): + values.extend( + extra.get(CONF_FILE) for extra in extras if isinstance(extra, dict) + ) + for value in values: + if (spec := _extract_remote_font(value)) is not None: + yield spec + + +def PREFETCH_FILES(entries: list[ConfigType]) -> Iterable[list[RemoteFile]]: + """Batch-download hook: web fonts, then Google Fonts CSS, then ttf. + + Stage one fetches web fonts and the CSS of stale gfonts; stage two + parses the now-cached CSS for the ttf URLs it names. + """ + stage1: list[RemoteFile] = [] + # Keyed by cache path: the same font at several sizes is one download, + # one freshness stat, and one stage-two CSS parse. + stale_gfonts: dict[Path, ConfigType] = {} + seen_web: set[Path] = set() + for spec in _iter_remote_specs(entries): + if spec[CONF_TYPE] == TYPE_WEB: + if (path := _web_font_path(spec)) not in seen_web: + seen_web.add(path) + stage1.append(RemoteFile(spec[CONF_URL], path)) + elif (css_path := _gfonts_css_path(spec)) not in stale_gfonts and ( + not external_files.is_file_recent( + _gfonts_ttf_path(spec), spec[CONF_REFRESH] + ) + ): + stale_gfonts[css_path] = spec + stage1.append(RemoteFile(_gfonts_css_url(spec), css_path)) + yield stage1 + + yield [ + RemoteFile(ttf_url, _gfonts_ttf_path(spec)) + for css_path, spec in stale_gfonts.items() + # Only trust CSS that stage one actually refreshed this run; a + # leftover from an earlier run may name a rotated ttf URL. + if external_files.is_fresh_this_run(css_path) + and css_path.exists() + and (ttf_url := _parse_gfonts_css(css_path.read_text("utf-8", "replace"))) + is not None + ] + + +def validate_file_shorthand(value: object) -> ConfigType: + value = cv.string_strict(value) + if (data := _shorthand_to_file_dict(value)) is None: + data = {CONF_TYPE: TYPE_LOCAL, CONF_PATH: value} + return font_file_schema(data) TYPED_FILE_SCHEMA = cv.typed_schema( diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index fc0318f076..ccccf06d69 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -29,6 +29,8 @@ from esphome.const import ( CONF_URL, ) from esphome.core import ID +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["touchscreen"] @@ -103,8 +105,7 @@ def _validate_firmware_data(data: bytes, source: str) -> None: def _cache_path(url: str) -> Path: """Cache path for a downloaded firmware blob, keyed by URL.""" - key = hashlib.sha256(url.encode()).hexdigest()[:8] - return external_files.compute_local_file_dir(DOMAIN) / key + return external_files.compute_local_file_path(DOMAIN, url) def firmware_path(firmware: dict) -> Path: @@ -156,6 +157,23 @@ FIRMWARE_SCHEMA = cv.All( ) +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if firmware is None: + model = str(entry.get(CONF_MODEL, "CUSTOM")).upper() + firmware = MODELS.get(model, {}).get(CONF_FIRMWARE) + if ( + isinstance(firmware, dict) + and CONF_FILE not in firmware + and isinstance(url := firmware.get(CONF_URL), str) + ): + return RemoteFile(url, _cache_path(url)) + return None + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def _config_schema(config): model_option = { cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True) diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 0dc146a061..a780854831 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -13,10 +13,17 @@ static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back b static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; +// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away. +static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4; +// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the +// rest names the button - the low byte for the door commands, the second register for those that do not fit +// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each. static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; +// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share. +static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false}; // High byte of the state register and the door state it stands for. State 0x00 is decoded separately because // its low byte tells a plain stop from the vent position. @@ -58,17 +65,29 @@ void HoermannHcp::update() { // Status broadcasts alone keep the connection alive, so a command the controller never fetches would // otherwise block every later one for as long as it keeps broadcasting. if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { - ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); - this->next_command_ = nullptr; - this->command_written_at_ = 0; - this->clear_target_(); + // Dropping after the press was presented leaves the door without its release value, which is worth saying + // apart from a command the controller never looked at. + if (this->command_written_at_ != 0) { + ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press", + this->next_command_->name); + } else { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); + } + this->drop_command_(); + // Children may have assumed the command would land, so let them re-derive from the door. + this->changed_ = true; } // A target waits for a door still travelling the other way to turn around. If it never does, the target has // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. - if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) { + if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) { ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); this->clear_target_(); } + // The door took the lamp key press but never reported the lamp changing, so stop expecting it to. + if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); + this->forget_light_toggles_(); + } if (this->changed_) { this->changed_ = false; this->state_callback_.call(); @@ -151,6 +170,16 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, this->on_state_reg_(registers[2]); if (registers.size() > 1) this->on_position_reg_(registers[1]); + if (registers.size() > 6) { + this->on_light_reg_(registers[6]); + return {}; + } + // Nothing refreshes the lamp any more, so what was read before must not be commanded against. + this->set_light_seen_(false); + if (!this->short_broadcast_logged_) { + this->short_broadcast_logged_ = true; + ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast(registers.size())); + } return {}; } @@ -165,11 +194,11 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { this->command_written_at_ = millis(); ESP_LOGI(TAG, "Sending '%s' command to door", command->name); registers.push_back(command->pressed_value); - registers.push_back(0x0000); + registers.push_back(command->pressed_value_2); return; } if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { - // Still inside the key-press window, so keep presenting 0x0000. + // Between the two events there is nothing to report, including in the second register. push_zeros(registers, 2); return; } @@ -177,8 +206,12 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { ESP_LOGD(TAG, "Released '%s' command", command->name); this->command_written_at_ = 0; this->next_command_ = nullptr; + // A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left + // to wait for, so it must not re-arm the watchdog. + if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0) + this->light_toggle_released_at_ = millis(); registers.push_back(command->released_value); - registers.push_back(0x0000); + registers.push_back(command->released_value_2); } void HoermannHcp::on_position_reg_(uint16_t value) { @@ -225,6 +258,13 @@ void HoermannHcp::on_state_reg_(uint16_t value) { ESP_LOGW(TAG, "Unknown door state 0x%02X", state); } +// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +void HoermannHcp::on_light_reg_(uint16_t value) { + this->set_light_seen_(true); + this->set_light_on_((value & 0x0010) != 0); +} + bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { if (!this->valid_) { // Queueing now would fire the command whenever the controller comes back, which may be much later. @@ -236,7 +276,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { return false; } // A new command supersedes any half-open target the door was still travelling to. - this->clear_target_(); + if (command.clears_target) + this->clear_target_(); this->next_command_ = &command; this->command_queued_at_ = millis(); return true; @@ -245,6 +286,31 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } +bool HoermannHcp::toggle_light() { + if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) { + ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one"); + return false; + } + if (!this->queue_command_(COMMAND_TOGGLE_LAMP)) + return false; + this->light_toggles_in_flight_++; + return true; +} +bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; } + +uint8_t HoermannHcp::unsent_light_toggles_() const { + return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0; +} + +bool HoermannHcp::cancel_light_toggle() { + // Once the pressed value has been presented the key press is already on the wire, so only an untouched + // command can be withdrawn. + if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0) + return false; + ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name); + this->drop_command_(); + return true; +} bool HoermannHcp::stop_door() { if (!is_moving(this->door_state_)) { @@ -270,6 +336,7 @@ bool HoermannHcp::set_position(float position) { if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE)) return false; this->target_position_ = position; + this->target_queued_at_ = millis(); this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; // A door already travelling that way is on its way; one moving the other way has to turn around first. this->target_started_ = this->door_state_ == this->target_direction_; @@ -292,9 +359,48 @@ void HoermannHcp::set_valid_(bool valid) { } ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. + this->drop_command_(); + // The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards. + this->clear_target_(); + this->forget_light_toggles_(); + // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted. + this->set_light_seen_(false); + this->short_broadcast_logged_ = false; +} + +void HoermannHcp::drop_command_() { + const bool was_light_toggle = this->is_light_toggle_pending_(); + // Cleared first so the settling below no longer counts this command among the toggles still to be sent. this->next_command_ = nullptr; this->command_written_at_ = 0; - this->clear_target_(); + if (was_light_toggle) { + // A lamp toggle says nothing about where the door was going, so it leaves the target alone. + this->light_toggle_settled_(); + } else { + this->clear_target_(); + } +} + +void HoermannHcp::light_toggle_settled_() { + if (this->light_toggles_in_flight_ == 0) + return; + this->light_toggles_in_flight_--; + // Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for. + if (this->light_toggles_in_flight_ == this->unsent_light_toggles_()) + this->light_toggle_released_at_ = 0; + // The light was showing where the lamp was heading, so it has to be told to look again. + this->changed_ = true; +} + +void HoermannHcp::forget_light_toggles_() { + // Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever. + this->light_toggle_released_at_ = 0; + // A toggle the door has not been shown yet is still going to fire, so it keeps counting. + const uint8_t unsent = this->unsent_light_toggles_(); + if (this->light_toggles_in_flight_ == unsent) + return; + this->light_toggles_in_flight_ = unsent; + this->changed_ = true; } void HoermannHcp::set_door_state_(DoorState state) { @@ -333,4 +439,26 @@ void HoermannHcp::clear_target_() { this->target_started_ = false; } +void HoermannHcp::set_light_on_(bool on) { + if (this->light_on_ == on) + return; + this->light_on_ = on; + this->changed_ = true; + if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) { + // The door has not been shown a toggle that could explain this, so the lamp was switched at the door. + ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on)); + return; + } + // The door acted, so one of the toggles it has seen has arrived. Any others still count. + this->light_toggle_settled_(); +} + +void HoermannHcp::set_light_seen_(bool seen) { + if (this->light_seen_ == seen) + return; + this->light_seen_ = seen; + // A resting door changes nothing else, so without this the light would never hear about it. + this->changed_ = true; +} + } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 142365f16e..41fd7617e4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -22,11 +22,15 @@ enum class DoorState : uint8_t { }; // A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a -// short delay the released value. The second command register remains zero. +// short delay the released value. Each half also carries a second register, which only the lamp command uses. struct HoermannHcpCommand { const char *name; uint16_t pressed_value; uint16_t released_value; + uint16_t pressed_value_2{0x0000}; + uint16_t released_value_2{0x0000}; + // A door command supersedes a half-open target; the lamp has no bearing on where the door is going. + bool clears_target{true}; }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { @@ -52,19 +56,41 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool impulse_door(); bool stop_door(); bool set_position(float position); + bool toggle_light(); DoorState get_door_state() const { return this->door_state_; } float get_current_position() const { return this->current_position_; } bool is_valid() const { return this->valid_; } + bool is_light_on() const { return this->light_on_; } + // False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection + // valid without saying anything about the lamp, so is_light_on() would still be its default. + bool is_light_known() const { return this->light_seen_; } + // Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the + // lamp still reads as its old self, so this is what a request has to be judged against. + bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); } + // Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright + // instead of fighting it. Returns false if there is nothing to cancel. + bool cancel_light_toggle(); protected: + // True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert. + bool is_light_toggle_pending_() const; + // Toggles the door has not been shown yet, which is at most the one still waiting in the command slot. + uint8_t unsent_light_toggles_() const; void record_response_(); // Returns false when the bus controller has not fetched the previous command yet. bool queue_command_(const HoermannHcpCommand &command); + // Throws away the pending command, taking any armed target with it unless the command was the lamp toggle. + void drop_command_(); + // One outstanding toggle reached the lamp, was withdrawn, or was thrown away. + void light_toggle_settled_(); + // Stops expecting the toggles the door has already been shown to reach the lamp. + void forget_light_toggles_(); // Appends the two key-press registers and advances the pending command's press/release state. void push_command_registers_(modbus::RegisterValues ®isters); void on_position_reg_(uint16_t value); void on_state_reg_(uint16_t value); + void on_light_reg_(uint16_t value); void set_valid_(bool valid); void set_door_state_(DoorState state); @@ -72,6 +98,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { void update_current_position_(); bool has_target_() const { return this->target_position_ != 0.0f; } void clear_target_(); + void set_light_on_(bool on); + void set_light_seen_(bool seen); CallbackManager state_callback_; @@ -82,8 +110,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { // Pending command / key-press state machine. const HoermannHcpCommand *next_command_{nullptr}; uint32_t command_queued_at_{0}; + // Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline. + uint32_t target_queued_at_{0}; uint32_t command_written_at_{0}; uint32_t last_response_{0}; + // When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the + // wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline. + uint32_t light_toggle_released_at_{0}; // A command is "pressed" for this long before its end value is sent. uint16_t key_press_delay_ms_{100}; @@ -102,9 +135,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { DoorState target_direction_{DoorState::STOPPED}; // Position as reported by the bus controller, 0..200 across the full travel. uint8_t position_raw_{0}; + uint8_t light_toggles_in_flight_{0}; bool target_started_{false}; bool valid_{false}; bool changed_{false}; + bool light_on_{false}; + bool light_seen_{false}; + bool short_broadcast_logged_{false}; }; } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/__init__.py b/esphome/components/hoermann_hcp/light/__init__.py new file mode 100644 index 0000000000..e895115db4 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpLight = hoermann_hcp_ns.class_( + "HoermannHcpLight", light.LightOutput, cg.Component +) + +CONFIG_SCHEMA = ( + light.light_schema(HoermannHcpLight, light.LightType.BINARY) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await light.new_light(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp new file mode 100644 index 0000000000..d3d784928d --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -0,0 +1,82 @@ +#include "hoermann_hcp_light.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.light"; + +light::LightTraits HoermannHcpLight::get_traits() { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::ON_OFF}); + return traits; +} + +void HoermannHcpLight::setup() { + // Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then. + this->status_set_warning(LOG_STR("waiting for the bus controller")); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; } + +void HoermannHcpLight::write_state(light::LightState *state) { + bool binary; + state->current_values_as_binary(&binary); + // A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on, + // so it is recognised by the value it carried rather than by the current one. + const optional published = this->published_state_; + this->published_state_.reset(); + // LightState::setup() always performs a call, so the very first write here is the restored state coming back + // rather than a request. + const bool restored = !this->boot_replay_done_; + this->boot_replay_done_ = true; + const bool heading_on = this->parent_->is_light_heading_on(); + if (binary == heading_on) + return; + if (restored) { + ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing"); + } else if (published != binary) { + if (!this->parent_->is_light_known()) { + // Commanding a lamp that has not been read could switch off one that is already on. + ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state"); + } else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) { + // A toggle the controller has not fetched is withdrawn outright rather than fought with a second one. + return; + } else { + ESP_LOGW(TAG, "Light command was not accepted by the door"); + } + } + // Nothing was sent, so the entity has to go back to showing the lamp rather than the request. + this->publish_lamp_state_(heading_on); +} + +void HoermannHcpLight::update_from_state_() { + if (this->light_state_ == nullptr) + return; + if (!this->parent_->is_valid()) { + this->status_set_warning(LOG_STR("bus controller not responding")); + return; + } + if (!this->parent_->is_light_known()) { + // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. + this->status_set_warning(LOG_STR("door has not reported the lamp")); + return; + } + this->status_clear_warning(); + const bool heading_on = this->parent_->is_light_heading_on(); + if (this->light_state_->remote_values.is_on() != heading_on) + this->publish_lamp_state_(heading_on); +} + +// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours. +void HoermannHcpLight::publish_lamp_state_(bool on) { + this->published_state_ = on; + auto call = this->light_state_->make_call(); + call.set_state(on); + // The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash. + call.set_save(false); + call.perform(); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h new file mode 100644 index 0000000000..82b12cb791 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpLight : public light::LightOutput, public Component { + public: + explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void setup_state(light::LightState *state) override; + light::LightTraits get_traits() override; + void write_state(light::LightState *state) override; + + protected: + void update_from_state_(); + void publish_lamp_state_(bool on); + + HoermannHcp *const parent_; + light::LightState *light_state_{nullptr}; + // Value last published and not yet seen come back, so the write carrying it is that publish, not a request. + optional published_state_; + // Set by the first write_state(), which is always the restored state replayed on boot. + bool boot_replay_done_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 288b1e5c40..9b97995a96 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -154,8 +154,12 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) { // Forward received IR data to API server #if defined(USE_API) && defined(USE_IR_RF) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 11f8e27fc3..2e408b3b01 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -3,17 +3,76 @@ #include "esphome/core/log.h" #include "internal_temperature.h" -#include "Arduino.h" +#include +#include +#include + +// The RP2 variant headers (pulled in transitively by Arduino.h) define +// ADC_RESOLUTION as the pin-level ADC bit count, which would be substituted +// into the constant below. Nothing here uses the Arduino definition, so drop +// it for this file. Not restored with pop_macro: the uses below would then be +// substituted again. +#undef ADC_RESOLUTION namespace esphome::internal_temperature { static const char *const TAG = "internal_temperature.rp2"; +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif +static constexpr float ADC_VREF = 3.3f; +static constexpr float ADC_RESOLUTION = 4096.0f; // 12-bit +// RP2040 datasheet 4.9.5 / RP2350 datasheet 12.4.6: T = 27 - (V - 0.706) / 0.001721 +static constexpr float TEMPERATURE_AT_REFERENCE = 27.0f; +static constexpr float REFERENCE_VOLTAGE = 0.706f; +static constexpr float VOLTS_PER_DEGREE = 0.001721f; +// The sensor is powered down again after each read, so every conversion is the +// first one after enabling. Let the bias circuitry settle first, matching what +// the adc component does for its own temperature readings. +static constexpr uint32_t SETTLE_TIME_US = 1000; + +static float read_internal_temperature() { + // adc_init() resets the ADC block, so this runs at most once for this + // component. The adc component guards its own adc_init() the same way, so a + // redundant reset is still possible when both are used. That is harmless + // because both re-select their input on every read. + static bool adc_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + if (!adc_ready) { + adc_init(); + adc_ready = true; + } + + adc_set_temp_sensor_enabled(true); + busy_wait_us(SETTLE_TIME_US); + adc_select_input(TEMPERATURE_ADC_INPUT); + const uint16_t raw = adc_read(); + adc_set_temp_sensor_enabled(false); + + const float voltage = raw * (ADC_VREF / ADC_RESOLUTION); + return TEMPERATURE_AT_REFERENCE - (voltage - REFERENCE_VOLTAGE) / VOLTS_PER_DEGREE; +} + void InternalTemperatureSensor::update() { float temperature = NAN; bool success = false; - temperature = analogReadTemp(); + temperature = read_internal_temperature(); success = (temperature != 0.0f); if (success && std::isfinite(temperature)) { diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index ae622cda28..f71bec7e5f 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -746,7 +746,14 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) { this->send_cmd_from_array(cmd_frame); } -void LD2420Component::handle_cmd_error(uint8_t error) { ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); } +void LD2420Component::handle_cmd_error(uint16_t error) { + if (error < std::size(ERR_MESSAGE)) { + ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); + } else { + // The error word comes from the device reply frame; unknown codes must not index ERR_MESSAGE + ESP_LOGE(TAG, "Command failed: error 0x%04X", error); + } +} int LD2420Component::get_gate_threshold_(uint8_t gate) { uint8_t error; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index ae44b16065..977ee2eccc 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -108,7 +108,7 @@ class LD2420Component final : public Component, public uart::UARTDevice { float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); - void handle_cmd_error(uint8_t error); + void handle_cmd_error(uint16_t error); void set_operating_mode(const char *state); void auto_calibrate_sensitivity(); void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number); diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index c51af373b3..c56cc48055 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -182,6 +182,9 @@ def get_download_types(storage_json: StorageJSON = None): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [ { "title": "UF2 package (recommended)", diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index b66a904437..acd5a9bdef 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -444,7 +444,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r lv_indev_set_type(this->drv_, LV_INDEV_TYPE_POINTER); lv_indev_set_disp(this->drv_, parent->get_disp()); lv_indev_set_long_press_time(this->drv_, long_press_time); - // long press repeat time TBD + lv_indev_set_long_press_repeat_time(this->drv_, long_press_repeat_time); lv_indev_set_user_data(this->drv_, this); lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) { auto *l = static_cast(lv_indev_get_user_data(d)); diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index 5ac92f2717..54c9819d2b 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -1,3 +1,4 @@ +from esphome.components.const import CONF_LABEL import esphome.config_validation as cv from esphome.const import CONF_TEXT @@ -14,8 +15,6 @@ from ..schemas import TEXT_SCHEMA from ..types import LvText from . import Widget, WidgetType -CONF_LABEL = "label" - class LabelType(WidgetType): def __init__(self): diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 255923f878..092c4977ce 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -166,12 +166,7 @@ MANIFEST_SCHEMA_V2 = cv.Schema( def _compute_local_file_path(config: dict) -> Path: - url = config[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, config[CONF_URL]) def _convert_manifest_v1_to_v2(v1_manifest): @@ -389,11 +384,14 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config external_files.download_content_many( - ((url, path / "manifest.json") for path, url in http_models.items()), + ( + external_files.RemoteFile(url, path / "manifest.json") + for path, url in http_models.items() + ), description="wake word manifest(s)", ) - model_files: list[tuple[str, Path]] = [] + model_files: list[external_files.RemoteFile] = [] errors: list[cv.Invalid] = [] for path, url in http_models.items(): try: @@ -412,7 +410,7 @@ def _download_http_models(config: ConfigType) -> ConfigType: cv.Invalid(f"Manifest file at {url} is missing the 'model' key") ) continue - model_files.append((urljoin(url, model), path / model)) + model_files.append(external_files.RemoteFile(urljoin(url, model), path / model)) if errors: raise cv.MultipleInvalid(errors) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 713969ab88..98ca23b60b 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -63,7 +63,6 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -333,68 +332,6 @@ CONFIG_SCHEMA = cv.All( ) -# Platforms whose MQTT components subscribe to an object_id-derived command topic. -# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus -# text, whose MQTT component subscribes a command topic that cannot be overridden. -_COMMAND_TOPIC_PLATFORMS = frozenset( - { - "alarm_control_panel", - "button", - "climate", - "cover", - "datetime", - "fan", - "light", - "lock", - "number", - "select", - "switch", - "text", - "update", - "valve", - } -) - - -# Platforms whose MQTT components derive extra sub-topics (position/command, -# mode/command, speed/command, ...) from the object_id, each with its own config -# key; custom state and command topics cannot exempt them from conflicting. -_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"}) - - -def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool: - """Check whether more than one entity actually uses an object_id-derived topic. - - An empty topic_prefix disables default topics entirely, custom state and - command topics avoid the default topics, and disabling discovery (globally - or per entity) avoids the discovery config topic. - """ - if config[CONF_TOPIC_PREFIX]: - platform = entities[0].platform - if platform in _SUB_TOPIC_PLATFORMS: - return True - if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1: - return True - if ( - platform in _COMMAND_TOPIC_PLATFORMS - and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1 - ): - return True - if not config[CONF_DISCOVERY]: - return False - discovery_entities = sum( - entity.config.get(CONF_DISCOVERY, True) for entity in entities - ) - return discovery_entities > 1 - - -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "mqtt builds default topics and discovery topics from the entity object_id, " - "which is the name converted to ASCII", - conflict_filter=_topics_conflict, -) - - def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 386fed5412..2d25558254 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -473,6 +473,9 @@ def copy_files() -> None: def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Get the download types for the firmware.""" + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [] UF2_PATH = "zephyr/zephyr.uf2" DFU_PATH = "firmware.zip" diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 0a69160fc1..cc1541ce80 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -3,7 +3,6 @@ from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL -from esphome.core.entity_helpers import validate_no_object_id_conflicts from esphome.cpp_types import EntityBase AUTO_LOAD = ["web_server_base"] @@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema( }, ).extend(cv.COMPONENT_SCHEMA) -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id, " - "which is the name converted to ASCII" -) - async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index fe6c6a9cb5..3e0a905737 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -99,8 +99,12 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { // Forward received RF data to API server #if defined(USE_API) && defined(USE_RADIO_FREQUENCY) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 87e78003ed..60fcd4f8b0 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -156,6 +156,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "UF2 factory format", diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index bd889c2c92..082639374f 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -234,7 +234,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.7.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index 94d6e7cfca..3c6c82b009 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -3,6 +3,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import runtime_image +from esphome.components.const import CONF_SLOT from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata import esphome.config_validation as cv from esphome.const import ( @@ -45,7 +46,6 @@ MAX_IMAGE_DIMENSION = 32767 MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60) MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60) -CONF_SLOT = "slot" CONF_CURRENT_IMAGE = "current_image" CONF_TRANSITION_IMAGE = "transition_image" CONF_ON_IMAGE_DISPLAY = "on_image_display" diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index cd6d858067..dd99fcbc90 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -2,9 +2,7 @@ import hashlib from pathlib import Path import re -import requests - -from esphome import pins +from esphome import external_files, pins import esphome.codegen as cg from esphome.components import light, sensor, uart from esphome.components.const import CONF_SHA256 @@ -28,8 +26,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import CORE, HexInt -from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.core import HexInt +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DOMAIN = "shelly_dimmer" AUTO_LOAD = ["sensor"] @@ -76,46 +75,85 @@ def parse_firmware_version(value): return major, minor -def get_firmware(value): +def _firmware_cache_path(name: str) -> Path: + return external_files.compute_local_file_dir(DOMAIN) / f"{name}_fw_stm.bin" + + +def _firmware_path(url: str, sha: str | None) -> Path: + """Cache path for a firmware blob: sha-keyed when verifiable, else + URL-keyed. Shared by the validator and the prefetch hook.""" + return _firmware_cache_path( + sha.lower() if sha else external_files.url_cache_key(url) + ) + + +def get_firmware(value: ConfigType) -> list[HexInt] | None: if not value[CONF_UPDATE]: return None - def dl(url): - try: - ensure_happy_eyeballs() - req = requests.get(url, timeout=30) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e - - h = hashlib.new("sha256") - h.update(req.content) - return req.content, h.hexdigest() - url = value[CONF_URL] - if CONF_SHA256 in value: # we have a hash, enable caching - path = Path(CORE.data_dir) / DOMAIN / (value[CONF_SHA256] + "_fw_stm.bin") - - if not path.is_file(): - firmware_data, dl_hash = dl(url) - - if dl_hash != value[CONF_SHA256]: - raise cv.Invalid( - f"Hash mismatch for {url}: {dl_hash} != {value[CONF_SHA256]}" - ) - - path.parent.mkdir(exist_ok=True, parents=True) - path.write_bytes(firmware_data) - - else: + if expected := value.get(CONF_SHA256): + expected = expected.lower() + path = _firmware_path(url, expected) + if path.is_file(): firmware_data = path.read_bytes() - else: # no caching, download every time - firmware_data, dl_hash = dl(url) + if hashlib.sha256(firmware_data).hexdigest() == expected: + return [HexInt(x) for x in firmware_data] + # A corrupted or foreign cache entry must never be trusted just + # because the file exists; discard it and download again. + path.unlink() + firmware_data = external_files.download_content(url, path) + if (actual := hashlib.sha256(firmware_data).hexdigest()) != expected: + path.unlink(missing_ok=True) + raise cv.Invalid(f"Hash mismatch for {url}: {actual} != {expected}") + else: + # No hash to verify the bytes, so an unrevalidated copy is an + # error rather than a silent fallback. + firmware_data = external_files.download_content( + url, + _firmware_path(url, None), + allow_stale=False, + ) return [HexInt(x) for x in firmware_data] +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if not isinstance(firmware, dict): + return None + try: + # cv.boolean, not truthiness: `update: "false"` is a valid False. + if not cv.boolean(firmware.get(CONF_UPDATE, False)): + return None + except cv.Invalid: + return None + url = firmware.get(CONF_URL) + sha = firmware.get(CONF_SHA256) + if url is None and (known := KNOWN_FIRMWARE.get(str(firmware.get(CONF_VERSION)))): + url, sha = known + if not isinstance(url, str): + return None + if sha is not None: + # Reject anything but a well-formed hash; a raw string would + # otherwise become a path component before validation runs. + try: + sha = validate_sha256(sha) + except (cv.Invalid, ValueError, TypeError): + return None + path = _firmware_path(url, sha) + if sha is not None and path.is_file(): + # Content-addressed and already on disk; get_firmware verifies it + # by hash, so there is nothing to revalidate. + return None + # No hash means no stale copies, matching the validator's policy. + return RemoteFile(url, path, allow_stale=sha is not None) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def validate_firmware(value): config = value.copy() if CONF_URL not in config: diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index ffe11cf229..af134e6ed4 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -20,14 +20,18 @@ void TemplateText::setup() { // Need std::string for pref_->setup() to fill from flash std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; - uint32_t extra = 0; - extra += this->traits.get_min_length() << 2; - extra += this->traits.get_max_length() << 4; - extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - // TextSaver::setup() picks the key for the platform and migrates old data once - uint32_t key = this->preference_key_base_() + extra; - uint32_t old_key = this->old_preference_key_base_() + extra; - this->pref_->setup(key, old_key, value); + // For future hash migration: use migrate_entity_preference_() with: + // old_key = get_preference_hash() + extra + // new_key = get_preference_hash_v2() + extra + // See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash(); +#pragma GCC diagnostic pop + key += this->traits.get_min_length() << 2; + key += this->traits.get_max_length() << 4; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + this->pref_->setup(key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index beeea4396a..229a61d9b8 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -14,9 +14,7 @@ class TemplateTextSaverBase { public: virtual bool save(const std::string &value) { return true; } - /// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once. - /// See: https://github.com/esphome/backlog/issues/85 - virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {} + virtual void setup(uint32_t id, std::string &value) {} protected: ESPPreferenceObject pref_; @@ -47,16 +45,11 @@ template class TextSaver : public TemplateTextSaverBase { // Make the preference object. Fill the provided location with the saved data // If it is available, else leave it alone - void setup(uint32_t id, uint32_t old_id, std::string &value) override { - char temp[SZ + 1]; -#ifdef USE_PREFERENCE_KEY_LOOKUP + void setup(uint32_t id, std::string &value) override { this->pref_ = global_preferences->make_preference(id); - bool hasdata = migrate_preference(this->pref_, reinterpret_cast(temp), SZ + 1, old_id, id); -#else - // Slot-based backends keep the old key; it is only a validity tag on a positional slot - this->pref_ = global_preferences->make_preference(old_id); + + char temp[SZ + 1]; bool hasdata = this->pref_.load(&temp); -#endif if (hasdata) { size_t len = static_cast(temp[0]); diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 3c7ecd9a83..c56f43f75a 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -292,8 +292,8 @@ bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool // Data bits line_coding[6] = channel->get_data_bits(); - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], - line_coding[6]); + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], + line_coding[5], line_coding[6]); std::vector lc_vec(line_coding, line_coding + 7); this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 9812714ec0..95763e2daf 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -249,7 +249,7 @@ void WebServerOTAComponent::setup() { return; } - // AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed + // The handler lives for the life of the process; WebServerBase never destroys its server base->add_handler(new OTARequestHandler(this)); // NOLINT } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c647a13b50..94579de70f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -112,9 +112,18 @@ class AuthMiddlewareHandler : public MiddlewareHandler { class WebServerBase final { public: + // The AsyncWebServer is created once and intentionally never deleted: on Arduino + // platforms ESPAsyncWebServer owns its registered handlers, so destroying it would + // also destroy live components (e.g. the captive portal) out from under us. + // init()/deinit() refcount users and start/stop the listener; handlers are + // registered once at creation and survive listener restarts. void init() { - if (this->initialized_) { - this->initialized_++; + this->initialized_++; + if (this->server_ != nullptr) { + if (this->initialized_ == 1) { + // Restart the listener after a previous deinit() + this->server_->begin(); + } return; } this->server_ = new AsyncWebServer(this->port_); @@ -126,14 +135,13 @@ class WebServerBase final { for (auto *handler : this->handlers_) this->server_->addHandler(handler); - - this->initialized_++; } void deinit() { + if (this->initialized_ == 0) + return; // unbalanced deinit() this->initialized_--; if (this->initialized_ == 0) { - delete this->server_; - this->server_ = nullptr; + this->server_->end(); } } AsyncWebServer *get_server() const { return this->server_; } diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 719a276bf9..acaa94b13c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -136,10 +136,21 @@ bool WiFiComponent::wifi_apply_power_save_() { https://github.com/d-a-v/Arduino/blob/0e7d21e17144cfc5f53c016191daca8723e89ee8/libraries/ESP8266WiFi/src/ESP8266WiFiSTA.cpp#L251 */ #undef netif_set_addr // need to call lwIP-v1.4 netif_set_addr() +#undef netif_set_down // need to call lwIP-v1.4 netif_set_down() extern "C" { struct netif *eagle_lwip_getif(int netif_index); void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t *netmask, const ip4_addr_t *gw); +void netif_set_down(struct netif *netif); }; + +// The SDK can free its WiFi connection node before taking the STA netif down, letting lwIP +// timers (e.g. IGMP reports armed by mDNS) transmit into the dead driver and crash in +// cnx_node_search; taking the netif down first makes the glue drop such frames (#18308). +static void sta_netif_down() { + struct netif *iface = eagle_lwip_getif(STATION_IF); + if (iface != nullptr) + netif_set_down(iface); +} #endif bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { @@ -523,6 +534,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); } global_wifi_component->error_from_callback_ = true; +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif #ifdef USE_WIFI_CONNECT_STATE_LISTENERS global_wifi_component->pending_.disconnect = true; #endif @@ -536,6 +550,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif wifi_station_disconnect(); global_wifi_component->error_from_callback_ = true; } @@ -719,8 +736,12 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { bool WiFiComponent::wifi_disconnect_() { bool ret = true; // Only call disconnect if interface is up - if (wifi_get_opmode() & WIFI_STA) + if (wifi_get_opmode() & WIFI_STA) { +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif ret = wifi_station_disconnect(); + } station_config conf{}; memset(&conf, 0, sizeof(conf)); ETS_UART_INTR_DISABLE(); diff --git a/esphome/config.py b/esphome/config.py index b747c69b3a..987bb9c96a 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1,14 +1,15 @@ from __future__ import annotations import abc -from contextlib import contextmanager +from collections.abc import Iterator +from contextlib import contextmanager, suppress import contextvars import copy import functools import heapq import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -40,6 +41,9 @@ from esphome.util import OrderedDict, safe_print from esphome.voluptuous_schema import ExtraKeysInvalid from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret +if TYPE_CHECKING: + from esphome.external_files import RemoteFile + _LOGGER = logging.getLogger(__name__) @@ -717,6 +721,125 @@ class AutoLoadValidationStep(ConfigValidationStep): ) +# Backstop against a runaway PREFETCH_FILES generator; no real component +# needs anywhere near this many stages (font, the deepest, uses two). +_MAX_PREFETCH_STAGES = 10 + + +class PrefetchRemoteFilesValidationStep(ConfigValidationStep): + """Batch-download remote files referenced by the raw config. + + Each round, the batches yielded by every ``PREFETCH_FILES`` hook (see + ``ComponentManifest.prefetch_files``) download in one parallel pass, so + per-entry schema validators find a warm cache. Must run between + AutoLoadValidationStep (-1.0) and MetadataValidationStep (-2.0): + metadata steps push priority-0 schema steps that pop immediately, so + this is the last point where every raw entry list is intact. Best + effort: failures are logged and memoized per run; the per-entry + validators stay authoritative. + """ + + priority = -1.5 + + def run(self, result: Config) -> None: + active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + + def warn_hook_failed(name: str, err: Exception) -> None: + # A broken hook must not fail validation; it only loses the + # batching speedup. + _LOGGER.warning("Remote file prefetch for %s failed: %s", name, err) + _LOGGER.debug("Prefetch hook traceback", exc_info=err) + + def start_hook( + name: str, manifest: ComponentManifest, entries: list[ConfigType] + ) -> None: + if (hook := manifest.prefetch_files) is None: + return + try: + active.append((name, iter(hook(entries)))) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + + for domain, conf in result.items(): + if not isinstance(domain, str) or domain.startswith("."): + continue + if (component := get_component(domain)) is None: + continue + if component.prefetch_files is None and not component.is_platform_component: + continue + if conf is None or isinstance(conf, core.AutoLoad): + continue + entries = [ + entry + for entry in (conf if isinstance(conf, list) else [conf]) + if isinstance(entry, dict) + ] + if not entries: + continue + # A domain-level hook on a platform component receives every + # entry; overlap with per-platform hooks dedupes by path. + start_hook(domain, component, entries) + if not component.is_platform_component: + continue + by_platform: dict[str, list[ConfigType]] = {} + for entry in entries: + if isinstance(p_name := entry.get(CONF_PLATFORM), str): + by_platform.setdefault(p_name, []).append(entry) + for p_name, p_entries in by_platform.items(): + if (platform := get_platform(domain, p_name)) is not None: + start_hook(f"{domain}.{p_name}", platform, p_entries) + + # One stage per round; later stages can read what earlier ones + # fetched. + for _ in range(_MAX_PREFETCH_STAGES): + if not active: + break + items: list[RemoteFile] = [] + still_active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + for name, generator in active: + try: + batch = list(next(generator)) + except StopIteration: + continue + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + continue + items.extend(batch) + still_active.append((name, generator)) + active = still_active + self._download(items) + for name, generator in active: + # A tripped backstop means a broken hook. + _LOGGER.warning( + "Remote file prefetch for %s stopped after %d stages", + name, + _MAX_PREFETCH_STAGES, + ) + if (close := getattr(generator, "close", None)) is not None: + # close() runs hook code too; it must not fail validation. + with suppress(Exception): + close() + + @staticmethod + def _download(items: list[RemoteFile]) -> None: + if not items: + return + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when a config actually references remote files. + from esphome import external_files + + try: + external_files.download_content_many(items, description="remote file(s)") + except cv.Invalid as err: + # INFO: the trace if an extractor's cache path ever drifts from + # its validator's, hiding the memoized failure replay. + _LOGGER.info("Remote file prefetch download failed: %s", err) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # The batch downloader itself broke; make it visible. + _LOGGER.warning("Remote file prefetch failed: %s", err) + _LOGGER.debug("Prefetch download traceback", exc_info=err) + + class MetadataValidationStep(ConfigValidationStep): """Validate component metadata @@ -1259,6 +1382,7 @@ def validate_config( for domain, conf in config.items(): result.add_validation_step(LoadValidationStep(domain, conf)) + result.add_validation_step(PrefetchRemoteFilesValidationStep()) result.add_validation_step(IDPassValidationStep()) result.add_validation_step(CoreFinalValidateStep()) result.add_validation_step(PinUseValidationCheck()) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0eebf12e66..f455c7b8bf 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -99,6 +99,10 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) + +# Deprecated re-export for external components; remove before 2027.2.0 +# pylint: disable-next=unused-import +from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base diff --git a/esphome/const.py b/esphome/const.py index a3e9f47909..0dd948544f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0-dev" +__version__ = "2026.9.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( diff --git a/esphome/core/application.h b/esphome/core/application.h index a18a6b31c8..a12cdc4ac8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -120,8 +120,8 @@ class Application { // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ void register_##singular(type *obj) { this->plural##_.push_back(obj); } \ - void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \ - obj->configure_entity_(name, entity_key, entity_fields); \ + void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \ + obj->configure_entity_(name, object_id_hash, entity_fields); \ this->plural##_.push_back(obj); \ } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ @@ -329,7 +329,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \ + if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ (include_internal || !obj->is_internal())) \ return obj; \ } \ @@ -340,7 +340,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \ + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \ return obj; \ } \ return nullptr; \ diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 328de05302..fc6ac503b5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,7 +8,7 @@ namespace esphome { static const char *const TAG = "entity_base"; -void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) { +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32 } } this->flags_.has_own_name = false; - // Dynamic name - must calculate key at runtime - this->calc_entity_key_(); + // Dynamic name - must calculate hash at runtime + this->calc_object_id_(); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed key if provided - if (entity_key != 0) { - this->entity_key_ = entity_key; + // Static name - use pre-computed hash if provided + if (object_id_hash != 0) { + this->object_id_hash_ = object_id_hash; } else { - this->calc_entity_key_(); + this->calc_object_id_(); } } // Unpack entity string table indices and flags from entity_fields. @@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Calculate the entity key directly from the raw name (no transformations) -void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); } - -// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility. -// Named entities historically used the hash pre-computed by Python code generation, which -// sanitized per UTF-8 code point; entities without their own name computed the hash at -// runtime per byte. See https://github.com/esphome/backlog/issues/85 -uint32_t EntityBase::calc_old_object_id_hash_() const { - return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name); +// Calculate Object ID Hash directly from name using snake_case + sanitize +void EntityBase::calc_object_id_() { + this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { @@ -173,22 +167,16 @@ StringRef EntityBase::get_object_id_to(std::span buf) c } ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { - // The old key hashed the sanitized object_id, so multiple entity names could collide on - // one key and overwrite each other's stored preferences; the new key hashes the raw name. - // See: https://github.com/esphome/backlog/issues/85 - uint32_t old_key = this->old_preference_key_base_() ^ version; -#ifdef USE_PREFERENCE_KEY_LOOKUP - uint32_t new_key = this->preference_key_base_() ^ version; - auto pref = global_preferences->make_preference(size, new_key); - // All in-tree entity preferences fit the stack buffer, so migration never hits the heap - SmallBufferWithHeapFallback<64> buffer(size); - migrate_preference(pref, buffer.get(), size, old_key, new_key); - return pref; -#else - // Slot-based backends keep the old key: it is only a validity tag on a positional slot, - // so collisions cannot corrupt data there and keeping it preserves stored state. - return global_preferences->make_preference(size, old_key); -#endif + // The key hashes the sanitized object_id, so multiple entity names can collide on one + // key and overwrite each other's stored preferences ("Living Room" and "living_room", + // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name + // fix this, but they change the entity key API clients track, which the Home Assistant + // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash() ^ version; +#pragma GCC diagnostic pop + return global_preferences->make_preference(size, key); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 7f8e5f2630..5f2e173d8d 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,17 +73,8 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the unique key of this Entity: FNV-1 hash of the raw entity name. - // This is the key sent to API clients and used to route entity state. - uint32_t get_entity_key() const { return this->entity_key_; } - - /// Returns the LEGACY object_id hash, unchanged from previous releases, so existing - /// callers keep getting stable values (for example preference keys). This is no longer - /// the key sent to API clients; that is get_entity_key(). - ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or " - "make_entity_preference() for preference storage. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); } + // Get the unique Object ID of this Entity + uint32_t get_object_id_hash() const { return this->object_id_hash_; } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) @@ -190,23 +181,39 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } - /// Get this entity's device id, or 0 when devices are not compiled in (main device). - uint32_t get_device_id_or_zero() const { -#ifdef USE_DEVICES - return this->get_device_id(); -#else - return 0; -#endif - } - - /// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id. - /// Intentionally keeps the old algorithm so external callers that store preferences under - /// this key keep stable keys; make_entity_preference() migrates to the new raw-name key, - /// this method never will. + /** + * @brief Get a unique hash for storing preferences/settings for this entity. + * + * This method returns a hash that uniquely identifies the entity for the purpose of + * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), + * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness + * across multiple devices that may have entities with the same object_id. + * + * Use this method when storing or retrieving preferences/settings that should be unique + * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies + * the entity regardless of the device it belongs to. + * + * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged + * from previous versions, so existing single-device configurations will continue to work. + * + * @return uint32_t The unique hash for preferences, including device_id if available. + * @deprecated Use make_entity_preference() instead, or preferences won't be migrated. + * See https://github.com/esphome/backlog/issues/85 + */ ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_preference_hash() { return this->old_preference_key_base_(); } + "2026.7.0") + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } /// Create a preference object for storing this entity's state/settings. /// @tparam T The type of data to store (must be trivially copyable) @@ -223,9 +230,9 @@ class EntityBase { // before push_back, so codegen can emit a single combined call per entity. friend class Application; - /// Combined entity setup from codegen: set name, entity key, entity string indices, and flags. + /// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags. /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. - void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields); + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); #ifdef USE_DEVICES // Codegen-only setter — only accessible from setup() via friend declaration. @@ -233,24 +240,13 @@ class EntityBase { #endif /// Non-template helper for make_entity_preference() to avoid code bloat. - /// Migrates preferences from the old sanitized-object_id key to the raw-name key - /// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85 + /// When the preference hash algorithm changes, migration logic goes here. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); - void calc_entity_key_(); - - /// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys. - uint32_t calc_old_object_id_hash_() const; - - /// Preference key base for this entity: raw-name entity key XOR device_id. - uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); } - - /// Legacy preference key base: sanitized-object_id hash XOR device_id. - /// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash. - uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); } + void calc_object_id_(); StringRef name_; - uint32_t entity_key_{}; + uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5060e32a2d..54e2551cb4 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,86 +25,25 @@ from esphome.core.config import ( from esphome.cpp_generator import MockObj, RawStatement, add, get_variable from esphome.cpp_types import App import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash_name, sanitize, snake_case +from esphome.helpers import ( + cpp_string_escape, + fnv1_hash, + fnv1_hash_object_id, + sanitize, + snake_case, +) from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) DOMAIN = "entity_string_pool" -_OBJECT_ID_DOMAIN = "entity_object_ids" - - -@dataclass -class ObjectIdEntity: - """An entity tracked by the sanitized object_id its name resolves to.""" - - name: str - platform: str - config: ConfigType - - -def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]: - """(device_id, platform, sanitized object_id) -> entities resolving to it.""" - return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {}) - - -def validate_no_object_id_conflicts( - reason: str, - conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None, -) -> Callable[[ConfigType], ConfigType]: - """Create a final-validate step that rejects entities with colliding object_ids. - - Entity keys are hashed from the raw name, so names that only differ in characters - lost during sanitizing (for example two UTF-8 names) validate fine in general. - Components that still address entities by the sanitized object_id string must - reject those configs until they are migrated to raw names. - - Args: - reason: One sentence stating what the component builds from the object_id, - e.g. "mqtt builds default topics from the entity object_id" - conflict_filter: Optional predicate receiving the colliding entities and the - component config; return False when the component is not affected - - Returns: - A validator function for use as (or within) FINAL_VALIDATE_SCHEMA - """ - - def validator(config: ConfigType) -> ConfigType: - # Skip in testing_mode, which is used for grouped component testing - if CORE.testing_mode: - return config - conflicts = { - key: entities - for key, entities in _get_object_id_registry().items() - if len(entities) > 1 - and (conflict_filter is None or conflict_filter(entities, config)) - } - if not conflicts: - return config - lines = [f"{reason}, so these entities would conflict:"] - lines.extend( - f" - {platform} entities " - + ", ".join(f"'{e.name}'" for e in entities) - + (f" on device '{device_id}'" if device_id else "") - + f" share the object_id '{object_id}'" - for (device_id, platform, object_id), entities in conflicts.items() - ) - lines.append( - "To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') " - "to distinguish the names" - ) - raise cv.Invalid("\n".join(lines)) - - return validator - - # Private config keys for storing registered string indices _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" -_KEY_ENTITY_KEY = "_entity_key" +_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" # Bit layout for entity_fields in configure_entity_(). # Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h @@ -367,7 +306,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] - entity_key = config[_KEY_ENTITY_KEY] + object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) @@ -387,30 +326,57 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: register_method = config.get(_KEY_REGISTER_METHOD) if register_method is not None: expr = getattr(App, f"register_{register_method}")( - var, entity_name, entity_key, packed + var, entity_name, object_id_hash, packed ) else: - expr = var.configure_entity_(entity_name, entity_key, packed) + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: add(expr) -def get_base_entity_name( +def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None ) -> str: - """Return the base name whose hash becomes this entity's key on the device. + """Calculate the base object ID for an entity that will be set via set_object_id(). - Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp): - entity name, then sub-device name, then friendly name, then the device name. + This function calculates what object_id_c_str_ should be set to in C++. - This is a config-time approximation for duplicate checking: when - name_add_mac_suffix is enabled the device appends the MAC suffix at runtime, - which is unknown here and identical for every entity on the device, so - ignoring it cannot change whether two entities collide with each other. + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() """ - return name or device_name or friendly_name or CORE.name + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif device_name: + # Entity has empty name and is on a sub-device + # C++ EntityBase::set_name() uses device->get_name() when device is set + base_str = device_name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) def setup_entity(var_or_platform, config=None, platform=None): @@ -469,15 +435,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device_(device)) - # Pre-compute entity name and entity key for configure_entity_() + # Pre-compute entity name and object_id hash for configure_entity_() # which is emitted later by finalize_entity_strings(). - # For named entities: pre-compute the key from the raw entity name - # For empty-name entities: pass 0, C++ calculates the key at runtime from - # device name, friendly_name, or app name + # For named entities: pre-compute hash from entity name + # For empty-name entities: pass 0, C++ calculates hash at runtime from + # device name, friendly_name, or app name (bug-for-bug compatibility) entity_name = config[CONF_NAME] - entity_key = fnv1_hash_name(entity_name) if entity_name else 0 + object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name - config[_KEY_ENTITY_KEY] = entity_key + config[_KEY_OBJECT_ID_HASH] = object_id_hash # Store flags for packing into configure_entity_() config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: @@ -590,13 +556,16 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Use the device ID string directly for uniqueness device_id = device_id_obj.id - # Hash the same raw name the device hashes into the entity key at runtime. - # This handles empty names correctly by using device/friendly names. - base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name) - name_hash = fnv1_hash_name(base_name) + # Calculate what object_id will actually be used + # This handles empty names correctly by using device/friendly names + name_key = get_base_entity_object_id( + entity_name, CORE.friendly_name, device_name + ) - # Check for duplicates: two entities on the same device and platform must not - # share an entity key, since the key is what routes state to API clients + # Check for duplicates by the FNV-1 hash of the object_id, which is the entity + # key that routes state to API clients. This rejects names that sanitize to the + # same object_id, and also two different object_ids whose 32-bit hashes collide. + name_hash = fnv1_hash(name_key) unique_key = (device_id, platform, name_hash) if unique_key in CORE.unique_ids: # Get the existing entity metadata @@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" - # Different names can only clash here through a genuine hash collision + # Distinguish names that sanitize to the same object_id from a genuine + # 32-bit hash collision between two different object_ids collision_msg = "" if entity_name != existing_name: - collision_msg = ( - f"\n The names '{entity_name}' and '{existing_name}' produce the" - f"\n same entity key hash ({name_hash:#010x})." - "\n To fix: Rename one of the entities" + existing_object_id = get_base_entity_object_id( + existing_name, CORE.friendly_name, existing_device or None ) + if existing_object_id == name_key: + collision_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + else: + collision_msg = ( + f"\n The object_ids '{name_key}' and '{existing_object_id}'" + f"\n produce the same entity key hash ({name_hash:#010x})." + "\n To fix: Rename one of the entities" + ) # Skip duplicate entity name validation when testing_mode is enabled # This flag is used for grouped component testing @@ -640,19 +621,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy f"{collision_msg}" ) - # Components that still address entities by the sanitized object_id reject - # colliding names in final validation via validate_no_object_id_conflicts(), - # so track every entity by the object_id its name resolves to. Scoped per - # device and platform to match the strictness configs had before entity keys - # moved to raw names: same-named entities on different sub-devices were - # already accepted then, internal entities were already skipped (above), and - # overlaps between platforms that share an MQTT component type (sensor and - # text_sensor both publish under "sensor") were already possible. - object_id = sanitize(snake_case(base_name)) - _get_object_id_registry().setdefault( - (device_id, platform, object_id), [] - ).append(ObjectIdEntity(base_name, platform, config)) - # Store metadata about this entity entity_metadata: EntityMetadata = { "name": entity_name, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d883ce146e..994fa2c26a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -809,19 +809,6 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; -/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *), -/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80. -/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8 -/// encoded bytes of the name. Used to compute entity keys from raw names. -inline uint32_t fnv1_hash_bytes(const char *str, size_t len) { - uint32_t hash = FNV1_OFFSET_BASIS; - for (size_t i = 0; i < len; i++) { - hash *= FNV1_PRIME; - hash ^= static_cast(str[i]); - } - return hash; -} - /// Extend a FNV-1 hash with an integer (hashes each byte). template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { using UnsignedT = std::make_unsigned_t; @@ -1026,20 +1013,12 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s // str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. -/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing -/// devices already have stored; see https://github.com/esphome/backlog/issues/85. -/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character -/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py, -/// which produced the hash for named entities. The per-byte form (default) matches the old -/// runtime hash for entities without their own name. Do not change either behavior. -/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a -/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong; -/// such names skip migration once and fall back to their defaults. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) { +/// This computes object_id hashes directly from names without creating an intermediate buffer. +/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. +/// If you modify this function, update the Python version and tests in both places. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { - if (per_code_point && (static_cast(str[i]) & 0xC0) == 0x80) - continue; // UTF-8 continuation byte, already counted via its lead byte hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 0622376fca..5df0804bdd 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -24,9 +24,10 @@ #endif // Key-lookup preference backends find stored data by key; their platforms add the -// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key -// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for -// every make_preference() call and use the key only as a validity tag on that slot; +// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads +// of stored data by key (the primitive preference key migrations need). Slot-based +// backends (ESP8266, RP2040) instead allocate a storage slot for every +// make_preference() call and use the key only as a validity tag on that slot; // migration is not possible there, and key collisions cannot corrupt data. namespace esphome { @@ -104,10 +105,9 @@ concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool }; // Key-lookup platforms additionally provide load_from_key(), a one-shot read -// of a stored preference by key that migrate_preference() relies on; see the -// key-lookup note at the top of this file. Not part of PreferencesContract, -// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP -// is set. +// of a stored preference by key; see the key-lookup note at the top of this +// file. Not part of PreferencesContract, so it is asserted in preferences.h +// only where USE_PREFERENCE_KEY_LOOKUP is set. template concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { { prefs.load_from_key(type, data, len) } -> std::same_as; diff --git a/esphome/core/preferences.cpp b/esphome/core/preferences.cpp deleted file mode 100644 index 8508647255..0000000000 --- a/esphome/core/preferences.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "esphome/core/preferences.h" -#include "esphome/core/log.h" -#include - -namespace esphome { - -#ifdef USE_PREFERENCE_KEY_LOOKUP -static const char *const TAG = "preferences"; - -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key) { - if (new_pref.load(scratch, size)) - return true; // Current data present - never overwrite newer data with the old copy - // One-shot read by key: no backend is allocated for the old key, so boots with - // nothing to migrate (for example fresh installs) cost no heap - if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size)) - return false; // No data stored under the old key, nothing to migrate - if (!new_pref.save(scratch, size)) { - ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key); - } - return true; -} -#endif // USE_PREFERENCE_KEY_LOOKUP - -} // namespace esphome diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index cfeddebda7..ed23dfae56 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -56,17 +56,5 @@ namespace esphome { static_assert(PreferencesKeyLookupContract, "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " "load_from_key() (esphome/core/preference_backend.h)"); - -/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys -/// differ and new_pref has no data yet. scratch must hold at least size bytes. -/// Returns true when scratch holds the entity's current data (loaded or just migrated). -/// The old entry is intentionally left in place so a firmware downgrade still finds its data. -/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get -/// valid data for this boot, callers that reload from the preference fall back to their -/// defaults, and the migration simply runs again on the next boot. -/// Only available on key-lookup preference backends; slot-based backends keep their old -/// keys instead. See: https://github.com/esphome/backlog/issues/85 -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key); } // namespace esphome #endif // USE_PREFERENCE_KEY_LOOKUP diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e1688f4170..bb6452acf2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -109,6 +109,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache = _cache().env if version not in env_cache: env_cache[version] = os.environ.copy() + # Do not leak PYTHONPATH into child env + env_cache[version].pop("PYTHONPATH", None) # Use provided IDF framework if available if "IDF_PATH" not in os.environ: diff --git a/esphome/espota2.py b/esphome/espota2.py index fa15c1dda2..61e897f601 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +import contextlib import gzip import hashlib import io @@ -8,7 +9,6 @@ import logging from pathlib import Path import secrets import socket -import sys import time from typing import Any @@ -76,6 +76,14 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 +# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time +# to clean up a half-open connection (its handshake watchdog runs at 20s) before it +# accepts a new one, so wait between attempts instead of failing the upload outright. +# Every resolved address is tried once, and this many extra attempts are shared +# across the addresses on top of that. +EXTRA_UPLOAD_ATTEMPTS = 2 +UPLOAD_RETRY_DELAY = 5.0 + _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) @@ -171,6 +179,23 @@ class OTAError(EsphomeError): pass +class OTANetworkError(OTAError): + """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" + + +def _committed_error(err: OTANetworkError) -> OTAError: + """Wrap a network failure that happened once the device had the full image. + + Past that point the device commits and reboots on its own, so the failure + must not be retried; a re-upload could flash a device that already updated. + """ + return OTAError( + f"{err} (the device may have already committed the update and " + f"be rebooting; check whether it comes back with the new " + f"firmware before uploading again)" + ) + + def recv_decode( sock: socket.socket, amount: int, decode: bool = True ) -> bytes | list[int]: @@ -209,19 +234,22 @@ def receive_exactly( try: data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg} response: {err}") from err + raise OTANetworkError(f"receiving {msg} response: {err}") from err try: check_error(data, expect) except OTAError as err: sock.close() - raise OTAError(f"receiving {msg}: {err}") from err + # type(err) preserves OTANetworkError vs OTAError so callers can tell + # retryable network failures from device-reported errors; subclasses + # must accept a single message argument + raise type(err)(f"receiving {msg}: {err}") from err while len(data) < amount: try: data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg}: {err}") from err + raise OTANetworkError(f"receiving {msg}: {err}") from err return data @@ -237,7 +265,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None # accept-any-response reads (e.g. feature negotiation, auth nonces) would be # silently passed through and surface later as cryptic decode/timeout failures. if not data: - raise OTAError( + raise OTANetworkError( "Device closed connection without responding. " "This may indicate the device ran out of memory, " "a network issue, or the connection was interrupted." @@ -274,7 +302,7 @@ def send_check( sock.sendall(data) except OSError as err: - raise OTAError(f"sending {msg}: {err}") from err + raise OTANetworkError(f"sending {msg}: {err}") from err def perform_ota( @@ -306,7 +334,7 @@ def perform_ota( send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.debug("Device support OTA version: %s", version) + _LOGGER.info("Connection established; device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -417,6 +445,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) + _LOGGER.info("Handshake complete") + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) @@ -449,21 +479,43 @@ def perform_ota( offset = 0 progress = ProgressBar("Uploading") - while True: - chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] - if not chunk: - break - offset += len(chunk) + try: + while True: + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] + if not chunk: + break + offset += len(chunk) + + try: + sock.sendall(chunk) + except OSError as err: + # A send failure can hide an error byte the device reported + # just before dropping the connection; surface that as the + # real, non-retryable cause when it is available + try: + sock.settimeout(1.0) + check_error(recv_decode(sock, 1), None) + except (OSError, OTANetworkError) as probe_err: + _LOGGER.debug( + "No device error behind the send failure: %s", probe_err + ) + raise OTANetworkError(f"sending data: {err}") from err - try: - sock.sendall(chunk) if version >= OTA_VERSION_2_0: - receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) - except OSError as err: - sys.stderr.write("\n") - raise OTAError(f"sending data: {err}") from err + try: + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + except OTANetworkError as err: + if offset < upload_size: + raise + # The device already had the complete image when this ack + # was lost, so it may be committing; do not retry + raise _committed_error(err) from err - progress.update(offset / upload_size) + progress.update(offset / upload_size) + except OTAError: + # Terminate the progress bar line before the error is logged + progress.done() + raise progress.done() # Enable nodelay for last checks @@ -472,11 +524,25 @@ def perform_ota( _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) - send_check(sock, RESPONSE_OK, "end acknowledgement") + # Once the device has the complete image it commits the update and + # reboots on its own; the exact commit point is not observable from + # here, so treat everything past the data phase as non-retryable. A + # re-upload could flash a device that already updated successfully. + try: + receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) + receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) + except OTANetworkError as err: + raise _committed_error(err) from err - _LOGGER.info("OTA successful") + try: + send_check(sock, RESPONSE_OK, "end acknowledgement") + except OTANetworkError as err: + # The device treats a missing end acknowledgement as non-fatal and is + # already rebooting into the new firmware, so the update succeeded + _LOGGER.warning("Failed sending end acknowledgement: %s", err) + _LOGGER.info("OTA successful (end acknowledgement not delivered)") + else: + _LOGGER.info("OTA successful") # Do not connect logs until it is fully on time.sleep(1) @@ -510,8 +576,33 @@ def run_ota_impl_( ) raise OTAError(err) from err - for r in res: - af, socktype, _, _, sa = r + if not res: + _LOGGER.error("No addresses to connect to for %s", remote_host) + return 1, None + + # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries + # are shared across the addresses, cycling through them. Wait before an + # attempt when the previous one actually reached the device, or when + # revisiting an address, so a flaky link can recover and the device can + # clean up a half-open connection (its handshake watchdog runs at 20s); + # moving on to the next address family stays immediate. Known limitation: + # a silent mid-transfer drop with no reset can wedge the device until its + # 90s data timeout, which outlasts this budget; the retries target the + # common failures where the device resets or closes the link promptly. + total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS + last_error = "" + reached_device = False + for attempt in range(total_attempts): + af, socktype, _, _, sa = res[attempt % len(res)] + if reached_device or attempt >= len(res): + _LOGGER.info( + "Retrying in %.0f seconds (attempt %d of %d)...", + UPLOAD_RETRY_DELAY, + attempt + 1, + total_attempts, + ) + time.sleep(UPLOAD_RETRY_DELAY) + reached_device = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -519,23 +610,30 @@ def run_ota_impl_( sock.connect(sa) except OSError as err: sock.close() - _LOGGER.error("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + _LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + last_error = f"connecting to {sa[0]} failed: {err}" continue _LOGGER.info("Connected to %s", sa[0]) - with Path(filename).open("rb") as file_handle: + reached_device = True + with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) + except OTANetworkError as err: + # Transient network failure; retry + last_error = str(err) + _LOGGER.warning("%s", last_error) + continue except OTAError as err: + # Device-reported error (wrong password, wrong flash size, ...); + # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) return 1, None - finally: - sock.close() # Successfully uploaded to sa[0] return 0, sa[0] - _LOGGER.error("Connection failed.") + _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) return 1, None diff --git a/esphome/external_files.py b/esphome/external_files.py index 160a2b6c29..f30d429425 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,16 +1,16 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from concurrent.futures import ThreadPoolExecutor import contextlib +from dataclasses import dataclass, field from datetime import UTC, datetime +import hashlib import logging import os from pathlib import Path import time -import requests - import esphome.config_validation as cv from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds @@ -21,8 +21,54 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] +DOMAIN = "external_files" + NETWORK_TIMEOUT = 30 + +@dataclass(frozen=True, slots=True) +class RemoteFile: + """A remote file to prefetch, yielded in stages by ``PREFETCH_FILES`` + hooks. A dataclass rather than a tuple so fields can be added later.""" + + url: str + path: Path + # False when nothing downstream can verify the bytes; a copy that + # cannot be revalidated is then an error, not a silent fallback. + allow_stale: bool = True + + +@dataclass(frozen=True, slots=True) +class FailedDownload: + """What went wrong for a cache path this run, kept for fast replay.""" + + url: str + message: str + cause: BaseException + + +@dataclass +class ExternalFilesRunData: + """Per-run download state, cleared by ``CORE.reset()`` between runs.""" + + # Verified fresh this run; later touches skip even the conditional HEAD. + fresh_paths: set[Path] = field(default_factory=set) + # Served from disk without revalidation; strict callers reject these. + stale_paths: set[Path] = field(default_factory=set) + # Served under skip_external_update, deliberately unchecked; skips the + # network like fresh_paths but never counts as verified. + unchecked_paths: set[Path] = field(default_factory=set) + # Failed with no usable copy; later touches replay the error fast. + failed_paths: dict[Path, FailedDownload] = field(default_factory=dict) + + +def _run_data() -> ExternalFilesRunData: + if (data := CORE.data.get(DOMAIN)) is not None: + return data + # setdefault: first touch may race on download_content_many's workers. + return CORE.data.setdefault(DOMAIN, ExternalFilesRunData()) + + IF_MODIFIED_SINCE = "If-Modified-Since" IF_NONE_MATCH = "If-None-Match" ETAG = "ETag" @@ -93,6 +139,9 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: def has_remote_file_changed( url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT ) -> bool: + # Deferred so configs with no remote files skip the heavy import. + import requests + ensure_happy_eyeballs() if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) @@ -127,6 +176,9 @@ def has_remote_file_changed( ) if (new_etag := response.headers.get(ETAG)) and new_etag != etag: _write_etag(local_file_path, new_etag) + # A confirmed 304 supersedes any earlier failed + # revalidation of this file. + _run_data().stale_paths.discard(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File modified") return True @@ -136,6 +188,9 @@ def has_remote_file_changed( url, e, ) + # The copy is a fallback, not a verified 304; record that so + # callers that must not use unverified bytes can reject it. + _run_data().stale_paths.add(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File doesn't exists at %s", local_file_path) @@ -159,14 +214,81 @@ def compute_local_file_dir(domain: str) -> Path: return base_directory -def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: +def url_cache_key(url: str) -> str: + """Short stable cache key for a URL.""" + return hashlib.sha256(url.encode()).hexdigest()[:8] + + +def compute_local_file_path(domain: str, url: str) -> Path: + """Cache path for a URL-keyed download under the domain's cache dir. + + Pure (no mkdir); parent directories are created at write time. + """ + return Path(CORE.data_dir) / domain / url_cache_key(url) + + +def is_fresh_this_run(path: Path) -> bool: + """Whether `path` was verified or downloaded during this run.""" + return path in _run_data().fresh_paths + + +def download_content( + url: str, + path: Path, + timeout: int = NETWORK_TIMEOUT, + allow_stale: bool = True, + return_content: bool = True, +) -> bytes: + """Download `url` into `path` and return the bytes, using the cache. + + On network failure an on-disk copy is served with a warning, unless + ``allow_stale=False``. ``CORE.skip_external_update`` always serves the + copy. ``return_content=False`` skips the disk read on cache hits. + """ + + # Deferred so configs with no remote files skip the heavy import. + import requests + + def _cached() -> bytes: + return path.read_bytes() if return_content else b"" + + # Memoized paths skip the network entirely; concurrent access is safe + # because download_content_many dedupes by path before fanning out. + run_data = _run_data() + fresh_paths = run_data.fresh_paths + if (path in fresh_paths or path in run_data.unchecked_paths) and path.exists(): + return _cached() + if allow_stale and path in run_data.stale_paths and path.exists(): + # Strict callers fall through to try the network themselves. + _LOGGER.info("Using cached copy of %s that could not be revalidated", url) + return _cached() + if (failure := run_data.failed_paths.get(path)) is not None: + if not path.exists(): + if failure.url == url: + raise cv.Invalid(failure.message) from failure.cause + raise cv.Invalid( + f"Could not download from {url}: an earlier download of " + f"{failure.url} to the same cache file failed: {failure.cause}" + ) from failure.cause + # The file appeared since the failure; revalidate normally. + del run_data.failed_paths[path] ensure_happy_eyeballs() if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) - return path.read_bytes() + run_data.unchecked_paths.add(path) + return _cached() if not has_remote_file_changed(url, path, timeout): + if path in run_data.stale_paths: + # The HEAD fell back to the copy without confirming it. + if not allow_stale: + raise cv.Invalid( + f"Could not check {url} for updates due to a network error " + f"and the cached copy cannot be verified" + ) + return _cached() _LOGGER.debug("Remote file has not changed %s", url) - return path.read_bytes() + fresh_paths.add(path) + return _cached() _LOGGER.info("Downloading %s", url) _LOGGER.debug("Saving to %s", path) @@ -185,16 +307,24 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by data = req.content except requests.exceptions.RequestException as e: if path.exists(): + # Memoized so a flaky host warns once per run, not per consumer. + run_data.stale_paths.add(path) + if not allow_stale: + raise cv.Invalid(f"Could not download from {url}: {e}") from e _LOGGER.warning( "Could not download from %s due to network error (%s), using cached file", url, e, ) - return path.read_bytes() - raise cv.Invalid(f"Could not download from {url}: {e}") from e + return _cached() + message = f"Could not download from {url}: {e}" + run_data.failed_paths[path] = FailedDownload(url, message, e) + raise cv.Invalid(message) from e write_file(path, data) _write_etag(path, req.headers.get(ETAG)) + fresh_paths.add(path) + run_data.stale_paths.discard(path) return data @@ -207,50 +337,47 @@ DEFAULT_DOWNLOAD_WORKERS = 8 def download_content_many( - items: Iterable[tuple[str, Path]], + items: Iterable[RemoteFile], 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. + """Run `download_content` for each `RemoteFile` 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 - is collected and surfaced together as `cv.MultipleInvalid` so the user - sees every broken file in a single validation pass instead of fixing - them one round-trip at a time. - - Items are de-duplicated by `path` -- two callers asking for the same - cache file (e.g. the same URL referenced twice in a config) would - otherwise race on `download_content`'s non-atomic write. When the - same `path` appears more than once, the last URL wins (standard dict - comprehension semantics); in practice duplicate paths only arise when - the URL is duplicated, so the choice doesn't matter. + `description` names the files in the progress log line. All workers run + to completion; every `cv.Invalid` raised is surfaced together as + `cv.MultipleInvalid`. Items dedupe by `path` (avoiding write races on + the same cache file); the last URL wins and a strict + `allow_stale=False` from any duplicate is kept. """ - seen: dict[Path, str] = {path: url for url, path in items} - if not seen: + seen: dict[Path, RemoteFile] = {} + for file in items: + if (prior := seen.get(file.path)) is not None and not prior.allow_stale: + file = RemoteFile(file.url, file.path, allow_stale=False) + seen[file.path] = file + unique = list(seen.values()) + if not unique: return ensure_happy_eyeballs() - _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) + _LOGGER.info("Checking %d %s for updates", len(unique), description) + + def _download_one(file: RemoteFile) -> None: + download_content( + file.url, + file.path, + timeout, + allow_stale=file.allow_stale, + return_content=False, + ) + + if len(unique) == 1: + _download_one(unique[0]) return - def _download_one(path_url: tuple[Path, str]) -> None: - # `seen` stores entries as (path, url) so the dict can dedupe by - # path; flip them back to download_content's (url, path) order. - path, url = path_url - download_content(url, path, timeout) - - workers = max(1, min(max_workers, len(seen))) + workers = max(1, min(max_workers, len(unique))) errors: list[cv.Invalid] = [] with ThreadPoolExecutor(max_workers=workers) as ex: - futures = [ex.submit(_download_one, item) for item in seen.items()] + futures = [ex.submit(_download_one, file) for file in unique] for future in futures: try: future.result() @@ -263,6 +390,21 @@ def download_content_many( raise cv.MultipleInvalid(errors) +def single_stage_prefetch( + extract: Callable[[ConfigType], RemoteFile | None], +) -> Callable[[list[ConfigType]], Iterator[list[RemoteFile]]]: + """Build a one-batch ``PREFETCH_FILES`` hook from a per-entry extractor. + + Covers the common case of one remote file per raw config entry; + components with staged downloads write their own generator. + """ + + def prefetch_files(entries: list[ConfigType]) -> Iterator[list[RemoteFile]]: + yield [ref for entry in entries if (ref := extract(entry)) is not None] + + return prefetch_files + + # Each component that uses external_files defines its own local # `TYPE_WEB = "web"`; the string is repeated here rather than imported # because there is no canonical `TYPE_WEB` in `esphome.const` to share. @@ -282,7 +424,7 @@ def download_web_files_in_config( slotted directly into a `cv.All(...)` chain. """ download_content_many( - (conf_file[CONF_URL], path_for(conf_file)) + RemoteFile(conf_file[CONF_URL], path_for(conf_file)) for entry in config if (conf_file := entry.get(CONF_FILE, {})).get(CONF_TYPE) == WEB_TYPE ) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6ed608b171..b8a43220ff 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -25,9 +25,13 @@ _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. +# connect errors move on to the next mirror immediately. _MIRROR_ATTEMPTS = 3 +# Passes over the whole mirror list when a transient network error is in +# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff). +_MIRROR_SWEEP_ATTEMPTS = 3 + def get_project_link_flags() -> list[str]: """Return the sorted -Wl, linker flags from the current build.""" @@ -151,6 +155,8 @@ def run_command( _LOGGER.debug("%s - running ...", cmd_str) run_env = os.environ.copy() + # Do not leak PYTHONPATH + run_env.pop("PYTHONPATH", None) if env: run_env.update(env) @@ -887,37 +893,51 @@ def _failure_reason(e: Exception) -> str: return str(e).split(" for url: ", maxsplit=1)[0] or repr(e) -def download_from_mirrors( - mirrors: list[str], - substitutions: dict[str, str], - target: io.RawIOBase | IO[bytes] | PathType, - timeout: int = 30, -) -> str: +def _spent_attempts_error(e: Exception, attempts: int) -> Exception: + """Wrap a failure whose mirror already consumed download attempts, so + the sweep classifies it as permanent.""" + from esphome.core import EsphomeError + + err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}") + err.__cause__ = e + return err + + +def _is_transient_download_error(e: Exception) -> bool: + """Return True when a download failure is worth retrying. + + Connection-level failures and HTTP 429/5xx are transient. Other HTTP + errors, local errors, and exhausted-attempts EsphomeError wrappers + (their per-mirror retries are already spent) are permanent. """ - Download file from multiple mirrors with substitution support. + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when actually downloading, never during config validation. + import requests - Args: - mirrors: list of mirror URLs - substitutions: Dictionary of substitutions to apply to URLs - target: Target file path or file-like object - timeout: Download timeout in seconds + if isinstance(e, requests.exceptions.HTTPError): + resp = e.response + return resp is not None and (resp.status_code == 429 or resp.status_code >= 500) + return isinstance( + e, + ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + ), + ) - 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. +def _try_mirrors_once( + urls: list[str], + path_target: Path | None, + f: IO[bytes] | None, + timeout: int, + failures: list[tuple[str, Exception]], +) -> str | None: + """Single pass over the resolved mirror ``urls``, one try per URL. - 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. - 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. + Returns the source URL on success, or None with each URL's exception + appended to ``failures``. """ # Imported lazily: requests is a heavy import (~85ms) and is only # needed when actually downloading, never during config validation. @@ -925,43 +945,7 @@ def download_from_mirrors( from esphome.core import EsphomeError - ensure_happy_eyeballs() - - # 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)}" - ) - - # 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 - + for url in urls: _LOGGER.debug("Trying to download from %s", url) # Path targets delegate to download_with_resume so a partial @@ -986,14 +970,14 @@ def download_from_mirrors( 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. + # File-like targets download here; 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 @@ -1001,9 +985,12 @@ def download_from_mirrors( try: resp, offset = _open_ranged(url, offset, timeout, validator) except (requests.RequestException, OSError) as e: - # Connect/HTTP error, no bytes flowed — next mirror. + # Connect/HTTP error, no bytes flowed — next mirror. Wrap + # when earlier attempts were already spent on this mirror. _LOGGER.debug("Failed to download %s: %s", url, str(e)) - failures.append((url, e)) + failures.append( + (url, _spent_attempts_error(e, attempt + 1) if attempt else e) + ) break try: @@ -1031,7 +1018,7 @@ def download_from_mirrors( _LOGGER.debug("Downloaded successfully from: %s", url) - # 5. Reset file pointer and return + # Reset file pointer and return f.seek(0) return url @@ -1054,16 +1041,124 @@ def download_from_mirrors( ) offset = 0 if attempt == _MIRROR_ATTEMPTS - 1: - failures.append((url, e)) + failures.append((url, _spent_attempts_error(e, _MIRROR_ATTEMPTS))) - # 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 + return None + + +def download_from_mirrors( + mirrors: list[str], + substitutions: dict[str, str], + target: io.RawIOBase | IO[bytes] | PathType, + timeout: int = 30, +) -> str: + """ + Download file from multiple mirrors with substitution support. + + Args: + mirrors: list of mirror URLs + substitutions: Dictionary of substitutions to apply to URLs + target: Target file path or file-like object + timeout: Download timeout in seconds + + 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. + + When every mirror fails and at least one failure is transient (dropped + connection, timeout, HTTP 429/5xx), the whole list is retried with a + short backoff; permanent failures (e.g. 404) raise immediately. + + Raises: + ValueError: If mirrors list is empty. + 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. + """ + from esphome.core import EsphomeError + + ensure_happy_eyeballs() + + # 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)}" ) + + # 2. Resolve the mirror templates (invariant across retry sweeps) + urls: list[str] = [] + skipped: list[tuple[str, str]] = [] + for mirror in mirrors: + try: + urls.append(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)")) + 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})")) + + # 3. Sweep the mirror list, retrying transient failures with backoff: + # a single pass keeps mirror failover fast, re-sweeping keeps one + # network blip from failing the build when only one mirror applies. + failures: list[tuple[str, Exception]] = [] + for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1): + sweep_failures: list[tuple[str, Exception]] = [] + if ( + url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures) + ) is not None: + return url + failures.extend(sweep_failures) + # Permanent failures (404, verification mismatch) won't heal; + # only retry when a transient error is in the mix (as git.py does). + transient = next( + ((u, e) for u, e in sweep_failures if _is_transient_download_error(e)), + None, + ) + if transient is None: + break + if sweep < _MIRROR_SWEEP_ATTEMPTS: + delay = 2**sweep + _LOGGER.warning( + "Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)", + transient[0], + _failure_reason(transient[1]), + delay, + sweep + 1, + _MIRROR_SWEEP_ATTEMPTS, + ) + time.sleep(delay) + + # 4. Report every attempted URL if all mirrors failed. failures spans + # all sweeps (deduplicated by URL and reason), so neither an early + # mirror's failure nor an earlier sweep's failure mode is hidden. + if failures: + seen: set[tuple[str, str]] = set() + attempts = "" + for url, e in failures: + reason = _failure_reason(e) + if (url, reason) not in seen: + seen.add((url, reason)) + attempts += f"\n {url}\n {reason}" attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) raise EsphomeError( f"Failed to download from all mirrors:{attempts}" diff --git a/esphome/helpers.py b/esphome/helpers.py index 15d9797ce1..9b2a461ccd 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -91,13 +91,8 @@ def fnv1a_32bit_hash(string: str) -> int: def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h - with per_code_point set. This is the OLD entity hash; it computes preference - keys that existing devices already have stored (see - https://github.com/esphome/backlog/issues/85) and is also still used for live - keys derived from config IDs (see the motion component's calibration key). - Note: lower() here is Unicode aware while the C++ reconstruction is not; see - the known limitation note on the C++ function. + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. + If you modify this function, update the C++ version and tests in both places. """ return fnv1_hash(sanitize(snake_case(name))) @@ -105,9 +100,9 @@ def fnv1_hash_object_id(name: str) -> int: def fnv1_hash_name(name: str) -> int: """Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations). - IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h, - which hashes the name bytes as stored on the device. - Used for pre-computing entity keys at code generation time. + 2026.8 beta firmware stored preferences under keys derived from this hash; + a future key migration must reconstruct those keys to recover that data + (see https://github.com/esphome/backlog/issues/85). """ return _fnv1_hash(name.encode("utf-8")) @@ -357,6 +352,24 @@ def resolve_ip_address( return res +def format_ip_url(family: int, sockaddr: tuple, port: int, path: str) -> str: + """Build an ``http://host:port/path`` URL for a resolved address. + + ``family``/``sockaddr`` come from a :func:`resolve_ip_address` entry. IPv6 + literals must be wrapped in brackets in URLs; link-local addresses need a + percent-encoded zone index per RFC 6874. + """ + import socket + + ip = sockaddr[0] + if family == socket.AF_INET6: + scope = sockaddr[3] if len(sockaddr) >= 4 else 0 + host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" + else: + host_part = ip + return f"http://{host_part}:{port}{path}" + + def sort_ip_addresses(address_list: list[str]) -> list[str]: """Takes a list of IP addresses in string form, e.g. from mDNS or MQTT, and sorts them into the best order to actually try connecting to them. diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6a9d7171ec..aff1a6819f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.1 + version: 0.7.2 lvgl/lvgl: version: 9.5.0 fastled/FastLED: diff --git a/esphome/loader.py b/esphome/loader.py index 22db8b156a..f994f0c5eb 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager from dataclasses import dataclass import importlib @@ -16,6 +16,7 @@ from esphome.types import ConfigType if TYPE_CHECKING: from esphome.cpp_generator import MockObjClass + from esphome.external_files import RemoteFile # `esphome.core.config` is imported lazily in `_lookup_module` when the # "esphome" pseudo-component is first resolved. It pulls in @@ -135,6 +136,21 @@ class ComponentManifest: """ return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None) + @property + def prefetch_files( + self, + ) -> Callable[[list[ConfigType]], Iterable[list["RemoteFile"]]] | None: + """Optional `PREFETCH_FILES` hook for batched remote file downloads. + + A generator called once per run with the component's raw, pre-schema + config entries; each yield is a stage of ``RemoteFile`` downloaded in + one parallel pass before schema validation, so a later stage may + derive URLs from earlier files' content. Best effort: skip anything + unrecognized. On platform components, place it on the platform + sub-module; a domain-module hook receives every entry. + """ + return getattr(self.module, "PREFETCH_FILES", None) + @property def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None: """Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module. @@ -253,10 +269,9 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: # If `domain` is the legacy name of a renamed component, redirect to the # canonical module so the rest of the loader (and every caller of # `get_component(legacy)`) transparently sees the new component. - alias_map = _get_alias_map() - if domain in alias_map: - canonical = alias_map[domain] - manif = _lookup_module(canonical, exception) + alias_meta = get_alias_metadata().get(domain) + if alias_meta is not None: + manif = _lookup_module(alias_meta.canonical, exception) if manif is not None: _COMPONENT_CACHE[domain] = manif return manif @@ -313,8 +328,10 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # --------------------------------------------------------------------------- # # A component can declare ``ALIASES = ["legacy_name"]`` (and optionally -# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two -# integrations are then wired up automatically: +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run +# ``script/build_alias_registry.py`` to regenerate +# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry +# is stale). Two integrations are then wired up automatically: # # 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) # intercepts ``esphome.components.``/``....`` @@ -328,13 +345,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # dependency checks, schema validation and codegen all see only the # canonical name. # -# Both lookups are populated by ``_build_alias_map``, which **AST-parses** -# every component's ``__init__.py`` rather than importing it. That keeps the -# cost low: scanning ~400 components on disk takes ~5 ms instead of the -# multi-second cost of executing every component's import side-effects. +# Both lookups read the checked-in registry in ``esphome.component_aliases`` +# (generated by ``script/build_alias_registry.py``, verified in CI), so no +# component-directory scan happens at runtime. ``_build_alias_map`` below is +# the generator's scan implementation; it **AST-parses** each component's +# ``__init__.py`` rather than importing it. -_ALIAS_MAP_CACHE: dict[str, str] | None = None _ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None @@ -351,31 +368,17 @@ class AliasMeta: removal_version: str | None -def _ensure_alias_caches() -> None: - """Populate both alias caches from a single directory scan. - - ``_build_alias_map`` returns both maps together, so building them in one - shot avoids scanning every component's ``__init__.py`` twice when a run - needs both the canonical map (loader) and the metadata map (config - pre-pass). - """ - global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE - if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: - _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() - - -def _get_alias_map() -> dict[str, str]: - """Return the legacy-name → canonical-name map, building it lazily.""" - _ensure_alias_caches() - return _ALIAS_MAP_CACHE - - def get_alias_metadata() -> dict[str, AliasMeta]: - """Return the legacy-name → :class:`AliasMeta` map (cached). + """Return the legacy-name → :class:`AliasMeta` map, built lazily from + the generated registry.""" + global _ALIAS_META_CACHE # noqa: PLW0603 + if _ALIAS_META_CACHE is None: + from esphome.component_aliases import COMPONENT_ALIASES - Used by the YAML pre-pass to format a per-alias deprecation warning. - """ - _ensure_alias_caches() + _ALIAS_META_CACHE = { + alias: AliasMeta(canonical=canonical, removal_version=removal_version) + for alias, (canonical, removal_version) in COMPONENT_ALIASES.items() + } return _ALIAS_META_CACHE @@ -521,11 +524,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder): # least three parts, so ``parts[2]`` (the domain) always exists. parts = fullname.split(".") domain = parts[2] - alias_map = _get_alias_map() - if domain not in alias_map: + alias_meta = get_alias_metadata().get(domain) + if alias_meta is None: return None - parts[2] = alias_map[domain] + parts[2] = alias_meta.canonical canonical_fullname = ".".join(parts) try: canonical_module = importlib.import_module(canonical_fullname) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 3198de9d21..62deafb09a 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -6,6 +6,7 @@ from pathlib import Path import ssl import tempfile import time +from typing import TYPE_CHECKING import paho.mqtt.client as mqtt @@ -31,6 +32,9 @@ from esphome.helpers import get_int_env, get_str_env from esphome.types import ConfigType from esphome.util import safe_print +if TYPE_CHECKING: + import threading + _LOGGER = logging.getLogger(__name__) @@ -164,6 +168,7 @@ def get_esphome_device_ip( password: str | None = None, client_id: str | None = None, timeout: float = 25, + stop_event: "threading.Event | None" = None, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( @@ -182,55 +187,113 @@ def get_esphome_device_ip( dev_name = config[CONF_ESPHOME][CONF_NAME] dev_ip = None + failed = False topic = "esphome/discover/" + dev_name _LOGGER.info("Starting looking for IP in topic %s", topic) def on_message(client, userdata, msg): - nonlocal dev_ip + nonlocal dev_ip, failed time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload _LOGGER.debug(message) - data = json.loads(payload) + try: + data = json.loads(payload) + except ValueError: + data = None + if not isinstance(data, dict): + # A raise in this handler would kill paho's network thread + _LOGGER.warning("Ignoring unparsable discovery payload") + return if "name" not in data or data["name"] != dev_name: _LOGGER.warning("Wrong device answer") return - dev_ip = [] + addresses = [] key = "ip" n = 0 while key in data: - dev_ip.append(data[key]) + value = data[key] + if ( + isinstance(value, str) + and (value := value.strip()) + and value.isprintable() + ): + addresses.append(value) + else: + # repr-escaped and truncated: must not forge log lines + _LOGGER.warning( + "Ignoring invalid address in discovery answer: %s", + repr(value)[:100], + ) n = n + 1 key = "ip" + str(n) - if dev_ip: - client.disconnect() + if not addresses: + _LOGGER.warning("Device answer did not include an IP address") + failed = True + return + + dev_ip = addresses + failed = False # a complete answer wins over an earlier empty one + client.disconnect() def on_connect(client, userdata, flags, return_code): topic = "esphome/ping/" + dev_name _LOGGER.info("Send discover via MQTT broker topic: %s", topic) client.publish(topic, None, retain=False) + if stop_event is not None and stop_event.is_set(): + # Teardown already started; don't open a broker connection at all + return [] + + def on_disconnect(client, userdata, result_code): + nonlocal failed + if result_code != 0: + _LOGGER.warning("Disconnected from MQTT broker (%s)", result_code) + failed = True + mqtt_client = prepare( config, [topic], on_message, on_connect, username, password, client_id ) + # Discovery is one-shot; prepare()'s reconnect-forever on_disconnect runs + # on the network thread and would make loop_stop() below join forever. + mqtt_client.on_disconnect = on_disconnect - mqtt_client.loop_start() - while timeout > 0: - if dev_ip is not None: - break - timeout -= 0.250 - time.sleep(0.250) - mqtt_client.loop_stop() + if stop_event is None: + import threading + + stop_event = threading.Event() # never set; wait() below is a plain sleep + stopped = stop_event.is_set() # teardown may have started during connect + try: + if not stopped: + mqtt_client.loop_start() + while timeout > 0: + if dev_ip is not None or failed: + break + if stop_event.wait(0.250): + stopped = True + break + timeout -= 0.250 + finally: + # A cleanup failure must not replace the discovery result or its + # EsphomeError; a second disconnect after on_message's is harmless. + try: + mqtt_client.disconnect() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Error disconnecting from MQTT broker", exc_info=True) + mqtt_client.loop_stop() # only signals and joins; does not raise if dev_ip is None: + if stopped: + # Aborted by the caller, not a failure; stay quiet + return [] raise EsphomeError("Failed to find IP via MQTT") - _LOGGER.info("Found IP: %s", dev_ip) + _LOGGER.info("Found IP via MQTT broker: %s", ", ".join(dev_ip)) return dev_ip diff --git a/esphome/storage_json.py b/esphome/storage_json.py index a90a36b848..9219914529 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -71,8 +71,11 @@ def archive_storage_path() -> Path: def _to_path_if_not_none(value: str | None) -> Path | None: - """Convert a string to Path if it's not None.""" - return Path(value) if value is not None else None + """Convert a string to Path; None and the legacy "None" both map to None. + + Sidecars written before as_dict skipped unset paths hold str(None). + """ + return Path(value) if value is not None and value != "None" else None def _parse_framework_version(framework_version: str) -> Version: @@ -170,8 +173,10 @@ class StorageJSON: "address": self.address, "web_port": self.web_port, "esp_platform": self.target_platform, - "build_path": str(self.build_path), - "firmware_bin_path": str(self.firmware_bin_path), + "build_path": str(self.build_path) if self.build_path else None, + "firmware_bin_path": ( + str(self.firmware_bin_path) if self.firmware_bin_path else None + ), "loaded_integrations": sorted(self.loaded_integrations), "loaded_platforms": sorted(self.loaded_platforms), "no_mdns": self.no_mdns, @@ -189,7 +194,18 @@ class StorageJSON: write_file_if_changed(path, self.to_json()) @staticmethod - def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: + def from_esphome_core( + esph: CoreType, old: StorageJSON | None, *, claim_build: bool = True + ) -> StorageJSON: + """Build a sidecar from post-validation CORE state. + + claim_build=False (the upload/logs fallback, which runs no build) + carries the build-artifact fields (esphome_version, + firmware_bin_path) from *old* instead of asserting this run built + firmware. Validation-derived fields (platform, framework_version, + toolchain, build_path) always stamp; storage_should_clean compares + them against the next compile. + """ hardware = esph.target_platform.upper() framework_version: str | None = None if esph.is_esp32: @@ -204,13 +220,21 @@ class StorageJSON: name=esph.name, friendly_name=esph.friendly_name, comment=esph.comment, - esphome_version=const.__version__, + esphome_version=( + const.__version__ + if claim_build + else (old.esphome_version if old else None) + ), src_version=1, address=esph.address, web_port=esph.web_port, target_platform=hardware, build_path=esph.build_path, - firmware_bin_path=esph.firmware_bin, + firmware_bin_path=( + esph.firmware_bin + if claim_build + else (old.firmware_bin_path if old else None) + ), loaded_integrations=esph.loaded_integrations, loaded_platforms=esph.loaded_platforms, no_mdns=( @@ -302,11 +326,27 @@ class StorageJSON: except Exception: # noqa: BLE001 # pylint: disable=broad-except return None + @staticmethod + def load_strict(path: Path) -> StorageJSON | None: + """Like load, but None only means missing; an unreadable file raises.""" + if not path.is_file(): + return None + return StorageJSON._load_impl(path) + + def can_apply_to_core(self) -> bool: + """True when the sidecar carries everything apply_to_core hands CORE. + + Wizard-written sidecars leave build_path unset (older wizards also + the platform fields) and can't drive upload/logs. + """ + return bool((self.core_platform or self.target_platform) and self.build_path) + def apply_to_core(self) -> None: """Populate CORE with the metadata upload/logs read. Inverse of :meth:`from_esphome_core`. Keep paired -- a new - attribute upload/logs needs has to be captured there too. + attribute upload/logs needs has to be captured there too and + reflected in :meth:`can_apply_to_core`. Validator-only fields (loaded_integrations/platforms, friendly_name) are skipped; the fast path doesn't run validation and CORE.__init__ defaults them. diff --git a/esphome/util.py b/esphome/util.py index 2fc34f3a69..b8ffa048ca 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -390,6 +390,20 @@ def is_dev_esphome_version(): return "dev" in const.__version__ +# Remove before 2027.2.0 +def parse_esphome_version() -> tuple[int, int, int]: + """Deprecated: use esphome.config_validation.require_esphome_version instead.""" + from esphome.core import Version + + _LOGGER.warning( + "parse_esphome_version() is deprecated. Use " + "cv.require_esphome_version to gate on a minimum version. " + "Removed in 2027.2.0" + ) + version = Version.parse(const.__version__) + return version.major, version.minor, version.patch + + # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): diff --git a/esphome/web_server_helpers.py b/esphome/web_server_helpers.py new file mode 100644 index 0000000000..f48934b185 --- /dev/null +++ b/esphome/web_server_helpers.py @@ -0,0 +1,43 @@ +"""Shared helpers for the web_server HTTP transports (OTA upload and logs).""" + +from __future__ import annotations + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import CORE, EsphomeError +from esphome.helpers import format_ip_url, resolve_ip_address +from esphome.types import ConfigType + + +def resolve_web_server_urls(host: str, port: int, path: str) -> list[tuple[str, str]]: + """Resolve ``host`` to ``(ip, url)`` pairs for the web_server ``path``. + + Wraps :func:`resolve_ip_address` (honoring ``CORE.address_cache``) and + formats each resolved address into an ``http://host:port/path`` URL via + :func:`format_ip_url`, handling both IPv4 and IPv6. Shared by the + web_server OTA upload and log streaming paths. + """ + addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + return [ + (sockaddr[0], format_ip_url(family, sockaddr, port, path)) + for family, _socktype, _, _, sockaddr in addr_infos + ] + + +def get_web_server_connection(config: ConfigType) -> tuple[int, str | None, str | None]: + """Return ``(port, username, password)`` for the web_server HTTP endpoint. + + Reads the port and optional HTTP Basic-auth credentials from the validated + ``web_server:`` config, shared by the web_server OTA upload and log + streaming paths. Raises :class:`EsphomeError` if ``web_server`` is absent. + """ + web_conf = config.get(CONF_WEB_SERVER) + if not web_conf: + raise EsphomeError(f"The {CONF_WEB_SERVER} component is not configured.") + auth = web_conf.get(CONF_AUTH) or {} + return int(web_conf[CONF_PORT]), auth.get(CONF_USERNAME), auth.get(CONF_PASSWORD) diff --git a/esphome/web_server_logs.py b/esphome/web_server_logs.py new file mode 100644 index 0000000000..e091e24bb7 --- /dev/null +++ b/esphome/web_server_logs.py @@ -0,0 +1,189 @@ +"""Stream device logs over the ``web_server`` component's HTTP SSE endpoint. + +The ``web_server`` component exposes a Server-Sent Events stream at ``/events`` +that multiplexes entity state, keepalive pings, and log lines (``event: log``). +This is the logging counterpart to the web_server OTA upload path +(:mod:`esphome.web_server_ota`); it lets ``esphome logs`` reach a device that +has ``web_server:`` configured but no ``api:``. + +Only the ``event: log`` frames are rendered; the payload is the device's +already-formatted, ANSI-colored log line, so it is passed through the same +``LogParser`` + ``safe_print`` path the serial and native-API log viewers use. +The stream is long-lived and the server drops idle connections, so the reader +reconnects automatically until interrupted. +""" + +from __future__ import annotations + +from datetime import datetime +import logging +import time +from typing import TYPE_CHECKING + +import requests +from requests.auth import HTTPBasicAuth + +from esphome.core import EsphomeError +from esphome.util import safe_print +from esphome.web_server_helpers import resolve_web_server_urls + +if TYPE_CHECKING: + from aioesphomeapi import LogParser + +_LOGGER = logging.getLogger(__name__) + +EVENTS_PATH = "/events" +# (connect_timeout, read_timeout). The device sends a keepalive ``ping`` every +# 10s, so a 30s read timeout tolerates a few missed pings before we treat the +# connection as dead and reconnect. +TIMEOUT = (10.0, 30.0) +# Pause between reconnect attempts so a downed device doesn't spin the CPU. +RECONNECT_DELAY = 1.0 +# Upper bound for the exponential backoff applied to consecutive failures, so an +# unreachable host backs off instead of retrying (and logging) once a second. +MAX_RECONNECT_DELAY = 10.0 + + +class WebServerLogsError(EsphomeError): + """Raised when the web_server log stream cannot be used (e.g. bad auth).""" + + +def _build_urls(hosts: list[str], port: int) -> list[tuple[str, str]]: + """Resolve ``hosts`` to ``(ip, url)`` pairs for the ``/events`` endpoint.""" + urls: list[tuple[str, str]] = [] + seen: set[str] = set() + for host in hosts: + try: + resolved = resolve_web_server_urls(host, port, EVENTS_PATH) + except EsphomeError as err: + _LOGGER.warning("Error resolving IP address of %s: %s", host, err) + continue + for ip, url in resolved: + if url not in seen: + seen.add(url) + urls.append((ip, url)) + return urls + + +def _emit(data_lines: list[str], parser: LogParser) -> None: + """Render the accumulated ``data:`` lines of one ``event: log`` frame.""" + time_ = datetime.now().astimezone() + milliseconds = time_.microsecond // 1000 + time_str = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]" + ) + for line in data_lines: + safe_print(parser.parse_line(line, time_str)) + + +def _consume(response: requests.Response, parser: LogParser) -> None: + """Parse the SSE stream, rendering only ``event: log`` frames. + + Implements the minimal slice of the SSE grammar the ``web_server`` stream + uses: ``field: value`` lines (with one optional leading space after the + colon) accumulated until a blank line dispatches the frame. ``id:``, + ``retry:``, and comment (``:``) lines are ignored, as are non-``log`` + events (``ping``, ``state``, ...). + """ + event_type = "message" + data_lines: list[str] = [] + # Iterate bytes and decode as UTF-8 ourselves (matching run_miniterm); the + # text/event-stream response has no charset, so requests' decode_unicode + # would fall back to Latin-1 and mojibake UTF-8 log characters. + for raw in response.iter_lines(): + line = raw.decode("utf8", "backslashreplace") + if not line: + if event_type == "log" and data_lines: + _emit(data_lines, parser) + event_type = "message" + data_lines = [] + continue + if line.startswith(":"): + continue + field, _, value = line.partition(":") + value = value.removeprefix(" ") + if field == "event": + event_type = value + elif field == "data": + data_lines.append(value) + + +def _stream(url: str, ip: str, auth: HTTPBasicAuth | None, parser: LogParser) -> bool: + """Connect and stream one session. + + Returns ``True`` if a connection was established (even if it later + dropped), ``False`` if the connection attempt itself failed so the caller + can try the next resolved address. + """ + connected = False + _LOGGER.info("Connecting to %s ...", url) + try: + with requests.get( + url, + stream=True, + auth=auth, + timeout=TIMEOUT, + headers={"Accept": "text/event-stream"}, + ) as response: + if response.status_code == 401: + raise WebServerLogsError( + "Authentication failed (HTTP 401). Check the 'web_server' " + "'auth' username and password." + ) + if response.status_code in (403, 404): + # Permanent: the endpoint won't appear on retry (wrong version, + # 'log' disabled, or forbidden). Surface it instead of looping. + raise WebServerLogsError( + f"Device returned HTTP {response.status_code} for " + f"{EVENTS_PATH}; the web_server log stream is unavailable. " + "Ensure 'web_server' is version 2 or higher with 'log' enabled." + ) + if response.status_code != 200: + _LOGGER.error( + "Unexpected HTTP %s response from %s", response.status_code, ip + ) + return False + connected = True + _LOGGER.info("Connected to %s", ip) + _consume(response, parser) + except requests.RequestException as err: + if connected: + _LOGGER.info("Log stream from %s ended (%s); reconnecting...", ip, err) + else: + _LOGGER.warning("Could not connect to %s: %s", ip, err) + return connected + + +def run_logs( + hosts: list[str], + port: int, + username: str | None, + password: str | None, +) -> int: + """Stream logs from the first reachable host over the web_server SSE feed. + + Reconnects automatically when the stream drops and returns ``0`` on + ``KeyboardInterrupt`` (Ctrl+C), mirroring how the serial log viewer exits. + """ + from aioesphomeapi import LogParser + + auth = HTTPBasicAuth(username, password) if username and password else None + parser = LogParser() + delay = RECONNECT_DELAY + try: + while True: + if not (urls := _build_urls(hosts, port)): + _LOGGER.error("Could not resolve any of: %s", ", ".join(hosts)) + connected = False + else: + # ``any`` stops at the first address that connects; when that + # stream drops we reconnect to the same set on the next pass. + connected = any(_stream(url, ip, auth, parser) for ip, url in urls) + # Reset the backoff once we reach the device; otherwise grow it + # (capped) so an unreachable host doesn't retry/log once a second. + delay = ( + RECONNECT_DELAY if connected else min(delay * 2, MAX_RECONNECT_DELAY) + ) + time.sleep(delay) + except KeyboardInterrupt: + return 0 diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py index 8d0fdeecff..7b508e8527 100644 --- a/esphome/web_server_ota.py +++ b/esphome/web_server_ota.py @@ -12,14 +12,14 @@ import io import logging from pathlib import Path import secrets -import socket from typing import BinaryIO import requests from requests.auth import HTTPBasicAuth from esphome.core import EsphomeError -from esphome.helpers import ProgressBar, resolve_ip_address +from esphome.helpers import ProgressBar +from esphome.web_server_helpers import resolve_web_server_urls _LOGGER = logging.getLogger(__name__) @@ -95,7 +95,7 @@ def _try_upload( from esphome.core import CORE try: - addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + addr_urls = resolve_web_server_urls(host, port, OTA_PATH) except EsphomeError as err: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", host @@ -104,7 +104,7 @@ def _try_upload( _LOGGER.error("(If you know the IP, try --device )") raise WebServerOTAError(err) from err - if not addr_infos: + if not addr_urls: _LOGGER.error("Could not resolve %s", host) return 1, None @@ -113,16 +113,7 @@ def _try_upload( auth = HTTPBasicAuth(username, password) if username and password else None # Iterate resolved IPs (IPv4 + IPv6 candidates) just like espota2 does. - for af, _socktype, _, _, sa in addr_infos: - ip = sa[0] - # IPv6 literals must be wrapped in brackets in URLs; link-local - # addresses need a percent-encoded zone index per RFC 6874. - if af == socket.AF_INET6: - scope = sa[3] if len(sa) >= 4 else 0 - host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" - else: - host_part = ip - url = f"http://{host_part}:{port}{OTA_PATH}" + for ip, url in addr_urls: _LOGGER.info("Connecting to %s port %s...", ip, port) try: diff --git a/requirements.txt b/requirements.txt index 9c231bd0fe..876b13793c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.0 +aioesphomeapi==45.10.2 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 @@ -23,11 +23,11 @@ pillow==12.3.0 resvg-py==0.3.4 freetype-py==2.5.1 jinja2==3.1.6 -bleak==2.1.1 +bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.1 # native esp-idf toolchain global cache dir +platformdirs==4.11.2 # native esp-idf toolchain global cache dir filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this diff --git a/requirements_test.txt b/requirements_test.txt index 0905fe6be1..95ee97437d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,8 +1,8 @@ -pylint==4.0.6 +pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.4.12 # also change in .github/workflows/ci.yml when updating +prek==0.4.13 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 diff --git a/script/build_alias_registry.py b/script/build_alias_registry.py new file mode 100755 index 0000000000..e007c075eb --- /dev/null +++ b/script/build_alias_registry.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate esphome/component_aliases.py from component ALIASES declarations. + +Run without arguments to regenerate the registry; ``--check`` (run in CI) +verifies it is up to date. +""" + +import argparse +from pathlib import Path +import sys + +# The root directory of the repo +root = Path(__file__).parent.parent +# Make the repo's esphome package win over any installed copy +sys.path.insert(0, str(root)) + +from esphome.helpers import write_file_if_changed # noqa: E402 +from esphome.loader import _build_alias_map # noqa: E402 + +parser = argparse.ArgumentParser() +parser.add_argument( + "--check", + help="Check if the alias registry is up to date.", + action="store_true", +) +args = parser.parse_args() + +registry_file = root / "esphome" / "component_aliases.py" + +HEADER = '''"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { +''' + +# _build_alias_map scans the real component tree and already rejects +# duplicate and shadowing aliases with an EsphomeError. +_, alias_meta = _build_alias_map() + +lines = [HEADER] +for alias, meta in sorted(alias_meta.items()): + removal = f'"{meta.removal_version}"' if meta.removal_version else "None" + lines.append(f' "{alias}": ("{meta.canonical}", {removal}),\n') +lines.append("}\n") +content = "".join(lines) + +if args.check: + if registry_file.read_text(encoding="utf-8") != content: + print("Component alias registry is not up to date.") + print("Please run `script/build_alias_registry.py`") + sys.exit(1) + print("Component alias registry is up to date") +else: + write_file_if_changed(registry_file, content) + print(f"Wrote {registry_file}") diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index 2549125a43..3774d990d3 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None: def test_esp32_defaults_are_valid() -> None: - """esp32 pins the ESP-IDF reference rate and exposes active (default on).""" + """esp32 pins the ESP-IDF reference rate and exposes active (default on). + + Without wifi loaded, the conditional window default falls back to the + historical 30 ms; the wifi-aware resolution is covered by the + esp32_ble_tracker component tests. + """ config = ESP32_SCHEMA({}) assert to_ble_units(config["interval"]) == 512 assert to_ble_units(config["window"]) == 48 diff --git a/tests/unit_tests/components/mqtt/__init__.py b/tests/component_tests/esp32_ble_tracker/__init__.py similarity index 100% rename from tests/unit_tests/components/mqtt/__init__.py rename to tests/component_tests/esp32_ble_tracker/__init__.py diff --git a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py new file mode 100644 index 0000000000..8a25f488fa --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py @@ -0,0 +1,122 @@ +"""Tests for the esp32_ble_tracker conditional scan window default. + +The scan window default depends on wifi coexistence and the IDF version: +IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the +configured window (espressif/esp-idf#18931), so on fixed versions the +historical 30 ms default would only listen 9.4 % of the time and miss most +advertisements. With the coexistence arbiter compiled in on a fixed IDF, the +window instead defaults to the interval, as Espressif recommends; without the +arbiter a full-duty scan would starve wifi, so the 30 ms default is kept. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_device_base import to_ble_units +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_ble_tracker import ( + CONF_SOFTWARE_COEXISTENCE, + CONFIG_SCHEMA, +) +from esphome.const import CONF_INTERVAL, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + +from ..types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32( + set_core_config: SetCoreConfigCallable, +) -> Callable[..., None]: + """Stage an esp32 build with a given IDF version and wifi presence.""" + + def stage(idf: str, *, wifi: bool) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + if wifi: + # Makes cv.OnlyWith default software_coexistence to True, exactly + # as a real config with wifi: does. + CORE.loaded_integrations.add("wifi") + + return stage + + +def _scan_params(config: ConfigType) -> ConfigType: + return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS] + + +@pytest.mark.parametrize( + ("idf", "config", "expected_units"), + [ + ("5.5.5", {}, 512), # first fixed version, default 320 ms interval + ("6.0.1", {}, 512), # any newer version behaves the same + # Follows a user-set interval. + ("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600), + ], +) +def test_wifi_on_fixed_idf_defaults_window_to_interval( + stage_esp32: Callable[..., None], + idf: str, + config: ConfigType, + expected_units: int, +) -> None: + """With wifi coexistence on a fixed IDF, the window defaults to the interval.""" + stage_esp32(idf, wifi=True) + params = _scan_params(config) + assert params[CONF_WINDOW] == params[CONF_INTERVAL] + assert to_ble_units(params[CONF_WINDOW]) == expected_units + + +@pytest.mark.parametrize( + ("idf", "wifi", "config"), + [ + # Buggy IDF over-scans anyway; keep the 30 ms default. + ("5.5.4", True, {}), + # No wifi (e.g. ethernet) means no radio contention. + ("5.5.5", False, {}), + # Coexistence disabled: no arbiter, so a full-duty scan would starve + # wifi outright. + ("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}), + ], +) +def test_30ms_default_kept( + stage_esp32: Callable[..., None], + idf: str, + wifi: bool, + config: ConfigType, +) -> None: + stage_esp32(idf, wifi=wifi) + assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48 + + +@pytest.mark.parametrize("window", ["60ms", "30ms"]) +def test_explicit_window_is_never_touched( + stage_esp32: Callable[..., None], window: str +) -> None: + """A user-set window wins over the conditional default. + + The explicit 30 ms case matters: it is indistinguishable from the + defaulted value by inspection, so the defaulted flag must separate them. + """ + stage_esp32("5.5.5", wifi=True) + params = _scan_params({"scan_parameters": {"window": window}}) + assert to_ble_units(params[CONF_WINDOW]) == to_ble_units( + cv.positive_time_period(window) + ) + + +def test_short_interval_without_window_still_rejected( + stage_esp32: Callable[..., None], +) -> None: + """The provisional 30 ms default validates against the interval as before.""" + stage_esp32("5.5.5", wifi=True) + with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): + _scan_params({"scan_parameters": {"interval": "20ms"}}) diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py index 8528cf23ca..950fa389be 100644 --- a/tests/component_tests/gsl3670/test_init.py +++ b/tests/component_tests/gsl3670/test_init.py @@ -87,13 +87,11 @@ def test_cache_path_is_deterministic_per_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """The cache path is derived from (and stable for) the URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) first = gsl._cache_path(VALID_URL) assert first == gsl._cache_path(VALID_URL) assert first != gsl._cache_path("https://example.com/other.bin") - assert first.parent == tmp_path + assert first.parent == tmp_path / "gsl3670" def test_firmware_path_prefers_local_file(tmp_path: Path) -> None: @@ -106,9 +104,7 @@ def test_firmware_path_uses_cache_for_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """A ``url`` source resolves to the cache path for that URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL) @@ -145,9 +141,7 @@ def test_firmware_url_downloads_and_validates( ) -> None: """A url source downloads the content and validates its structure.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL} @@ -157,9 +151,7 @@ def test_firmware_url_sha256_mismatch_rejected( ) -> None: """A configured SHA-256 that does not match the download is rejected.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) with pytest.raises(cv.Invalid, match="SHA-256 mismatch"): gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32}) @@ -169,9 +161,7 @@ def test_firmware_url_invalid_structure_rejected( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Downloaded content that is not a valid blob is rejected.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr( gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02" ) diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp index 3cf708c19e..6e9b567080 100644 --- a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -2,29 +2,9 @@ #include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" -namespace esphome::hoermann_hcp { +#include "../common.h" -using modbus::RegisterValues; - -namespace { - -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// Exposes the connection bookkeeping so a drop can be driven without waiting one out. -class TestableHoermannHcp : public HoermannHcp { - public: - using HoermannHcp::set_valid_; -}; - -} // namespace +namespace esphome::hoermann_hcp::testing { // Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected. TEST(HoermannHcpBinarySensorTest, StartsDisconnected) { @@ -42,7 +22,7 @@ TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) { sensor.setup(); ASSERT_FALSE(sensor.state); - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + connect_controller(door); door.update(); EXPECT_TRUE(sensor.state); @@ -59,7 +39,7 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { int publishes = 0; sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + connect_controller(door); door.update(); ASSERT_EQ(publishes, 1); @@ -69,4 +49,4 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { EXPECT_EQ(publishes, 1); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h new file mode 100644 index 0000000000..a6151697f0 --- /dev/null +++ b/tests/components/hoermann_hcp/common.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include +#include +#include "esphome/components/hoermann_hcp/hoermann_hcp.h" + +namespace esphome::hoermann_hcp::testing { + +using modbus::RegisterValues; + +// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. +constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); + +inline RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// A status broadcast carrying the lamp register, which the door reports at index 6. +inline RegisterValues lamp_broadcast(uint16_t lamp_reg) { + return make_registers({0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, lamp_reg}); +} + +// The door only accepts commands once the bus controller has actually talked to it. +inline void connect_controller(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); +} + +// Runs one command poll (write 2 / read 8) and returns both key-press registers. +inline std::pair poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + if (response.size() != 8u) + return {0xFFFF, 0xFFFF}; + return {response[2], response[3]}; +} + +// Presents and then releases the queued command, leaving the slot free. +inline void consume_command(HoermannHcp &door) { + poll_command(door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); +} + +// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. +class TestableHoermannHcp : public HoermannHcp { + public: + TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } + + using HoermannHcp::connection_timeout_ms_; + using HoermannHcp::is_light_toggle_pending_; + using HoermannHcp::light_toggle_released_at_; + using HoermannHcp::light_toggles_in_flight_; + using HoermannHcp::set_valid_; +}; + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 84162e8812..552b1cb0fd 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -11,3 +11,7 @@ binary_sensor: - platform: hoermann_hcp is_connected: name: Garage Connected + +light: + - platform: hoermann_hcp + name: Garage Light diff --git a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp index 0ec2ed1ddd..43ca47edb2 100644 --- a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp +++ b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp @@ -2,36 +2,9 @@ #include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h" -namespace esphome::hoermann_hcp { +#include "../common.h" -using modbus::RegisterValues; - -namespace { - -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t STATE_REG = 0x9CB9; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// The door only accepts commands once the bus controller has actually talked to it. -void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } - -// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. -uint16_t poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); - RegisterValues response; - door.on_read_holding_registers(STATE_REG, 8, response); - EXPECT_EQ(response.size(), 8u); - return response.size() == 8u ? response[2] : 0xFFFF; -} - -} // namespace +namespace esphome::hoermann_hcp::testing { // Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish. TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) { @@ -92,10 +65,10 @@ TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_open().perform(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed } // The same for cover.close, which arrives as a position of 0.0. @@ -103,32 +76,32 @@ TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_close().perform(); - EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed } TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_toggle().perform(); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); // The door is opening, so it takes an impulse to stop it. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); cover.make_call().set_command_stop().perform(); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // A position between the end stops starts the door in the right direction; it is stopped there later. @@ -136,10 +109,10 @@ TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) { HoermannHcp door; // starts out fully closed HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_position(0.5f).perform(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed } // A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has @@ -153,7 +126,7 @@ TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) { cover.make_call().set_command_close().perform(); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); EXPECT_EQ(publishes, 1); EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN); } @@ -166,9 +139,9 @@ TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) { cover.setup(); EXPECT_TRUE(cover.status_has_warning()); - connect(door); + connect_controller(door); door.update(); EXPECT_FALSE(cover.status_has_warning()); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp index 8463c3f605..1cc5301b4a 100644 --- a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp +++ b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp @@ -3,51 +3,9 @@ #include #include -#include "esphome/components/hoermann_hcp/hoermann_hcp.h" +#include "common.h" -namespace esphome::hoermann_hcp { - -using modbus::RegisterValues; - -namespace { - -// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t STATE_REG = 0x9CB9; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. -constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// The device only accepts commands once the bus controller has actually talked to it. -void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } - -// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. -uint16_t poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); - RegisterValues response; - door.on_read_holding_registers(STATE_REG, 8, response); - EXPECT_EQ(response.size(), 8u); - return response.size() == 8u ? response[2] : 0xFFFF; -} - -// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. -class TestableHoermannHcp : public HoermannHcp { - public: - TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } - - using HoermannHcp::connection_timeout_ms_; - using HoermannHcp::set_valid_; -}; - -} // namespace +namespace esphome::hoermann_hcp::testing { // An empty poll (write 2 / read 2) answers with the fixed status word 0x0004. TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) { @@ -91,7 +49,7 @@ TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) { // A queued control command is injected into the next command poll as a simulated key press. TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) { HoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); RegisterValues response; @@ -113,31 +71,31 @@ TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) { // A command is held for the key-press duration, then released, and only then can the next one be queued. TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed // Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120. door.close_door(); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // With the command gone, the next one is accepted again. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed } // Commands issued while the bus controller is absent are dropped instead of firing when it returns. TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) { HoermannHcp door; door.open_door(); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // Losing the controller must drop a command it never fetched, otherwise it blocks every later command // and fires unasked once the bus comes back. TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); ASSERT_TRUE(door.is_valid()); @@ -145,10 +103,10 @@ TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { EXPECT_FALSE(door.is_valid()); // The reconnecting poll must not replay the dropped command. - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // And the slot is free, so a new command is accepted. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); + EXPECT_EQ(poll_command(door).first, 0x0220); } // The connection is dropped by update() once the controller stops polling, which is what releases a @@ -157,7 +115,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { TestableHoermannHcp door; // Wide enough that a stall cannot expire the connection before the check below runs. door.connection_timeout_ms_ = 10000; - connect(door); + connect_controller(door); door.open_door(); // Still inside the window: the controller counts as present. @@ -170,7 +128,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { door.update(); EXPECT_FALSE(door.is_valid()); // The pending command went with the connection instead of firing on the reconnecting poll. - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // Status broadcasts alone keep the connection alive, so a command the controller never fetches has to @@ -178,7 +136,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { TestableHoermannHcp door; door.connection_timeout_ms_ = 200; - connect(door); + connect_controller(door); door.open_door(); std::this_thread::sleep_for(std::chrono::milliseconds(220)); @@ -189,7 +147,7 @@ TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { // With the stale command gone, the door accepts commands again. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); + EXPECT_EQ(poll_command(door).first, 0x0220); } // The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed @@ -272,7 +230,7 @@ TEST(HoermannHcpWrite, EndStopsReportExactPositions) { // A position request below the lower snap threshold becomes a plain close command. TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { HoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.02f); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); @@ -283,7 +241,7 @@ TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { // A half-open target starts the door moving towards the requested position. TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { HoermannHcp door; // starts out fully closed - connect(door); + connect_controller(door); door.set_position(0.5f); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); @@ -294,31 +252,31 @@ TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { // The door has no notion of a target, so it is stopped with an impulse once it travels past the request. TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // Position 20/200 = 0.1 while opening: short of the target, so the door keeps going. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); ASSERT_EQ(door.get_door_state(), DoorState::OPENING); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Position 120/200 = 0.6 is past the target, so the door is stopped. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once // must be read as "already stopped" rather than "still opening". TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); ASSERT_EQ(door.get_door_state(), DoorState::OPENING); @@ -326,17 +284,17 @@ TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { // Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000})); ASSERT_EQ(door.get_door_state(), DoorState::OPEN); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // A target the door never reaches is dropped once it comes to rest, so a later move is not cut short. TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); // The door is stopped at 0.3 by a wall button, short of the requested 0.5. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); @@ -346,48 +304,48 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { // A later manual open must run freely instead of being stopped at the abandoned target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // A target armed while the door is still travelling the other way must not be judged by that old direction, // otherwise the very next position it reports counts as reached and stops the door where it stands. TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) { TestableHoermannHcp door; - connect(door); + connect_controller(door); // The door is closing, passing 60/200 = 0.3. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // Still closing at 58/200 = 0.29: below the target, but not on the way to it. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Now opening at 62/200 = 0.31, still short of the target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Past the target at 110/200 = 0.55, so the door is stopped. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // A motor turning around can report a momentary stop; dropping the target there would let the door run on // to the end stop that the reversing command asked for. TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); // The stop reported on the way from closing to opening. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); @@ -395,23 +353,23 @@ TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { // The door then opens and still has to be stopped at the requested position. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); + EXPECT_EQ(poll_command(door).first, 0x0240); } // A door that never turns around has to lose the target as well, otherwise it would cut a later move short. TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { TestableHoermannHcp door; door.connection_timeout_ms_ = 200; - connect(door); + connect_controller(door); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); std::this_thread::sleep_for(std::chrono::milliseconds(220)); // The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the @@ -424,7 +382,7 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { // A later manual open must run freely instead of being stopped at the abandoned target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp new file mode 100644 index 0000000000..ed7e81b279 --- /dev/null +++ b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp @@ -0,0 +1,761 @@ +#include + +#include +#include + +#include "esphome/components/hoermann_hcp/light/hoermann_hcp_light.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// Counts how often the platform is asked to write, so a publish that re-triggers itself becomes visible. +class CountingHoermannHcpLight : public HoermannHcpLight { + public: + using HoermannHcpLight::HoermannHcpLight; + + void write_state(light::LightState *state) override { + this->writes++; + HoermannHcpLight::write_state(state); + } + + int writes{0}; +}; + +// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences. +struct LightFixture { + TestableHoermannHcp door; + CountingHoermannHcpLight output{&door}; + light::LightState state{&output}; + + explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) { + this->state.set_restore_mode(restore_mode); + this->output.setup(); + // setup() queues the restored state for write_state(); the first settle() below delivers it, which is the + // boot ordering tests need to be able to place around the bus controller coming up. + this->state.setup(); + } + + // Brings the bus controller up and lets the platform read the lamp once, which is what a device does before + // any user command can arrive. + void bring_up() { + connect_controller(this->door); + this->report_lamp(false); + } + + // Issues a command the way Home Assistant would, then lets the state machine settle. + void command(bool on) { + auto call = this->state.make_call(); + call.set_state(on); + call.perform(); + this->settle(); + } + + // Delivers a status broadcast and runs the hub's notification pass. + void report_broadcast(const RegisterValues ®isters) { + this->door.on_write_registers(BROADCAST_REG, registers); + this->pump(); + } + + void report_lamp(bool on) { this->report_broadcast(lamp_broadcast(on ? 0x0010 : 0x0000)); } + + // Runs the hub's notification pass and lets the resulting publishes settle. + void pump() { + this->door.update(); + this->settle(); + } + + void settle() { + for (int i = 0; i < 4; i++) + this->state.loop(); + } + + bool entity_on() { return this->state.remote_values.is_on(); } +}; + +} // namespace + +// The lamp state lives in the low byte of register 6; only 0x14 and 0x10 mean lit. +TEST(HoermannHcpLightTest, LampStateIsDecodedFromTheBroadcast) { + HoermannHcp door; + EXPECT_FALSE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0014)); + EXPECT_TRUE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + EXPECT_FALSE(door.is_light_on()); +} + +// The lamp command is the only one that drives the second command register, on both halves of the press. +TEST(HoermannHcpLightTest, LampCommandUsesTheSecondRegister) { + TestableHoermannHcp door; + connect_controller(door); + ASSERT_FALSE(door.is_light_on()); + ASSERT_TRUE(door.toggle_light()); + + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + auto [released, released_2] = poll_command(door); + EXPECT_EQ(released, 0x0800); + EXPECT_EQ(released_2, 0x0200); + + // The command is spent, so the next poll carries nothing. + auto [idle, idle_2] = poll_command(door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// Toggling the lamp must not disturb a cover position the door is still travelling to. +TEST(HoermannHcpLightTest, LampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + + // Past the target: the door still has to be stopped despite the lamp command in between. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp toggle occupies the single command slot, so a target stop falling due while it waits to be fetched +// has to wait too. The target stays armed and the stop goes out on the next position report, which costs the +// door a little overshoot but never loses the stop. +TEST(HoermannHcpLightTest, LampToggleDelaysButDoesNotLoseTheTargetStop) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + // The door passes the target while the lamp toggle still holds the slot, so the lamp goes out first. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); + + // The target survived the refusal, so the next position report still stops the door. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0079, 0x0100})); + auto [stop, stop_2] = poll_command(door); + EXPECT_EQ(stop, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(stop_2, 0x0000); +} + +// The target's start deadline is its own, so toggling the lamp cannot keep a stale target alive. +TEST(HoermannHcpLightTest, LampToggleDoesNotExtendTheTargetWatchdog) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // The door is closing, so an opening target is armed but not yet under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + door.update(); + + // The target expired on its own schedule, so a later opening move runs freely. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Without a bus controller the command cannot be delivered, and the caller is told. +TEST(HoermannHcpLightTest, LampCommandIsRefusedWhileDisconnected) { + HoermannHcp door; + EXPECT_FALSE(door.toggle_light()); +} + +// Switching the entity on sends one toggle, and the door's own report does not send a second. +TEST(HoermannHcpLightPlatformTest, CommandTogglesOnceAndSettles) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, clearing the slot + + // The lamp is now on, and the resulting broadcast must not queue another toggle. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A broadcast arriving while a toggle is queued must not reconcile against the not-yet-inverted lamp, which +// would cancel the user's own command. +TEST(HoermannHcpLightPlatformTest, BroadcastDuringPendingToggleKeepsTheCommand) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + // A door movement sets changed_, firing the state callback while the toggle is still queued. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp switched on at the door itself has to reach the entity. +TEST(HoermannHcpLightPlatformTest, DoorDrivenChangeReachesTheEntity) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_FALSE(fixture.entity_on()); + + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refused command must leave the entity showing the lamp, not the request. +TEST(HoermannHcpLightPlatformTest, RefusedCommandRepublishesTheLamp) { + LightFixture fixture; // never connected, so the hub refuses every command + + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press once the toggle is already on the wire cannot stop it, so the entity has to end up +// showing the lamp rather than the request that was refused. +TEST(HoermannHcpLightPlatformTest, RefusedPressAfterFetchShowsWhereTheLampIsHeading) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); // the controller fetches the press, so it can no longer be cancelled + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_TRUE(fixture.entity_on()); + + // A door movement while the refused toggle is still on the wire must not pull the entity back either. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_TRUE(fixture.entity_on()); + + // The toggle lands and the door confirms it; the entity must already agree. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// The lamp is only reported some time after the key press is released, so an unrelated door broadcast in +// that gap must not publish the state the lamp is about to leave. +TEST(HoermannHcpLightPlatformTest, DoorMovementDoesNotFlipTheEntityBeforeTheLampReports) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, so nothing is pending any more + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); // the lamp has still not been reported + + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.entity_on()); +} + +// A toggle the controller never fetches is eventually dropped, and nothing else will ever report the lamp +// moving, so the entity has to be brought back to what the lamp actually is. +TEST(HoermannHcpLightPlatformTest, DroppedToggleReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + // The controller keeps broadcasting but never fetches the command, so the connection stays up. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + fixture.pump(); + + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); +} + +// Losing the bus controller discards the queued toggle too, so the entity must not keep showing it once the +// controller is back and still reporting the lamp unchanged. +TEST(HoermannHcpLightPlatformTest, ToggleLostWithTheConnectionReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); // the connection times out and the command goes with it + ASSERT_FALSE(fixture.door.is_valid()); + + connect_controller(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// The lamp can be switched at the door while the bus is quiet, so what was read before an outage must not +// decide whether a toggle is needed after it. +TEST(HoermannHcpLightPlatformTest, LampIsNotTrustedAcrossAConnectionLoss) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + fixture.report_lamp(true); + ASSERT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Back on the bus, but nothing has said what the lamp is doing yet. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(false); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A door that never reports the lamp leaves the entity unable to do anything, so it must not look healthy. +TEST(HoermannHcpLightPlatformTest, UnreportedLampIsFlaggedOnTheEntity) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + EXPECT_TRUE(fixture.output.status_has_warning()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.output.status_has_warning()); +} + +// Two outstanding toggles leave the lamp where it started, so a third tap has to be judged against that and +// withdraw the one still waiting rather than deciding nothing is needed. +TEST(HoermannHcpLightPlatformTest, ThirdTapWithTwoTogglesOutstandingIsHonoured) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // the first toggle is released but not reported back + fixture.command(false); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + + // Two toggles cancel out, so asking for on again means withdrawing the second one. + fixture.command(true); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 1); + EXPECT_TRUE(fixture.entity_on()); +} + +// The boot replay is the first write and nothing else, so a real command arriving before the hub's next poll +// must not be mistaken for it and swallowed. +TEST(HoermannHcpLightPlatformTest, CommandBeforeTheFirstPollIsNotMistakenForTheBootReplay) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); // the boot replay lands here, while the lamp is still unknown + + // The first status broadcast arrives, but the hub has not polled yet, so no callback has fired. + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// On boot the restored state is replayed through write_state() before the lamp has ever been read. A lamp +// that is already on must not be switched off by that replay. +TEST(HoermannHcpLightPlatformTest, RestoredStateOnBootDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller is already up and reporting the lamp lit before the entity's first loop. + connect_controller(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_TRUE(fixture.door.is_light_on()); + + fixture.settle(); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + // Once the platform has read the lamp the entity follows it, still without commanding anything. + fixture.pump(); + EXPECT_TRUE(fixture.entity_on()); +} + +// Bus traffic makes the connection valid without saying anything about the lamp, so a request arriving before +// the first status broadcast must not be judged against a lamp state that was never read. +TEST(HoermannHcpLightPlatformTest, RequestBeforeTheLampIsReportedDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller polls for commands, which is enough to connect but carries no lamp register. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(true); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A toggle that has been released onto the wire is no longer pending, but the lamp has not reported it yet. +// A reversing request in that window is a real request and has to be sent, not swallowed. +TEST(HoermannHcpLightPlatformTest, ReversingRequestAfterReleaseQueuesASecondToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // released, so nothing is pending and the lamp is still unreported + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); + + fixture.command(false); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); + EXPECT_FALSE(fixture.entity_on()); + + // The first toggle lands and is reported, but the entity is already heading for off. + fixture.report_lamp(true); + EXPECT_FALSE(fixture.entity_on()); + + // The second toggle lands too, and the lamp finally agrees with the request. + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refusal that has no toggle on the wire leaves nothing outstanding, so it must not latch the entity +// against the next lamp change the door reports. +TEST(HoermannHcpLightPlatformTest, RefusalWithoutAToggleStillFollowsTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Refused because the bus is down, so no toggle is heading for the lamp. + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); + + // The controller returns and reports the lamp switched on at the door itself. + connect_controller(fixture.door); + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp toggle carries no target, so dropping it unfetched must leave the cover's target alone. +TEST(HoermannHcpLightTest, DroppedLampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + // The controller keeps broadcasting but stops fetching, so the lamp toggle expires on its own. + ASSERT_TRUE(door.toggle_light()); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.update(); + + // The target survived the lamp toggle being dropped, so the door is still stopped on the way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A door that takes the key press but never actually switches the lamp must not leave the entity showing the +// request for ever; the wait has to end so the entity can settle back on what the door reports. +TEST(HoermannHcpLightPlatformTest, ToggleTheDoorIgnoresStopsBeingWaitedFor) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the door takes press and release, then does nothing + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); // the lamp is still off, and keeps saying so + EXPECT_FALSE(fixture.entity_on()); +} + +// A resting door's first broadcast changes nothing except the lamp finally being reported, so unless that +// counts as a change the light never hears about it and swallows the first command. +TEST(HoermannHcpLightPlatformTest, FirstLampReportReachesTheEntity) { + LightFixture fixture; + // A command poll connects the controller without saying anything about the lamp. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // Closed, at rest, lamp off: every field matches the defaults the hub started with. + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000})); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// A lost connection means the door can travel unwatched, so a target left armed would stop it long afterwards. +// Which command happened to be in the slot must not change that. +TEST(HoermannHcpLightTest, ConnectionLossWithALampTogglePendingClearsTheTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + ASSERT_TRUE(door.toggle_light()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + ASSERT_FALSE(door.is_valid()); + + // Back on the bus and travelling past where the target was: nothing should stop the door now. + connect_controller(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Withdrawing a later toggle must not take the deadline of the one already on the wire with it, or a door +// that never reports the lamp would leave the entity waiting for ever. +TEST(HoermannHcpLightPlatformTest, WithdrawingALaterToggleKeepsTheWatchdogArmed) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the first toggle is released but never reported back + fixture.command(false); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + fixture.command(true); // withdraws the second, leaving the first outstanding + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 1); + + // The door still says nothing about the lamp, so the wait has to time out on its own. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_FALSE(fixture.entity_on()); +} + +// A request refused while the lamp is unknown must leave the entity idle. Republishing unconditionally would +// re-enter write_state() on every loop, so the platform would never stop asking to be written. +TEST(HoermannHcpLightPlatformTest, RefusedRequestLeavesTheEntityIdle) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // The lamp is unknown and the entity already shows off, so asking for off cannot be serviced or displayed. + fixture.command(false); + const int settled_writes = fixture.output.writes; + fixture.settle(); + EXPECT_EQ(fixture.output.writes, settled_writes); +} + +// A door that acts on the key press and reports the lamp before the release is even fetched leaves nothing +// outstanding. Arming the watchdog on that release anyway would leave it firing on every poll and abandoning +// the next toggle the moment it is queued. +TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarmed) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + poll_command(door); // the door is shown the key press + + // The door acts on it and reports the lamp straight away, which settles the count. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_EQ(door.light_toggles_in_flight_, 0); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); // the release, with nothing left to wait for + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be +// adopted back to what is known rather than turned into a command. +TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) { + LightFixture fixture{light::LIGHT_ALWAYS_ON}; + connect_controller(fixture.door); + fixture.settle(); + + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press before the toggle is fetched cancels it, so the lamp never moves. +TEST(HoermannHcpLightPlatformTest, ReversingPressCancelsTheQueuedToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); + + // Nothing is left for the controller to fetch, so the lamp stays off as asked. + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp switched at the door itself is not one of our toggles landing, so a toggle the door has not even +// been shown has to keep counting. +TEST(HoermannHcpLightTest, DoorSideLampChangeLeavesAnUnsentToggleCounted) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + // The toggle still in the slot will invert what the door just reported. + EXPECT_FALSE(door.is_light_heading_on()); +} + +// Once the toggles left over are all still waiting in the slot, nothing the door has seen is outstanding, +// so the wait has to end rather than time out against toggles the door was never shown. +TEST(HoermannHcpLightTest, SettlingTheLastSentToggleEndsTheWait) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, so the wait for a lamp report starts + ASSERT_TRUE(door.toggle_light()); // queued behind it, never shown + ASSERT_NE(door.light_toggle_released_at_, 0u); + + // The door reports the lamp change the first toggle caused, leaving only the unsent one. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + ASSERT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// The watchdog gives up on the toggles the door was shown, but one still waiting in the command slot is +// going to fire, so it keeps counting. +TEST(HoermannHcpLightTest, WatchdogKeepsAToggleTheDoorHasNotSeen) { + TestableHoermannHcp door; + // Wide enough that the toggle queued after the sleep cannot expire before update() runs. + door.connection_timeout_ms_ = 200; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, which then says nothing about the lamp + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // Queued just now, so only the wait for the first toggle is overdue. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + door.update(); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_TRUE(door.is_light_toggle_pending_()); + EXPECT_TRUE(door.is_light_heading_on()); +} + +// Only the parity of the outstanding count says where the lamp is heading, so the count must not run away. +TEST(HoermannHcpLightTest, TogglesAreRefusedOnceTooManyAreOutstanding) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + + // The door takes every key press but never reports the lamp, so nothing is ever confirmed. + for (int i = 0; i < 4; i++) { + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + } + + EXPECT_FALSE(door.toggle_light()); + EXPECT_EQ(door.light_toggles_in_flight_, 4); +} + +// A controller that stops carrying the lamp register leaves nothing refreshing it, so the entity has to flag +// itself rather than command against what was read before. +TEST(HoermannHcpLightPlatformTest, BroadcastWithoutTheLampRegisterMarksItUnknown) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000})); + + EXPECT_FALSE(fixture.door.is_light_known()); + EXPECT_TRUE(fixture.output.status_has_warning()); +} + +// A publish of ours only reaches write_state() a loop pass later. If the lamp changed at the door in that +// gap, the write still carries the old value and must not be taken for a request to invert the lamp. +TEST(HoermannHcpLightPlatformTest, PublishOvertakenByTheLampIsNotARequest) { + LightFixture fixture; + fixture.bring_up(); + // A door command holds the only command slot, so the request below is refused and the lamp published back. + ASSERT_TRUE(fixture.door.open_door()); + + auto call = fixture.state.make_call(); + call.set_state(true); + call.perform(); + fixture.state.loop(); // the refusal happens here and schedules the publish for a later pass + + // The slot frees up and the lamp is switched on at the door before that publish arrives. + consume_command(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + fixture.settle(); + + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_TRUE(fixture.entity_on()); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py index 95f6a0321e..7596983ee2 100644 --- a/tests/integration/entity_utils.py +++ b/tests/integration/entity_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case if TYPE_CHECKING: from aioesphomeapi import DeviceInfo, EntityInfo @@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: return device_info.name.endswith(f"-{mac_suffix}") -def _resolve_entity_name( +def _get_name_for_object_id( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> str: - """Resolve the effective name for an entity. + """Get the name used for object_id computation. This is the algorithm that aioesphomeapi will use to determine which - name to use for computing object_id client-side from API data; the same - name is what the device hashes into the entity key. + name to use for computing object_id client-side from API data. Args: entity: The entity to get name for @@ -73,27 +72,27 @@ def compute_entity_object_id( Returns: The computed object_id string """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return compute_object_id(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return compute_object_id(name_for_id) -def compute_entity_key( +def compute_entity_hash( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> int: - """Compute expected entity key for an entity. + """Compute expected object_id hash for an entity. Args: - entity: The entity to compute the key for + entity: The entity to compute hash for device_info: Device info from the API device_id_to_name: Mapping of device_id to device name for sub-devices Returns: - The computed FNV-1 hash of the raw name + The computed FNV-1 hash """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return fnv1_hash_name(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return fnv1_hash_object_id(name_for_id) def verify_entity_object_id( @@ -119,7 +118,7 @@ def verify_entity_object_id( f"expected '{expected_object_id}', got '{entity.object_id}'" ) - expected_hash = compute_entity_key(entity, device_info, device_id_to_name) + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) assert entity.key == expected_hash, ( f"hash mismatch for entity '{entity.name}': " f"expected {expected_hash:#x}, got {entity.key:#x}" diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index d4511bb8c6..2097b2fbf9 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -71,38 +71,6 @@ esphome: ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); } - // Raw name hash: matches Python fnv1_hash_name("My Sensor Name") - uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14); - if (hash_raw == 0x8cec6fb0) { - ESP_LOGI("FNV1_OID", "raw PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw); - } - - // Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température") - uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12); - if (hash_raw_utf8 == 0x531a74aa) { - ESP_LOGI("FNV1_OID", "raw_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8); - } - - // Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température") - uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true); - if (hash_old_utf8 == 0x965698f3) { - ESP_LOGI("FNV1_OID", "old_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8); - } - - // Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度") - uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true); - if (hash_old_cjk == 0x3276cb9f) { - ESP_LOGI("FNV1_OID", "old_cjk PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk); - } - host: api: logger: diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 582add90a8..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -156,17 +156,10 @@ button: ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); - // Log preference key bases for entities that actually store preferences. - // This is the key base make_entity_preference() uses: entity key XOR device id. - ESP_LOGI("test", "Device A Switch Pref Hash: %u", - id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Switch Pref Hash: %u", - id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Switch Pref Hash: %u", - id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero()); - ESP_LOGI("test", "Device A Number Pref Hash: %u", - id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Number Pref Hash: %u", - id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Number Pref Hash: %u", - id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/fixtures/preference_key_migration.yaml b/tests/integration/fixtures/preference_key_stability.yaml similarity index 94% rename from tests/integration/fixtures/preference_key_migration.yaml rename to tests/integration/fixtures/preference_key_stability.yaml index a9b01fc2d2..a74bb2c7f7 100644 --- a/tests/integration/fixtures/preference_key_migration.yaml +++ b/tests/integration/fixtures/preference_key_stability.yaml @@ -1,5 +1,5 @@ esphome: - name: host-pref-key-migration + name: host-pref-key-stability host: api: diff --git a/tests/integration/test_fnv1_hash_object_id.py b/tests/integration/test_fnv1_hash_object_id.py index 0c2848a20c..23e8ca04c2 100644 --- a/tests/integration/test_fnv1_hash_object_id.py +++ b/tests/integration/test_fnv1_hash_object_id.py @@ -37,10 +37,6 @@ async def test_fnv1_hash_object_id( "special", "complex", "empty", - "raw", - "raw_utf8", - "old_utf8", - "old_cjk", } def on_log_line(line: str) -> None: diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index 8dafb37c64..c8603e0682 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -2,8 +2,8 @@ This test verifies a three-way match between: 1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) -2. C++ entity key generation (fnv1_hash of the raw name in helpers.h) -3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py) +2. C++ hash generation (fnv1_hash_object_id in helpers.h) +3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) The API response contains C++ computed values, so verifying API == Python implicitly verifies C++ == Python == API for both object_id and hash. @@ -25,7 +25,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -123,7 +123,7 @@ async def test_object_id_api_verification( ) # Verify hash can be computed from the name - hash_from_name = fnv1_hash_name(entity_name) + hash_from_name = fnv1_hash_object_id(entity_name) assert hash_from_name == entity.key, ( f"Entity '{entity_name}': hash mismatch. " f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" @@ -164,7 +164,7 @@ async def test_object_id_api_verification( ) # Verify hash matches - expected_hash = fnv1_hash_name(expected_name) + expected_hash = fnv1_hash_object_id(expected_name) assert entity.key == expected_hash, ( f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " f"API key: {entity.key:#x}, expected: {expected_hash:#x}" diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index b58593f2ef..7199a2b371 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import ( compute_object_id, @@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix( ) # Hash should match friendly_name - expected_hash = fnv1_hash_name("My Friendly Device") + expected_hash = fnv1_hash_object_id("My Friendly Device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 45b5f730a6..b548f02fde 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( OLD behavior: - is_object_id_dynamic_() returned false (mac suffix not enabled) - Used object_id_c_str_ which was pre-computed in Python - - Python used get_base_entity_name() with fallback to CORE.name + - Python used get_base_entity_object_id() with fallback to CORE.name Result: object_id = sanitize(snake_case(device_name)) """ @@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( ) # Hash should match device name - expected_hash = fnv1_hash_name("test-device") + expected_hash = fnv1_hash_object_id("test-device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_preference_key_migration.py b/tests/integration/test_preference_key_stability.py similarity index 57% rename from tests/integration/test_preference_key_migration.py rename to tests/integration/test_preference_key_stability.py index e7f699bb12..5704075746 100644 --- a/tests/integration/test_preference_key_migration.py +++ b/tests/integration/test_preference_key_stability.py @@ -1,14 +1,14 @@ -"""Integration test for entity preference key migration. +"""Integration test for entity preference key stability. -Entity keys are now the FNV-1 hash of the raw name instead of the sanitized -object_id (https://github.com/esphome/backlog/issues/85). On key-lookup -preference backends, make_entity_preference() must move data stored under the -old key to the new key, so devices keep their restored state after upgrading. +Entity preferences are stored under keys derived from the sanitized object_id +hash. This test seeds the host preferences file the way existing firmware +wrote it and verifies the state is restored, proving the key scheme has not +drifted; a save and reload round trip cannot catch drift because it writes +and reads with the same code. -This test seeds the host preferences file the way a pre-migration firmware -would have written it and verifies: -1. Data stored under the OLD key is restored (migration happened, no data loss) -2. Data already stored under the NEW key is never overwritten by old data +The second run also seeds the raw-name-hash entries a 2026.8 beta device left +behind (see https://github.com/esphome/esphome/pull/18361) and proves they are +ignored: the object_id entries win and the beta leftovers are inert. """ from __future__ import annotations @@ -33,22 +33,23 @@ from .host_prefs import clear_host_prefs, write_host_prefs from .state_utils import InitialStateHelper, require_entity from .types import CompileFunction, ConfigWriter -DEVICE_NAME = "host-pref-key-migration" +DEVICE_NAME = "host-pref-key-stability" -# The pre-migration preference key was the sanitized object_id hash; the new -# key is the raw-name hash. All entities are on the main device (device_id 0) -# and their preferences use no version salt, so the key is just the hash. -SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch") -SWITCH_NEW_KEY = fnv1_hash_name("Test Switch") -NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number") -NUMBER_NEW_KEY = fnv1_hash_name("Test Number") +# All entities are on the main device (device_id 0) and their preferences use +# no version salt, so the key is just the object_id hash. +SWITCH_KEY = fnv1_hash_object_id("Test Switch") +NUMBER_KEY = fnv1_hash_object_id("Test Number") + +# Raw-name-hash keys as written by 2026.8 beta firmware; never read by this build +SWITCH_BETA_KEY = fnv1_hash_name("Test Switch") +NUMBER_BETA_KEY = fnv1_hash_name("Test Number") # template_text salts its key with the length limits and pattern hash; this must # match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20, # no pattern configured) TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6) -TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF -TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF +TEXT_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF +TEXT_BETA_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF # TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes TEXT_MAX_LENGTH = 20 @@ -62,18 +63,18 @@ def text_pref_payload(value: str) -> bytes: @pytest.mark.asyncio -async def test_preference_key_migration( +async def test_preference_key_stability( yaml_config: str, write_yaml_config: ConfigWriter, compile_esphome: CompileFunction, reserved_tcp_port: tuple[int, socket.socket], ) -> None: - """Test that preferences stored under the old key survive the upgrade.""" + """Test that preferences stored by earlier firmware are restored.""" port, port_socket = reserved_tcp_port - assert SWITCH_OLD_KEY != SWITCH_NEW_KEY - assert NUMBER_OLD_KEY != NUMBER_NEW_KEY - assert TEXT_OLD_KEY != TEXT_NEW_KEY + assert SWITCH_KEY != SWITCH_BETA_KEY + assert NUMBER_KEY != NUMBER_BETA_KEY + assert TEXT_KEY != TEXT_BETA_KEY # Write and compile once config_path = await write_yaml_config(yaml_config) @@ -117,49 +118,51 @@ async def test_preference_key_migration( return switch_state, number_state, text_state try: - # --- Run 1: only OLD keys present, as written by pre-migration firmware. - # The restored states prove the data was migrated to the new keys. + # --- Run 1: entries under the object_id-hash keys, exactly as any + # earlier firmware wrote them. The restored states prove the key + # scheme has not drifted. write_host_prefs( DEVICE_NAME, { - SWITCH_OLD_KEY: b"\x01", # bool: switch was ON - NUMBER_OLD_KEY: struct.pack(" None: + [files] = list(bsec.PREFETCH_FILES([{"model": "bme680"}])) + assert len(files) == 1 + assert "bme680_iaq_33v_3s_28d" in files[0].url + assert files[0].path == bsec._compute_local_file_path(files[0].url) + + +def test_prefetch_normalizes_enum_case(setup_core: Path) -> None: + [files] = list( + bsec.PREFETCH_FILES( + [ + { + "model": "BME688", + "sample_rate": "ulp", + "supply_voltage": "1.8v", + "algorithm_output": "REGRESSION", + "operating_age": "4D", + } + ] + ) + ) + assert len(files) == 1 + assert "bme688_reg_18v_300s_4d" in files[0].url + + +def test_prefetch_skips_unknown_values(setup_core: Path) -> None: + entries = [ + {"model": "bme999"}, + {"model": "bme680", "sample_rate": "TURBO"}, + {"model": "bme680", "algorithm_output": "psychic"}, + {}, + ] + assert list(bsec.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_matches_validator_url(setup_core: Path) -> None: + """The hook's URL equals _compute_url over the validated config shape.""" + validated = { + "model": "bme688", + "operating_age": "28d", + "sample_rate": "LP", + "supply_voltage": "3.3V", + "algorithm_output": "classification", + } + [files] = list(bsec.PREFETCH_FILES([dict(validated)])) + assert files[0].url == bsec._compute_url(validated) + + +def test_hook_is_wired_to_the_user_facing_domain() -> None: + """The i2c domain (the only user-facing one) exposes the hook.""" + + component = get_component("bme68x_bsec2_i2c") + assert component is not None + assert component.prefetch_files is bsec.PREFETCH_FILES diff --git a/tests/unit_tests/components/file/__init__.py b/tests/unit_tests/components/file/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py new file mode 100644 index 0000000000..a9c1684db3 --- /dev/null +++ b/tests/unit_tests/components/file/test_image.py @@ -0,0 +1,75 @@ +"""Tests for the file image platform's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from esphome.components.file import image as file_image +from esphome.external_files import RemoteFile +from esphome.loader import get_component, get_platform + + +def test_extract_mdi_shorthand(setup_core: Path) -> None: + ref = file_image._extract_file_ref("mdi:home") + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdi"] + "home.svg" + assert ref.path.name == "home.svg" + assert ref.path.parent.name == "mdi" + + +def test_extract_web_url(setup_core: Path) -> None: + url = "https://example.com/img.png" + ref = file_image._extract_file_ref(url) + assert ref == RemoteFile(url, file_image.compute_local_image_path(url)) + + +def test_extract_typed_dicts(setup_core: Path) -> None: + url = "https://example.com/img.png" + assert file_image._extract_file_ref({"source": "web", "url": url}) == RemoteFile( + url, file_image.compute_local_image_path(url) + ) + ref = file_image._extract_file_ref({"source": "mdil", "icon": "home"}) + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdil"] + "home.svg" + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert file_image._extract_file_ref("images/local.png") is None + assert file_image._extract_file_ref("mdi:not a valid icon!") is None + assert file_image._extract_file_ref({"source": "local", "path": "x.png"}) is None + assert file_image._extract_file_ref(42) is None + assert file_image._extract_file_ref(None) is None + + +def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: + entries = [ + {"file": "mdi:home"}, + {"file": "images/local.png"}, + {"file": "https://example.com/img.png"}, + {"no_file_key": True}, + ] + [files] = list(file_image.PREFETCH_FILES(entries)) + assert len(files) == 2 + assert files[0].url.endswith("home.svg") + assert files[1].url == "https://example.com/img.png" + + +def test_extractor_matches_validator_path(setup_core: Path) -> None: + """The path the validator downloads to equals the extractor's path.""" + with patch( + "esphome.components.file.image.external_files.download_content" + ) as mock_download: + file_image.validate_file_shorthand("mdi:home") + + validated_path = mock_download.call_args[0][1] + assert validated_path == file_image._extract_file_ref("mdi:home").path + + +def test_hook_is_wired_to_both_animation_domains() -> None: + """Both animation entry points expose the shared image hook.""" + + assert get_component("animation").prefetch_files is file_image.PREFETCH_FILES + assert ( + get_platform("image", "animation").prefetch_files is file_image.PREFETCH_FILES + ) diff --git a/tests/unit_tests/components/font/__init__.py b/tests/unit_tests/components/font/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/font/test_init.py b/tests/unit_tests/components/font/test_init.py new file mode 100644 index 0000000000..0ea3a0e3a1 --- /dev/null +++ b/tests/unit_tests/components/font/test_init.py @@ -0,0 +1,229 @@ +"""Tests for the font component's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import external_files +from esphome.components import font +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _gspec(family: str, weight: int = 400, italic: bool = False) -> dict: + return {"family": family, "weight": weight, "italic": italic} + + +def test_extract_gfonts_shorthand_defaults(setup_core: Path) -> None: + spec = font._extract_remote_font("gfonts://Roboto") + assert spec is not None + assert spec[font.CONF_FAMILY] == "Roboto" + assert spec[font.CONF_WEIGHT] == 400 + assert spec[font.CONF_ITALIC] is False + + +def test_extract_gfonts_shorthand_weight_variants(setup_core: Path) -> None: + assert font._extract_remote_font("gfonts://Roboto@bold")[font.CONF_WEIGHT] == 700 + assert font._extract_remote_font("gfonts://Roboto@500")[font.CONF_WEIGHT] == 500 + + +def test_extract_gfonts_normalizes_quoted_italic(setup_core: Path) -> None: + """Boolean spellings the schema accepts are accepted by the extractor.""" + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "true"} + ) + assert spec is not None + assert spec[font.CONF_ITALIC] is True + assert ( + font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "maybe"} + ) + is None + ) + + +def test_extract_typed_gfonts_dict(setup_core: Path) -> None: + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "weight": "medium", "italic": True} + ) + assert spec is not None + assert spec[font.CONF_WEIGHT] == 500 + assert spec[font.CONF_ITALIC] is True + + +def test_extract_web_font(setup_core: Path) -> None: + url = "https://example.com/font.ttf" + for value in (url, {"type": "web", "url": url}): + spec = font._extract_remote_font(value) + assert spec is not None + assert spec[font.CONF_URL] == url + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert font._extract_remote_font("fonts/local.ttf") is None + assert font._extract_remote_font({"type": "local", "path": "x.ttf"}) is None + assert ( + font._extract_remote_font({"type": "gfonts", "family": "R", "weight": "no"}) + is None + ) + assert font._extract_remote_font(42) is None + + +def test_prefetch_yields_css_for_stale_gfont(setup_core: Path) -> None: + entries = [ + {"file": "gfonts://Roboto"}, + {"file": "fonts/local.ttf"}, + { + "file": "https://example.com/font.ttf", + "extras": [{"file": "gfonts://Monocraft"}], + }, + ] + batches = list(font.PREFETCH_FILES(entries)) + urls = [file.url for file in batches[0]] + assert font._gfonts_css_url(_gspec("Roboto")) in urls + assert font._gfonts_css_url(_gspec("Monocraft")) in urls + assert "https://example.com/font.ttf" in urls + assert len(batches[0]) == 3 + + +def test_prefetch_skips_recent_ttf(setup_core: Path) -> None: + path = font._gfonts_ttf_path(_gspec("Roboto")) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"cached ttf") + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches == [[], []] + + +def test_stage2_parses_cached_css(setup_core: Path) -> None: + + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/roboto.ttf) format('truetype');" + ) + # Stage two only trusts CSS confirmed fetched this run. + external_files._run_data().fresh_paths.add(css_path) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [ + RemoteFile( + "https://fonts.gstatic.com/roboto.ttf", + font._gfonts_ttf_path(_gspec("Roboto")), + ) + ] + + +def test_stage2_skips_missing_css(setup_core: Path) -> None: + batches = list(font.PREFETCH_FILES([{"file": "gfonts://NoCss"}])) + assert batches[1] == [] + + +def test_prefetch_handles_bare_mapping_extras(setup_core: Path) -> None: + """A bare-mapping extras value (valid raw config) is scanned.""" + entries = [ + { + "file": "fonts/local.ttf", + "extras": {"file": "gfonts://Roboto", "glyphs": "ABC"}, + } + ] + batches = list(font.PREFETCH_FILES(entries)) + assert [file.url for file in batches[0]] == [font._gfonts_css_url(_gspec("Roboto"))] + + +def test_unparseable_gfonts_css_is_evicted(setup_core: Path) -> None: + """A CSS body that fails to parse is removed from the cache.""" + + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + css_path = font._gfonts_css_path(spec) + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"no truetype url here", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="please report this"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"\xff\xfe\x00\x01binary", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="not a text document"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + +def test_unrevalidated_gfonts_css_uses_cached_font(setup_core: Path) -> None: + """A CSS body that could not be revalidated is not parsed for a ttf + URL; the cached font is used instead.""" + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + ttf_path = font._gfonts_ttf_path(spec) + ttf_path.parent.mkdir(parents=True, exist_ok=True) + ttf_path.write_bytes(b"cached ttf") + cache = MagicMock() + with ( + patch.object(font, "FONT_CACHE", cache), + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + ): + assert font.download_gfont(spec) is spec + cache.__setitem__.assert_called_once_with(spec, ttf_path) + + +def test_unrevalidated_gfonts_css_without_cached_font_errors( + setup_core: Path, +) -> None: + """No verified CSS and no cached font is a clear error.""" + spec = { + "family": "Roboto", + "weight": 500, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + pytest.raises(cv.Invalid, match="no cached font"), + ): + font.download_gfont(spec) + + +def test_stage2_skips_css_not_fetched_this_run(setup_core: Path) -> None: + """A leftover CSS from an earlier run is not trusted for stage two.""" + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/rotated.ttf) format('truetype');" + ) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [] diff --git a/tests/unit_tests/components/gsl3670/__init__.py b/tests/unit_tests/components/gsl3670/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/gsl3670/test_touchscreen.py b/tests/unit_tests/components/gsl3670/test_touchscreen.py new file mode 100644 index 0000000000..a4b96d72da --- /dev/null +++ b/tests/unit_tests/components/gsl3670/test_touchscreen.py @@ -0,0 +1,35 @@ +"""Tests for the gsl3670 touchscreen prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components.gsl3670 import touchscreen as gsl +from esphome.external_files import RemoteFile + + +def test_prefetch_explicit_url(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"platform": "gsl3670", "firmware": {"url": url}}] + assert list(gsl.PREFETCH_FILES(entries)) == [ + [RemoteFile(url, gsl._cache_path(url))] + ] + + +def test_prefetch_model_default_firmware(setup_core: Path) -> None: + entries = [{"platform": "gsl3670", "model": "seeed-reterminal-d1001"}] + [files] = list(gsl.PREFETCH_FILES(entries)) + assert len(files) == 1 + assert ( + files[0].url == gsl.MODELS["SEEED-RETERMINAL-D1001"][gsl.CONF_FIRMWARE]["url"] + ) + assert files[0].path == gsl._cache_path(files[0].url) + + +def test_prefetch_skips_local_file_and_custom(setup_core: Path) -> None: + entries = [ + {"platform": "gsl3670", "firmware": {"file": "fw.bin"}}, + {"platform": "gsl3670", "model": "CUSTOM"}, + {"platform": "gsl3670"}, + ] + assert list(gsl.PREFETCH_FILES(entries)) == [[]] diff --git a/tests/unit_tests/components/micro_wake_word/test_init.py b/tests/unit_tests/components/micro_wake_word/test_init.py index 84371ab906..96fb73b18b 100644 --- a/tests/unit_tests/components/micro_wake_word/test_init.py +++ b/tests/unit_tests/components/micro_wake_word/test_init.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) +from esphome.external_files import RemoteFile @pytest.fixture @@ -114,12 +115,16 @@ def test_download_http_models_batches_manifests_then_models( 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") + RemoteFile( + 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") + RemoteFile( + f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite" + ) for name in names ] diff --git a/tests/unit_tests/components/mqtt/test_object_id_conflicts.py b/tests/unit_tests/components/mqtt/test_object_id_conflicts.py deleted file mode 100644 index 0ac61b4dad..0000000000 --- a/tests/unit_tests/components/mqtt/test_object_id_conflicts.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Tests for the MQTT object_id conflict filter. - -MQTT still builds default topics and discovery topics from the sanitized -object_id, so entity names that only differ in characters lost during -sanitizing conflict there; _topics_conflict() exempts entities that never -use an object_id-derived topic. See https://github.com/esphome/backlog/issues/85 -""" - -from pathlib import Path - -import pytest - -from esphome.components.mqtt import ( - _COMMAND_TOPIC_PLATFORMS, - _SUB_TOPIC_PLATFORMS, - _topics_conflict, -) -from esphome.config_validation import Invalid -from esphome.const import ( - CONF_COMMAND_TOPIC, - CONF_DISCOVERY, - CONF_NAME, - CONF_STATE_TOPIC, - CONF_TOPIC_PREFIX, -) -from esphome.core import CORE -from esphome.core.entity_helpers import ( - entity_duplicate_validator, - validate_no_object_id_conflicts, -) - -COMPONENTS_DIR = Path(__file__).parents[4] / "esphome" / "components" - -REASON = "mqtt builds default topics from the entity object_id" - - -# MQTT infrastructure sources, not entity components -_NON_ENTITY_MQTT_SOURCES = {"mqtt_client", "mqtt_component"} -# The date, time and datetime MQTT components all belong to the datetime platform -_DATETIME_STEMS = {"date", "time", "datetime"} - - -def test_command_topic_platforms_in_sync() -> None: - """Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe. - - Drift silently reintroduces shared subscribe topics, so this derives the set - from the C++ components that actually call subscribe(); that also catches - platforms like text that subscribe a command topic without exposing a - command_topic key in their schema. - """ - expected: set[str] = set() - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"): - if path.stem in _NON_ENTITY_MQTT_SOURCES: - continue - if "this->subscribe" not in path.read_text(encoding="utf-8"): - continue - stem = path.stem.removeprefix("mqtt_") - expected.add("datetime" if stem in _DATETIME_STEMS else stem) - assert expected == _COMMAND_TOPIC_PLATFORMS - - -def test_sub_topic_platforms_in_sync() -> None: - """Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics. - - Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra - topics such as position/command from the object_id. - """ - expected = { - path.stem.removeprefix("mqtt_") - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h") - if path.stem != "mqtt_component" - and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8") - } - assert expected == _SUB_TOPIC_PLATFORMS - - -def test_conflict_filter_exempts_custom_topics() -> None: - """Test that custom state topics with discovery off avoid the conflict.""" - validator = entity_duplicate_validator("sensor") - # Both entities have custom state topics and discovery disabled per entity, - # so no object_id-derived MQTT topic is used - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - # Without the filter the same conflicts are fatal - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - validate_no_object_id_conflicts(REASON)({}) - - -def test_conflict_on_default_command_topic() -> None: - """Test that commandable platforms conflict through their default command topic. - - Custom state topics with discovery off are not enough for platforms that also - subscribe to an object_id-derived command topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - # Both switches share the default command topic: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator(mqtt_config) - - # With custom command topics as well, nothing derives from the object_id - CORE.reset() - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - assert component_validator(mqtt_config) is mqtt_config - - -def test_conflict_on_sub_topic_platforms() -> None: - """Test that platforms with extra object_id sub-topics always conflict. - - Covers derive topics like position/command from the object_id through their - own config keys, so custom state and command topics cannot exempt them. - """ - validator = entity_duplicate_validator("cover") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}) - - -def test_no_conflict_on_disjoint_default_topics() -> None: - """Test that entities whose default topics are disjoint do not conflict. - - One entity uses only the default command topic and the other only the default - state topic, so they never share a topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - -def test_no_conflict_on_empty_topic_prefix() -> None: - """Test that an empty topic_prefix disables the default topic conflict. - - With topic_prefix set to null no default topics exist at runtime, so entities - without custom state topics cannot conflict; only discovery still matters. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - # No default topics and no discovery: valid - config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""} - assert component_validator(config) is config - - # Discovery still uses object_id-derived config topics: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""}) diff --git a/tests/unit_tests/components/shelly_dimmer/__init__.py b/tests/unit_tests/components/shelly_dimmer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/shelly_dimmer/test_light.py b/tests/unit_tests/components/shelly_dimmer/test_light.py new file mode 100644 index 0000000000..e5440db4c9 --- /dev/null +++ b/tests/unit_tests/components/shelly_dimmer/test_light.py @@ -0,0 +1,154 @@ +"""Tests for the shelly_dimmer firmware download and prefetch extraction.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome import external_files +from esphome.components.shelly_dimmer import light as shd +from esphome.config_validation import Invalid +from esphome.external_files import RemoteFile + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def test_prefetch_known_version(setup_core: Path) -> None: + entries = [{"firmware": {"version": "51.6", "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + url, sha = shd.KNOWN_FIRMWARE["51.6"] + assert stages == [[RemoteFile(url, shd._firmware_cache_path(sha))]] + + +def test_prefetch_normalizes_update_like_the_schema(setup_core: Path) -> None: + """Quoted booleans behave as the schema will normalize them.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + off = [{"firmware": {"version": "51.6", "update": "false"}}] + assert list(shd.PREFETCH_FILES(off)) == [[]] + on = [{"firmware": {"version": "51.6", "update": "true"}}] + assert list(shd.PREFETCH_FILES(on)) == [ + [RemoteFile(url, shd._firmware_cache_path(sha))] + ] + + +def test_prefetch_rejects_malformed_sha256(setup_core: Path) -> None: + """A raw sha256 that is not a hash never becomes a path component.""" + entries = [ + { + "firmware": { + "url": "https://example.com/fw.bin", + "sha256": "/tmp/payload", + "update": True, + } + } + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_skips_content_addressed_blob_on_disk(setup_core: Path) -> None: + """A sha-keyed cache file needs no revalidation; get_firmware hashes it.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + shd._firmware_cache_path(sha).write_bytes(b"pinned firmware") + entries = [{"firmware": {"version": "51.6", "update": True}}] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_explicit_url_without_sha(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"firmware": {"url": url, "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + key = external_files.url_cache_key(url) + # No sha means the bytes cannot be verified, so the prefetch itself + # must carry the validator's strict no-stale policy. + assert stages == [ + [RemoteFile(url, shd._firmware_cache_path(key), allow_stale=False)] + ] + + +def test_prefetch_skips_no_update(setup_core: Path) -> None: + entries = [ + {"firmware": {"version": "51.6"}}, + {"firmware": "51.6"}, + {"firmware": {"version": "0.0", "update": True}}, + {}, + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_get_firmware_rejects_corrupted_cache(setup_core: Path) -> None: + """A cached blob failing its hash check is discarded and re-downloaded.""" + good = b"good firmware" + expected = _sha(good) + path = shd._firmware_cache_path(expected) + path.write_bytes(b"corrupted blob") + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=good, + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_called_once() + assert result == [int(b) for b in good] + + +def test_get_firmware_trusts_valid_cache(setup_core: Path) -> None: + """A cached blob passing its hash check is used with zero network.""" + good = b"good firmware" + expected = _sha(good) + shd._firmware_cache_path(expected).write_bytes(good) + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content" + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_not_called() + assert result == [int(b) for b in good] + + +def test_get_firmware_hash_mismatch_raises_and_uncaches(setup_core: Path) -> None: + """A fresh download failing its hash check raises and is not cached.""" + expected = _sha(b"expected firmware") + path = shd._firmware_cache_path(expected) + + with ( + patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"wrong firmware", + ), + pytest.raises(Invalid, match="Hash mismatch"), + ): + shd.get_firmware( + {"update": True, "url": "https://example.com/fw.bin", "sha256": expected} + ) + + assert not path.exists() + + +def test_get_firmware_without_sha_rejects_stale(setup_core: Path) -> None: + """The unverifiable no-hash branch must not accept a stale copy.""" + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"fw", + ) as mock_download: + shd.get_firmware({"update": True, "url": "https://example.com/fw.bin"}) + + assert mock_download.call_args.kwargs["allow_stale"] is False diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 64400c4fd4..53035ad713 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,4 +1,4 @@ -"""Tests for entity helpers: name selection, entity key hashing, duplicate checks.""" +"""Test get_base_entity_object_id function matches C++ behavior.""" from collections.abc import Callable, Generator from pathlib import Path @@ -25,17 +25,16 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, finalize_entity_strings, - get_base_entity_name, + get_base_entity_object_id, register_device_class, register_icon, register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, - validate_no_object_id_conflicts, ) from esphome.cpp_generator import MockObj -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture @@ -58,26 +57,206 @@ def restore_core_state() -> Generator[None, None, None]: CORE.friendly_name = original_friendly_name -def test_get_base_entity_name_priority_order() -> None: +def test_with_entity_name() -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + # Even with device name, entity name takes precedence + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" + + # Mixed case + assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" + + +def test_empty_name_with_device_name() -> None: + """Test when entity has empty name and is on a sub-device - should use device name.""" + # C++ behavior: when has_own_name is false and device is set, uses device->get_name() + assert ( + get_base_entity_object_id("", "Friendly Device", "Sub Device 1") + == "sub_device_1" + ) + assert ( + get_base_entity_object_id("", "Kitchen Controller", "controller_1") + == "controller_1" + ) + assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" + + +def test_empty_name_with_friendly_name() -> None: + """Test when entity has empty name and no device - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + +def test_empty_name_no_friendly_name() -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + +def test_edge_cases() -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ], +) +def test_matches_cpp_helpers(name: str, expected: str) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + +def test_empty_name_fallback() -> None: + """Test empty name handling which falls back to friendly_name or CORE.name.""" + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + + +def test_name_add_mac_suffix_behavior() -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" + + +def test_priority_order() -> None: """Test the priority order: entity name > device name > friendly name > CORE.name.""" CORE.name = "core-device" - # 1. Entity name has highest priority and is used as-is, no transformations + # 1. Entity name has highest priority assert ( - get_base_entity_name("Entity Name", "Friendly Name", "Device Name") - == "Entity Name" + get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") + == "entity_name" ) - assert get_base_entity_name("Température", None) == "Température" # 2. Device name is next priority (when entity name is empty) - assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name" + assert ( + get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" + ) # 3. Friendly name is next (when entity and device names are empty) - assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name" + assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" - # 4. CORE.name is last resort; an empty friendly name falls through to it - assert get_base_entity_name("", None, None) == "core-device" - assert get_base_entity_name("", "") == "core-device" + # 4. CORE.name is last resort + assert get_base_entity_object_id("", None, None) == "core-device" + + +@pytest.mark.parametrize( + ("name", "friendly_name", "device_name", "expected"), + [ + # name, friendly_name, device_name, expected + ("Living Room Light", None, None, "living_room_light"), + ("", "Kitchen Controller", None, "kitchen_controller"), + ( + "", + "ESP32 Device", + "controller_1", + "controller_1", + ), # Device name takes precedence + ("GPIO2 Button", None, None, "gpio2_button"), + ("WiFi Signal", "My Device", None, "wifi_signal"), + ("", None, "esp32_node", "esp32_node"), + ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), + ], +) +def test_real_world_examples( + name: str, friendly_name: str | None, device_name: str | None, expected: str +) -> None: + """Test real-world entity naming scenarios.""" + result = get_base_entity_object_id(name, friendly_name, device_name) + assert result == expected + + +def test_issue_6953_scenarios() -> None: + """Test specific scenarios from issue #6953.""" + # Scenario 1: Multiple empty names on main device with name_add_mac_suffix + # The Python code calculates the base, C++ might append MAC suffix dynamically + CORE.name = "device-name" + CORE.friendly_name = "Friendly Device" + + # All empty names should resolve to same base + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + + # Scenario 2: Empty names on sub-devices + assert ( + get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" + ) + assert ( + get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" + ) + + # Scenario 3: xyz duplicates + assert get_base_entity_object_id("xyz", None) == "xyz" + assert get_base_entity_object_id("xyz", "Device") == "xyz" # Tests for setup_entity function @@ -336,10 +515,9 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Check metadata was stored - metadata = CORE.unique_ids[temperature_key] + metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))] assert metadata["name"] == "Temperature" assert metadata["platform"] == "sensor" @@ -347,9 +525,8 @@ def test_entity_duplicate_validator() -> None: config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - humidity_key = ("", "sensor", fnv1_hash_name("Humidity")) - assert humidity_key in CORE.unique_ids - metadata2 = CORE.unique_ids[humidity_key] + assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))] assert metadata2["name"] == "Humidity" # Duplicate entity should fail @@ -360,6 +537,34 @@ def test_entity_duplicate_validator() -> None: validator(config3) +def test_entity_duplicate_validator_hash_collision() -> None: + """Test that two different object_ids with the same FNV-1 hash are rejected.""" + # Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4 + name_a = "Sensor aooxzi" + name_b = "Sensor baraia" + object_id_a = sanitize(snake_case(name_a)) + object_id_b = sanitize(snake_case(name_b)) + assert object_id_a != object_id_b + assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b) + + validator = entity_duplicate_validator("sensor") + + config1 = {CONF_NAME: name_a} + validated1 = validator(config1) + assert validated1 == config1 + + config2 = {CONF_NAME: name_b} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate sensor entity with name 'Sensor baraia' found.*" + r"produce the same entity key hash \(0xe95747e4\)", + re.DOTALL, + ), + ): + validator(config2) + + def test_entity_duplicate_validator_with_devices() -> None: """Test entity_duplicate_validator with devices.""" # Create validator for sensor platform @@ -370,19 +575,18 @@ def test_entity_duplicate_validator_with_devices() -> None: device2 = ID("device2", type="Device") # Same name on different devices should pass - name_hash = fnv1_hash_name("Temperature") config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", name_hash) in CORE.unique_ids - metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)] + assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))] assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 - assert ("device2", "sensor", name_hash) in CORE.unique_ids - metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)] + assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))] assert metadata2["device_id"] == "device2" # Duplicate on same device should fail @@ -434,33 +638,6 @@ def test_entity_different_platforms_yaml_validation( assert result is not None -def test_object_id_conflict_mqtt_yaml_validation( - yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] -) -> None: - """Test that names sanitizing to the same object_id fail when mqtt is configured.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR - ) - assert result is None - - captured = capsys.readouterr() - assert ( - "mqtt builds default topics and discovery topics from the entity object_id" - in captured.out - ) - - -def test_object_id_conflict_without_mqtt_yaml_validation( - yaml_file: Callable[[str], str], -) -> None: - """Test that names sanitizing to the same object_id pass without mqtt/prometheus.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR - ) - # This should succeed - assert result is not None - - def test_entity_duplicate_validator_error_message() -> None: """Test that duplicate entity error messages include helpful metadata.""" # Create validator for sensor platform @@ -519,8 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated1 = validator(config1) assert validated1 == config1 # New format includes device_id (empty string for main device) - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} @@ -528,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: assert validated2 == config2 # Internal entity should not be added to unique_ids # Count how many times the key appears (should still be 1) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Another internal entity with same name should also pass @@ -536,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Non-internal entity with same name should fail @@ -564,148 +744,30 @@ def test_empty_or_null_device_id_on_entity() -> None: def test_entity_duplicate_validator_non_ascii_names() -> None: - """Test that distinct non-ASCII names no longer collide. - - These names used to be rejected because both sanitize to only underscores; - the entity key now hashes the raw name so they stay distinct. - """ + """Test that non-ASCII names show helpful error messages.""" # Create validator for binary_sensor platform validator = entity_duplicate_validator("binary_sensor") - # Both Russian sensors should pass even though they sanitize identically + # First Russian sensor should pass config1 = {CONF_NAME: "Датчик открытия основного крана"} validated1 = validator(config1) assert validated1 == config1 + # Second Russian sensor with different text but same ASCII conversion should fail config2 = {CONF_NAME: "Датчик закрытия основного крана"} - validated2 = validator(config2) - assert validated2 == config2 - - # An exact duplicate still fails - config3 = {CONF_NAME: "Датчик открытия основного крана"} - with pytest.raises( - Invalid, - match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found", - ): - validator(config3) - - -def test_entity_duplicate_validator_hash_collision() -> None: - """Test that two different names with the same FNV-1 hash are rejected.""" - # Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b - name_a = "Sensor m2CZ" - name_b = "Sensor qCaa" - assert name_a != name_b - assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b) - - validator = entity_duplicate_validator("sensor") - - config1 = {CONF_NAME: name_a} - validated1 = validator(config1) - assert validated1 == config1 - - config2 = {CONF_NAME: name_b} with pytest.raises( Invalid, match=re.compile( - rf"Duplicate sensor entity with name '{name_b}' found.*" - rf"The names '{name_b}' and '{name_a}' produce the.*" - r"same entity key hash \(0x0ee5ff7b\).*" - r"To fix: Rename one of the entities", + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", re.DOTALL, ), ): validator(config2) -def test_object_id_conflicts_rejected_by_component_validator() -> None: - """Test that object_id conflicts pass entity validation but fail for mqtt/prometheus.""" - validator = entity_duplicate_validator("sensor") - - # Both names validate fine in general (distinct raw names, distinct keys) - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - # A component that addresses entities by object_id must reject the config - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - with pytest.raises( - Invalid, - match=re.compile( - r"mqtt builds default topics from the entity object_id.*" - r"sensor entities 'Датчик открытия', 'Датчик закрытия' " - r"share the object_id '_______________'.*" - r"To fix: Add unique ASCII characters", - re.DOTALL, - ), - ): - component_validator({}) - - -def test_object_id_conflicts_skipped_in_testing_mode() -> None: - """Test that testing_mode skips the conflict check, as used for grouped testing.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - CORE.testing_mode = True - try: - config: dict = {} - assert component_validator(config) is config - finally: - CORE.testing_mode = False - - -def test_object_id_conflicts_none_recorded() -> None: - """Test that distinct object_ids produce no conflicts.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature"}) - validator({CONF_NAME: "Humidity"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - -def test_object_id_conflicts_device_scoped() -> None: - """Test that the object_id conflict check is scoped per device. - - Same-named entities on different sub-devices were accepted before entity keys - moved to raw names, so the check keeps that scope; conflicts within one device - are still reported with the device named in the message. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")}) - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")}) - - component_validator = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - # Two names sanitizing identically on the same sub-device still conflict - validator( - {CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - validator( - {CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - with pytest.raises( - Invalid, - match=re.compile( - r"prometheus builds metric labels.*on device 'device1'", re.DOTALL - ), - ): - component_validator({}) - - def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: """Test that identical names don't show the enhanced message.""" # Create validator for sensor platform @@ -763,7 +825,7 @@ async def test_setup_entity_empty_name_with_device( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -792,7 +854,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -822,7 +884,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -853,7 +915,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 def test_register_string_overflow() -> None: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml deleted file mode 100644 index 4a6f56f473..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml +++ /dev/null @@ -1,22 +0,0 @@ -esphome: - name: test-object-id-conflict - -esp32: - board: esp32dev - -wifi: - ssid: MySSID - password: password1 - -mqtt: - broker: test.mosquitto.org - -sensor: - # Distinct raw names are fine in general, but both sanitize to the same - # object_id, which MQTT still uses to build default topics - should fail - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml deleted file mode 100644 index c0fbd5cbba..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml +++ /dev/null @@ -1,15 +0,0 @@ -esphome: - name: test-object-id-ok - -esp32: - board: esp32dev - -sensor: - # Distinct raw names that sanitize to the same object_id are allowed when no - # component addresses entities by object_id (no mqtt or prometheus configured) - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/lazy_imports/_storage.py b/tests/unit_tests/fixtures/lazy_imports/_storage.py index 969528304b..94acd2e93a 100644 --- a/tests/unit_tests/fixtures/lazy_imports/_storage.py +++ b/tests/unit_tests/fixtures/lazy_imports/_storage.py @@ -1,10 +1,15 @@ """Shared storage-sidecar factory for the lazy-import fixture scripts.""" +from pathlib import Path + from esphome.storage_json import StorageJSON def make_storage() -> StorageJSON: - """A minimal post-compile esp32 sidecar the upload/logs fast path accepts.""" + """A minimal post-compile esp32 sidecar the upload/logs fast path accepts. + + build_path must be set: the fast path rejects sidecars without one. + """ return StorageJSON( storage_version=1, name="test", @@ -15,8 +20,8 @@ def make_storage() -> StorageJSON: address="1.2.3.4", web_port=None, target_platform="ESP32S3", - build_path=None, - firmware_bin_path=None, + build_path=Path("/build/test"), + firmware_bin_path=Path("/build/test/firmware.bin"), loaded_integrations=set(), loaded_platforms=set(), no_mdns=False, diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py index 19ed83abe1..405567d84f 100644 --- a/tests/unit_tests/test_api_client.py +++ b/tests/unit_tests/test_api_client.py @@ -56,7 +56,7 @@ async def test_async_run_logs_full_flow(caplog) -> None: with ( patch.object(api_client, "async_run", mock_run), - patch.object(api_client, "APIClient") as mock_client, + patch.object(api_client, "APIClient", autospec=True) as mock_client, patch.object(api_client, "safe_print", printed.append), ): task = asyncio.get_running_loop().create_task( @@ -163,3 +163,324 @@ async def test_async_run_logs_passes_deep_sleep( await api_client.async_run_logs(config, ["1.2.3.4"]) assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_feeds_addresses(caplog) -> None: + """Addresses discovered via MQTT are fed into the running client.""" + caplog.set_level("INFO", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = asyncio.Event() + + def resolver(stop_event): + return ["10.0.0.9", "10.0.0.10"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or True + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + async with asyncio.timeout(1): + await fed.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with( + ["10.0.0.9", "10.0.0.10"] + ) + assert "Discovered address(es) via MQTT" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_no_addresses_keeps_running() -> None: + """A resolver returning nothing (failed lookup) leaves the session running.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + # The resolver owns failure handling; a failed lookup returns [] + resolver_ran.set() + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_stopped_on_teardown() -> None: + """Teardown sets the resolver's stop event so the thread exits promptly.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + # Simulate a slow broker lookup that only ends via the stop event. + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert captured_event is not None + assert captured_event.is_set() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_crash_still_stops_cleanly(caplog) -> None: + """A resolver raising unexpectedly must not skip stop() at teardown.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + raise RuntimeError("resolver blew up") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_cancels_mqtt_discovery() -> None: + """A successful connection stops the in-flight broker lookup.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)) as mock_run, + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + + # The runner reports a successful connection + on_connect = mock_run.call_args.kwargs["on_connect"] + on_connect() + await asyncio.sleep(0.05) + + assert captured_event is not None + assert captured_event.is_set() + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_before_discovery_skips_lookup() -> None: + """A connection during async_run startup prevents the lookup from starting.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver = Mock(name="resolver") + + async def fake_async_run(*args, **kwargs): + # Connection succeeds before async_run even returns + kwargs["on_connect"]() + return stop + + with ( + patch.object(api_client, "async_run", AsyncMock(side_effect=fake_async_run)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + resolver.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_duplicate_addresses_logged(caplog) -> None: + """A discovery the client rejects as already known leaves a debug trace.""" + import threading + + caplog.set_level("DEBUG", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = threading.Event() + + def resolver(stop_event): + return ["1.2.3.4"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or False + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(fed.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with(["1.2.3.4"]) + assert "MQTT-discovered address(es) already known: 1.2.3.4" in caplog.text + assert "Discovered address(es) via MQTT" not in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_base_exception_escape_logged_at_teardown(caplog) -> None: + """A BaseException escaping the worker is reported, and stop() still runs.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + class WorkerEscape(BaseException): + """Not an Exception, so the task-level guard must not catch it.""" + + def resolver(stop_event): + resolver_ran.set() + raise WorkerEscape("worker bailed") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_stubborn_worker_cancelled_at_teardown() -> None: + """A worker that ignores the stop event is cancelled after the grace period.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + release = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + # Ignore stop_event entirely; only the test releases us + release.wait(timeout=10) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + release.set() + + stop.assert_awaited_once() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index b3c2170c3f..77690a6897 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import contextmanager from ipaddress import IPv4Address, IPv4Network import json import os @@ -19,6 +20,7 @@ from esphome.compiled_config import ( compiled_config_path, load_compiled_config, save_compiled_config, + save_compiled_config_and_sidecar, ) from esphome.const import ( CONF_API, @@ -31,7 +33,16 @@ from esphome.const import ( KEY_VARIANT, Toolchain, ) -from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds +from esphome.core import ( + CORE, + ID, + EsphomeError, + HexInt, + Lambda, + MACAddress, + TimePeriodMilliseconds, +) +from esphome.storage_json import StorageJSON from esphome.util import OrderedDict _VALIDATED_CONFIG = { @@ -54,8 +65,9 @@ def _cache_body(config: dict | None = None) -> str: def _write_storage( storage_path: Path, *, - esp_platform: str = "ESP32", + esp_platform: str | None = "ESP32", core_platform: str | None = "esp32", + build_path: str | None = "/build/lite_test", ) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) @@ -69,7 +81,7 @@ def _write_storage( "address": "192.168.1.42", "web_port": None, "esp_platform": esp_platform, - "build_path": "/build/lite_test", + "build_path": build_path, "firmware_bin_path": "/build/lite_test/firmware.bin", "loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_platforms": [], @@ -359,31 +371,262 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( mock_read.assert_called_once() -def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( - tmp_path: Path, -) -> None: - """Without a StorageJSON sidecar (no compile has run), the fallback - skips the cache write -- load_compiled_config requires the sidecar, - so writing the rendered (secret-resolved) config would be inert and - leak secrets to disk for nothing.""" +def _storage_fixture(tmp_path: Path) -> StorageJSON: + """A loaded StorageJSON instance matching _write_storage's contents.""" + fixture = tmp_path / "fixture_storage.json" + _write_storage(fixture) + return StorageJSON.load(fixture) + + +def _bare_yaml(tmp_path: Path) -> Path: + """A minimal YAML with CORE.config_path pointed at it.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path + return yaml_path + +@contextmanager +def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any: + """Patch the fallback path's collaborators for a run_esphome call. + + Without kwargs, from_esphome_core stays real (yielded mock is None). + """ with ( patch( "esphome.config.read_config", return_value={"esphome": {"name": "lite_test"}}, - ), - patch("esphome.compiled_config.save_compiled_config") as mock_save, + ) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", - {"upload": lambda args, config: 0}, + {command: lambda args, config: 0}, ), ): - run_esphome(["esphome", "upload", str(yaml_path)]) + if not from_core_kwargs: + yield mock_read, None + return + with patch.object( + StorageJSON, "from_esphome_core", **from_core_kwargs + ) as mock_from_core: + yield mock_read, mock_from_core + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar( + tmp_path: Path, command: str +) -> None: + """A never-compiled config caches on its first upload/logs run: the + fallback writes the StorageJSON sidecar itself (load_compiled_config + needs it), so the second run hits the fast path.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + + with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as ( + mock_read, + mock_from_core, + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + mock_from_core.assert_called_once() + assert (storage_dir / "lite_test.yaml.validated.json").exists() + storage = StorageJSON.load(storage_dir / "lite_test.yaml.json") + assert storage is not None + # No compile happened, so the sidecar must not claim one. + assert mock_from_core.call_args.kwargs == {"claim_build": False} + + # The second run loads the cache instead of re-validating. + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + mock_read.assert_called_once() + + +# as_dict serialized unset paths as str(None) until 2026.9; files +# written by those wizards are still on disk. +_WIZARD_SIDECAR_CASES = pytest.mark.parametrize( + "wizard_kwargs", + [ + {"esp_platform": None, "core_platform": None, "build_path": None}, + {"build_path": None}, + {"build_path": "None"}, + ], + ids=["legacy_wizard", "modern_wizard", "none_string_wizard"], +) + + +def _prime_core(tmp_path: Path) -> None: + """Set the post-validation CORE state from_esphome_core reads.""" + CORE.name = "lite_test" + CORE.build_path = tmp_path / "build" / "lite_test" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp8266", + KEY_TARGET_FRAMEWORK: "arduino", + } + + +@_WIZARD_SIDECAR_CASES +def test_run_esphome_fallback_completes_wizard_sidecar( + tmp_path: Path, wizard_kwargs: dict[str, Any] +) -> None: + """A wizard-written sidecar can't drive the fast path (no build_path; + older wizards also no platform fields); the fallback rewrites it from + CORE so the cache loads on the next run.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs) + + with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_called_once() + storage = StorageJSON.load(storage_dir / "lite_test.yaml.json") + assert storage is not None and storage.core_platform == "esp32" + # What the wizard recorded about a build (nothing, or a real one) + # carries through instead of being stamped with this run's values. + assert storage.esphome_version == "2026.1.0" + assert load_compiled_config(yaml_path) is not None + + +def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails( + tmp_path: Path, +) -> None: + """A failed sidecar write is non-fatal and skips the cache save too: + without the sidecar the cache could never be loaded back, so writing + it would only leave resolved secrets on disk.""" + yaml_path = _bare_yaml(tmp_path) + + with ( + _fallback_run(side_effect=RuntimeError("boom")), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + ): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 mock_save.assert_not_called() + assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists() + + +def test_run_esphome_fallback_write_failure_takes_io_branch( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """StorageJSON.save raises EsphomeError (write_file wraps OSError into + it), which must land in the plain I/O warning, not the traceback + branch for structural bugs.""" + yaml_path = _bare_yaml(tmp_path) + + with ( + _fallback_run(return_value=_storage_fixture(tmp_path)), + patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + caplog.at_level("WARNING", logger="esphome.compiled_config"), + ): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_save.assert_not_called() + assert "Could not refresh the storage sidecar" in caplog.text + assert "Unexpected error" not in caplog.text + + +def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None: + """A present-but-corrupt sidecar is not overwritten: it may hold a real + build's metadata, and replacing it would suppress the next compile's + clean of a possibly incoherent build tree. The cache save is skipped.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + sidecar = storage_dir / "lite_test.yaml.json" + sidecar.parent.mkdir(parents=True, exist_ok=True) + sidecar.write_text("{truncated", encoding="utf-8") + + with _fallback_run(return_value=None) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_not_called() + assert sidecar.read_text(encoding="utf-8") == "{truncated" + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + + +def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete( + tmp_path: Path, +) -> None: + """If the rebuilt sidecar would still be incomplete, nothing is written: + the cache could never be loaded back, so saving it would only rewrite + resolved secrets on every run.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + + incomplete = tmp_path / "incomplete_storage.json" + _write_storage(incomplete, build_path=None) + + with _fallback_run(return_value=StorageJSON.load(incomplete)): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + assert not (storage_dir / "lite_test.yaml.json").exists() + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + + +def test_run_esphome_fallback_sidecar_records_platformio_toolchain( + tmp_path: Path, +) -> None: + """The toolchain fallback runs before the sidecar write, so platforms + whose validators leave CORE.toolchain unset record the same + "platformio" a compile writes, not null.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + assert CORE.toolchain is None + + with _fallback_run(): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + storage = StorageJSON.load( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + ) + assert storage is not None + assert storage.toolchain == "platformio" + + +@pytest.mark.parametrize("existing_sidecar", [None, "wizard"]) +def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists( + tmp_path: Path, existing_sidecar: str | None +) -> None: + """An existing build tree with a missing or wizard-only sidecar keeps + it that way: the mismatch is what makes the next compile wipe the + unknown tree, so the fallback writes nothing and skips the cache.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.build_path.mkdir(parents=True) + storage_dir = tmp_path / ".esphome" / "storage" + if existing_sidecar == "wizard": + _write_storage(storage_dir / "lite_test.yaml.json", build_path=None) + wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8") + + with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_not_called() + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + if existing_sidecar == "wizard": + sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8") + assert sidecar_body == wizard_body + else: + assert not (storage_dir / "lite_test.yaml.json").exists() + + +def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None: + """Drive the real from_esphome_core on the fallback path: the + post-validation CORE state yields a complete, loadable sidecar.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}} + CORE.toolchain = Toolchain.PLATFORMIO + + save_compiled_config_and_sidecar(CORE.config) + + storage = StorageJSON.load( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + ) + assert storage is not None + assert storage.core_platform == "esp8266" + assert storage.build_path is not None + # No compile happened, so the sidecar must not claim one. + assert storage.esphome_version is None + assert storage.firmware_bin_path is None + assert load_compiled_config(yaml_path) is not None @pytest.mark.parametrize("command", ["upload", "logs"]) @@ -409,6 +652,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( patch( "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config ) as mock_save, + patch.object(StorageJSON, "from_esphome_core") as mock_from_core, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {command: lambda args, config: 0}, @@ -417,6 +661,8 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( assert run_esphome(["esphome", command, str(yaml_path)]) == 0 mock_save.assert_called_once_with(fresh_config) + # The compile-written sidecar is complete; the fallback leaves it alone. + mock_from_core.assert_not_called() # mtime is now newer than the source YAML, so a follow-up call hits # the fast path instead of repeating read_config. assert cache.stat().st_mtime >= yaml_path.stat().st_mtime @@ -647,24 +893,15 @@ def test_int_keys_coerce_to_strings(primed_storage: Path) -> None: assert config["table"] == {"1": "a", "2": "b"} -def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None: - """A wizard-only sidecar (no compile -- no core_platform / target_platform) - can't drive upload/logs, so the fast path falls back.""" - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text("esphome:\n name: lite_test\n") - CORE.config_path = yaml_path - +@_WIZARD_SIDECAR_CASES +def test_load_compiled_config_rejects_wizard_only_sidecar( + tmp_path: Path, wizard_kwargs: dict[str, Any] +) -> None: + """A wizard-written sidecar (no build_path; older wizards also no + platform fields) can't drive upload/logs, so the fast path falls back.""" + yaml_path = _bare_yaml(tmp_path) storage_dir = tmp_path / ".esphome" / "storage" - storage_dir.mkdir(parents=True, exist_ok=True) - # StorageJSON with both core_platform and target_platform unset. - (storage_dir / "lite_test.yaml.json").write_text( - '{"storage_version": 1, "name": "lite_test", "friendly_name": null, ' - '"comment": null, "esphome_version": null, "src_version": 1, ' - '"address": null, "web_port": null, "esp_platform": null, ' - '"build_path": null, "firmware_bin_path": null, ' - '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' - '"framework": null, "core_platform": null}' - ) + _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs) cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache_path, yaml_path, offset=5) diff --git a/tests/unit_tests/test_config_prefetch.py b/tests/unit_tests/test_config_prefetch.py new file mode 100644 index 0000000000..afb93a09a0 --- /dev/null +++ b/tests/unit_tests/test_config_prefetch.py @@ -0,0 +1,355 @@ +"""Tests for the remote file prefetch validation step.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import core +from esphome.config import Config, PrefetchRemoteFilesValidationStep +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _component(prefetch: Any = None, is_platform: bool = False) -> SimpleNamespace: + return SimpleNamespace( + is_platform_component=is_platform, + prefetch_files=prefetch, + ) + + +def _run_step( + domains: dict[str, Any], + components: dict[str, Any], + platforms: dict[tuple[str, str], Any] | None = None, + download_side_effect: Any = None, +) -> tuple[Config, MagicMock]: + result = Config() + for domain, conf in domains.items(): + result[domain] = conf + with ( + patch("esphome.config.get_component", side_effect=components.get), + patch( + "esphome.config.get_platform", + side_effect=lambda d, p: (platforms or {}).get((d, p)), + ), + patch( + "esphome.external_files.download_content_many", + side_effect=download_side_effect, + ) as mock_download, + ): + PrefetchRemoteFilesValidationStep().run(result) + return result, mock_download + + +def _downloaded(mock_download: MagicMock, call: int = 0) -> list[RemoteFile]: + return list(mock_download.call_args_list[call][0][0]) + + +def test_component_hook_receives_normalized_entries() -> None: + """A bare dict conf is passed to the hook as a one-entry list.""" + seen: list[Any] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("https://example.com/a", Path("/cache/a"))] + + _, mock_download = _run_step( + {"my_comp": {"key": "value"}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert seen == [[{"key": "value"}]] + mock_download.assert_called_once() + assert _downloaded(mock_download) == [ + RemoteFile("https://example.com/a", Path("/cache/a")) + ] + + +def test_platform_entries_are_grouped_per_platform() -> None: + """Platform domains route entries to each platform module's hook.""" + seen_a: list[Any] = [] + seen_b: list[Any] = [] + + def hook_a(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_a.extend(entries) + yield [RemoteFile("url-a", Path("/a"))] + + def hook_b(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_b.extend(entries) + yield [RemoteFile("url-b", Path("/b"))] + + entries = [ + {"platform": "a", "n": 1}, + {"platform": "b", "n": 2}, + {"platform": "a", "n": 3}, + ] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(is_platform=True)}, + platforms={ + ("image", "a"): _component(prefetch=hook_a), + ("image", "b"): _component(prefetch=hook_b), + }, + ) + + assert seen_a == [entries[0], entries[2]] + assert seen_b == [entries[1]] + assert sorted(_downloaded(mock_download), key=lambda f: f.url) == [ + RemoteFile("url-a", Path("/a")), + RemoteFile("url-b", Path("/b")), + ] + + +def test_hook_failure_does_not_fail_validation( + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising hook is logged and other hooks still prefetch.""" + + def bad_hook(entries: list[dict]) -> list[RemoteFile]: + raise RuntimeError("garbage config") + + def good_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/g"))] + + _, mock_download = _run_step( + {"bad": {"x": 1}, "good": {"y": 2}}, + { + "bad": _component(prefetch=bad_hook), + "good": _component(prefetch=good_hook), + }, + ) + + assert "Remote file prefetch for bad failed" in caplog.text + assert _downloaded(mock_download) == [RemoteFile("url", Path("/g"))] + + +def test_stages_download_between_resumptions() -> None: + """Each yielded stage is downloaded before the generator resumes.""" + order: list[str] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + order.append("stage1") + yield [RemoteFile("css-url", Path("/css"))] + order.append("stage2") + yield [RemoteFile("ttf-url", Path("/ttf"))] + + def record_download(items: Any, description: str) -> None: + order.append(f"download:{[file.url for file in items]}") + + _, mock_download = _run_step( + {"font": {"f": 1}}, + {"font": _component(prefetch=hook)}, + download_side_effect=record_download, + ) + + assert order == [ + "stage1", + "download:['css-url']", + "stage2", + "download:['ttf-url']", + ] + assert mock_download.call_count == 2 + + +def test_runaway_generator_is_capped(caplog: pytest.LogCaptureFixture) -> None: + """An endless generator stops after the stage backstop.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + n = 0 + while True: + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + n += 1 + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_mid_stage_failure_stops_only_that_hook( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator raising on a later stage does not affect other hooks.""" + + def flaky_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("first", Path("/first"))] + raise RuntimeError("stage two exploded") + + def steady_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("one", Path("/one"))] + yield [RemoteFile("two", Path("/two"))] + + _, mock_download = _run_step( + {"flaky": {"x": 1}, "steady": {"y": 2}}, + { + "flaky": _component(prefetch=flaky_hook), + "steady": _component(prefetch=steady_hook), + }, + ) + + assert "Remote file prefetch for flaky failed" in caplog.text + assert mock_download.call_count == 2 + assert _downloaded(mock_download, 1) == [RemoteFile("two", Path("/two"))] + + +def test_download_failure_is_swallowed() -> None: + """cv.Invalid from the batch download never escapes the step.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=cv.Invalid("download failed"), + ) + + mock_download.assert_called_once() + assert not result.errors + + +def test_domains_without_hooks_do_not_download() -> None: + """Components without PREFETCH_FILES cause no download call.""" + _, mock_download = _run_step( + {"plain": {"x": 1}, ".ignored": {"y": 2}, "unknown": {"z": 3}}, + {"plain": _component()}, + ) + mock_download.assert_not_called() + + +def test_none_and_autoload_confs_are_skipped() -> None: + """None and AutoLoad confs never reach a hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"a": None, "b": core.AutoLoad()}, + {"a": _component(prefetch=hook), "b": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_non_dict_entries_are_ignored() -> None: + """Garbage entries never reach a component hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"my_comp": ["just-a-string", 42]}, + {"my_comp": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_platform_entries_without_platform_key_are_ignored() -> None: + """Entries with a missing or unknown platform never reach a hook.""" + _, mock_download = _run_step( + {"image": [{"n": 1}, "garbage", {"platform": "unknown"}]}, + {"image": _component(is_platform=True)}, + ) + mock_download.assert_not_called() + + +def test_generator_still_alive_at_the_cap_is_warned_and_closed( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator with a stage left at the cap is warned about and closed.""" + closed: list[bool] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + finally: + closed.append(True) + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + assert closed == [True] + + +def test_plain_iterable_hook_survives_the_cap( + caplog: pytest.LogCaptureFixture, +) -> None: + """A hook returning a plain list of batches cannot crash the backstop.""" + + def hook(entries: list[dict]) -> list[list[RemoteFile]]: + return [[RemoteFile(f"url-{n}", Path(f"/f{n}"))] for n in range(12)] + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_domain_level_hook_on_platform_component() -> None: + """A hook on the platform component's domain module sees all entries.""" + seen: list[Any] = [] + + def domain_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("domain-url", Path("/domain"))] + + entries = [{"platform": "a", "n": 1}, {"platform": "b", "n": 2}] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(prefetch=domain_hook, is_platform=True)}, + ) + + assert seen == [entries] + assert _downloaded(mock_download) == [RemoteFile("domain-url", Path("/domain"))] + + +def test_generator_raising_on_close_is_contained( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator whose close() raises at the cap is logged, not crashed on.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + except GeneratorExit: + raise RuntimeError("close exploded") from None + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_unexpected_download_error_is_logged_visibly( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken batch downloader warns instead of silently disabling prefetch.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=TypeError("not a RemoteFile"), + ) + + mock_download.assert_called_once() + assert not result.errors + assert "Remote file prefetch failed" in caplog.text diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7627ef9273..971c4e462d 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2967,6 +2967,23 @@ def test_require_esphome_version_older_prerelease_fails() -> None: cv.require_esphome_version(2026, 8, 0)("test") +def test_parse_esphome_version_deprecated_shim( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removed helper still works for external components and warns.""" + from esphome import const, util + + with ( + patch.object(const, "__version__", "2026.9.0-dev"), + caplog.at_level(logging.WARNING), + ): + assert cv.parse_esphome_version() == (2026, 9, 0) + assert cv.parse_esphome_version() < (9999, 0, 0) + assert "parse_esphome_version() is deprecated" in caplog.text + # Both historical import paths resolve to the same function + assert cv.parse_esphome_version is util.parse_esphome_version + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py new file mode 100644 index 0000000000..2ccf53f7e3 --- /dev/null +++ b/tests/unit_tests/test_download_types.py @@ -0,0 +1,52 @@ +"""Platform get_download_types contract for never-built configs. + +Wizard-written and upload/logs-fallback sidecars record no +firmware_bin_path; the download panel must get an empty list for them, +not entries pointing at files that were never built. +""" + +from __future__ import annotations + +from importlib import import_module +from pathlib import Path +from typing import Any + +import pytest + +from esphome.storage_json import StorageJSON + +PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52"] + + +def _download_types(platform: str, storage: StorageJSON) -> list[dict[str, Any]]: + return import_module(f"esphome.components.{platform}").get_download_types(storage) + + +def _wizard_storage() -> StorageJSON: + return StorageJSON.from_wizard( + name="test_device", + friendly_name="Test Device", + address="test_device.local", + platform="ESP32", + ) + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_no_firmware_path_yields_no_downloads(platform: str) -> None: + """No recorded firmware path means nothing was built; no downloads.""" + assert _download_types(platform, _wizard_storage()) == [] + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) -> None: + """With a firmware path recorded, every platform offers entries in + the documented title/description/file/download shape.""" + storage = _wizard_storage() + storage.firmware_bin_path = tmp_path / "firmware.bin" + + types = _download_types(platform, storage) + + assert types + assert all( + {"title", "description", "file", "download"} <= entry.keys() for entry in types + ) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 56f358a24c..26d812af8b 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -265,6 +265,21 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: + """A PYTHONPATH from the parent environment must not reach idf.py. + + It would override the IDF venv's isolation, shadowing its pinned + packages and failing idf.py's dependency check. + """ + toolchain._cache().env.clear() + with patch.dict( + os.environ, + {"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"}, + ): + env = toolchain._get_idf_env(version="5.5.4") + assert "PYTHONPATH" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 9413fbcf29..db4a4b1117 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -44,13 +44,17 @@ def mock_file() -> io.BytesIO: @pytest.fixture -def mock_time() -> Generator[None]: +def mock_sleep() -> Generator[Mock]: + """Mock time.sleep so delays don't slow down tests.""" + with patch("time.sleep") as mock: + yield mock + + +@pytest.fixture +def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with ( - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), - ): + with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): yield @@ -79,6 +83,28 @@ def mock_resolve_ip() -> Generator[Mock]: yield mock +DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0) +DUAL_STACK_SA4 = ("192.168.1.100", 3232) + + +@pytest.fixture +def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock: + """Make resolve_ip_address return an IPv6 and an IPv4 address.""" + mock_resolve_ip.return_value = [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6), + (socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4), + ] + return mock_resolve_ip + + +@pytest.fixture +def firmware_file(tmp_path: Path) -> Path: + """Create a firmware file on disk for run_ota_impl_ tests.""" + firmware = tmp_path / "firmware.bin" + firmware.write_bytes(b"firmware content") + return firmware + + @pytest.fixture def mock_perform_ota() -> Generator[Mock]: """Mock perform_ota function for testing.""" @@ -137,9 +163,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: with pytest.raises( espota2.OTAError, match="receiving auth:.*Authentication invalid" - ): + ) as exc_info: espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + # Device-reported errors must stay plain OTAError, not the retryable kind + assert not isinstance(exc_info.value, espota2.OTANetworkError) mock_socket.close.assert_called_once() @@ -147,10 +175,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: """Test receive_exactly handles socket errors.""" mock_socket.recv.side_effect = OSError("Connection reset") - with pytest.raises(espota2.OTAError, match="receiving test response"): + with pytest.raises(espota2.OTANetworkError, match="receiving test response"): espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) +def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None: + """Test receive_exactly handles socket errors after the first byte.""" + mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")] + + with pytest.raises(espota2.OTANetworkError, match="receiving test:"): + espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + +def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None: + """Test receive_exactly raises OTANetworkError when the device closes the connection.""" + mock_socket.recv.return_value = b"" + + with pytest.raises( + espota2.OTANetworkError, match="Device closed connection without responding" + ): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + mock_socket.close.assert_called_once() + + @pytest.mark.parametrize( ("error_code", "expected_msg"), [ @@ -227,15 +275,15 @@ def test_check_error_unexpected_response() -> None: def test_check_error_empty_data() -> None: - """Test check_error raises error when device closes connection without responding.""" + """Test check_error raises the retryable OTANetworkError when the device closes the connection.""" with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error([], [espota2.RESPONSE_OK]) # Also test with empty bytes with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error(b"", [espota2.RESPONSE_OK]) @@ -530,6 +578,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, None, mock_file, "test.bin") +def _no_auth_handshake(version: int) -> list[bytes]: + """Recv responses for a handshake without auth, up to the MD5 check.""" + return [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([version]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA raises the retryable OTANetworkError when sending a chunk fails.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Probe for a pending error byte fails too + ] + # Sends before the data phase: magic bytes, features, binary size, MD5; + # fail on the fifth sendall, the first firmware chunk + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises(espota2.OTANetworkError, match="sending data:"): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error_surfaces_device_error( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a device error byte pending behind a send failure becomes the cause.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed + ] + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises( + espota2.OTAError, match="Writing OTA data to flash memory failed" + ) as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device-reported error is not retryable + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_final_chunk_ack_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a lost ack for the final chunk is not retried.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the only (final) chunk is lost + ] + + with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device already had the whole image, so it may be committing + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_intermediate_chunk_ack_failure_retryable( + mock_socket: Mock, +) -> None: + """Test a lost ack for a non-final chunk stays retryable.""" + # Two chunks: the firmware is larger than one upload block + big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1)) + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the first of two chunks is lost + ] + + with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"): + espota2.perform_ota(mock_socket, None, big_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_post_commit_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a network failure after the device committed is a plain OTAError.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + OSError("Connection reset"), # Connection lost waiting for end result + ] + + with pytest.raises(espota2.OTAError, match="receiving update end result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # Must not be the retryable kind; the device is already rebooting + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_mismatch_not_marked_committed( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test an MD5 mismatch keeps its own message and stays non-retryable.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update + ] + + with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device aborted without committing, so the message must not claim + # the update may have been installed, and the error must not be retried + assert not isinstance(exc.value, espota2.OTANetworkError) + assert "committed" not in str(exc.value) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_end_ack_send_failure_is_success( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a send failure on the final acknowledgement does not fail the OTA.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed + ] + # Sends: magic bytes, features, binary size, MD5, one firmware chunk; + # fail on the sixth sendall, the end acknowledgement + mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")] + + # Must not raise; the device treats a missing acknowledgement as non-fatal + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + assert mock_socket.sendall.call_count == 6 + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_successful( mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock @@ -564,21 +750,183 @@ def test_run_ota_impl_successful( @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") -def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: - """Test run_ota_impl_ when connection fails.""" +def test_run_ota_impl_connection_failed( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries when connection fails and eventually gives up.""" mock_socket.connect.side_effect = OSError("Connection refused") - # Create a real firmware file - firmware_file = tmp_path / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) ) assert result_code == 1 assert result_host is None - mock_socket.close.assert_called_once() + # A single address gets the whole attempt budget, with a delay before + # each revisit + assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connect_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ succeeds when a retry connects after a failed attempt.""" + mock_socket.connect.side_effect = [OSError("Connection timed out"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_socket.connect.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries after a network error during the upload.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("receiving features: Device closed connection"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_perform_ota.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_exhausts_attempts( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ gives up after all attempts hit network errors.""" + mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_multiple_addresses_cycle( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ visits every address and cycles for the retries.""" + mock_socket.connect.side_effect = OSError("No route to host") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + # Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare + # attempts cycle back through them; the budget is shared, not per address + assert mock_socket.connect.call_args_list == [ + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + ] + # No connect ever reached the device, so the delay only applies before + # the revisits + assert mock_sleep.call_count == 2 + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_second_address_succeeds_without_delay( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ falls through to the next address with no pause.""" + mock_socket.connect.side_effect = [OSError("No route to host"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + mock_sleep.assert_not_called() + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_pauses_after_reaching_device( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ pauses before the next address once the device was reached.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("sending data: connection reset"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + # The first attempt reached the device, so the next one waits first even + # though it targets a fresh address + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_device_error_not_retried( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails immediately on a device-reported error.""" + mock_perform_ota.side_effect = espota2.OTAError( + "Authentication invalid. Is the password correct?" + ) + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_perform_ota.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_ota_impl_no_addresses( + firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + mock_resolve_ip.return_value = [] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_sleep.assert_not_called() def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 16cee9564f..4e993ff4f3 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -3,6 +3,7 @@ import os from pathlib import Path import time +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -26,19 +27,21 @@ def _seed_etag(cache_file: Path, etag: str) -> Path: @pytest.fixture def mock_requests_head() -> MagicMock: - """Patch `external_files.requests.head` so the conditional HEAD-request - validator can be tested without doing real HTTP. + """Patch `requests.head` so the conditional HEAD-request validator can + be tested without doing real HTTP. Patched on the requests module + because external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.head") as m: + with patch("requests.head") as m: yield m @pytest.fixture def mock_requests_get() -> MagicMock: - """Patch `external_files.requests.get` so the download path can be - tested without doing real HTTP. + """Patch `requests.get` so the download path can be tested without + doing real HTTP. Patched on the requests module because + external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.get") as m: + with patch("requests.get") as m: yield m @@ -549,6 +552,10 @@ def test_download_content_skip_external_update_uses_cache( assert result == cached_content mock_has_remote_file_changed.assert_not_called() mock_requests_get.assert_not_called() + # Deliberately unchecked is memoized for the run but never "fresh". + assert not external_files.is_fresh_this_run(test_file) + assert external_files.download_content(url, test_file) == cached_content + mock_has_remote_file_changed.assert_not_called() def test_download_content_skip_external_update_downloads_when_missing( @@ -587,10 +594,16 @@ def test_download_content_many_single_item_avoids_pool( mock_download_content: MagicMock, setup_core: Path ) -> None: """A single item should be downloaded inline (no thread pool overhead).""" - item = ("https://example.com/file.txt", setup_core / "f.txt") + item = external_files.RemoteFile( + "https://example.com/file.txt", setup_core / "f.txt" + ) external_files.download_content_many([item]) mock_download_content.assert_called_once_with( - item[0], item[1], external_files.NETWORK_TIMEOUT + item.url, + item.path, + external_files.NETWORK_TIMEOUT, + allow_stale=True, + return_content=False, ) @@ -602,7 +615,12 @@ def test_download_content_many_runs_in_parallel( barrier = threading.Barrier(3) - def slow_download(url: str, path: Path, timeout: int) -> bytes: + def slow_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: # If calls were serial this would deadlock (third caller never arrives # while the first is blocked at the barrier). barrier.wait(timeout=2.0) @@ -610,9 +628,9 @@ def test_download_content_many_runs_in_parallel( mock_download_content.side_effect = slow_download items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] external_files.download_content_many(items, max_workers=4) assert mock_download_content.call_count == 3 @@ -625,15 +643,20 @@ def test_download_content_many_propagates_single_error( it in a `MultipleInvalid` that the caller would have to unpack. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("bad"): raise Invalid(f"could not download {url}") return b"" mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad", setup_core / "bad"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad", setup_core / "bad"), ] with pytest.raises(Invalid, match="could not download") as exc_info: external_files.download_content_many(items) @@ -648,16 +671,21 @@ def test_download_content_many_aggregates_multiple_errors( them one network round-trip at a time. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("ok"): return b"" raise Invalid(f"could not download {url}") mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad1", setup_core / "bad1"), - ("https://example.com/bad2", setup_core / "bad2"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad1", setup_core / "bad1"), + external_files.RemoteFile("https://example.com/bad2", setup_core / "bad2"), ] with pytest.raises(MultipleInvalid) as exc_info: external_files.download_content_many(items) @@ -678,9 +706,9 @@ def test_download_content_many_dedupes_by_path( """ path = setup_core / "shared" items = [ - ("https://example.com/a", path), - ("https://example.com/b", path), - ("https://example.com/a", path), + external_files.RemoteFile("https://example.com/a", path), + external_files.RemoteFile("https://example.com/b", path), + external_files.RemoteFile("https://example.com/a", path), ] external_files.download_content_many(items) assert mock_download_content.call_count == 1 @@ -695,8 +723,8 @@ def test_download_content_many_clamps_invalid_max_workers( be clamped up to at least 1 worker. """ items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), ] external_files.download_content_many(items, max_workers=0) assert mock_download_content.call_count == 2 @@ -724,8 +752,8 @@ def test_download_web_files_in_config_filters_and_dispatches( assert result is config mock_download_content_many.assert_called_once() assert list(mock_download_content_many.call_args[0][0]) == [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] @@ -799,3 +827,264 @@ def test_download_content_atomic_write_no_partial_on_failure( # into the cache directory either way. leftover_tmps = list(setup_core.glob("tmp*")) assert leftover_tmps == [] + + +def test_download_content_memoizes_fresh_path( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A path downloaded once this run skips all network on later calls.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"fresh content" + assert external_files.download_content(url, test_file) == b"fresh content" + + mock_has_remote_file_changed.assert_called_once() + mock_requests_get.assert_called_once() + + +def test_download_content_memo_revalidates_deleted_file( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A memoized path whose file vanished is downloaded again.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.download_content(url, test_file) + test_file.unlink() + external_files.download_content(url, test_file) + + assert mock_requests_get.call_count == 2 + + +def test_download_content_failure_fails_fast_on_retry( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A failed download is remembered; a retry raises without network.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + + mock_requests_get.assert_called_once() + + +def test_download_content_failed_path_revalidates_when_file_appears( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A recorded failure is dropped once the file exists on disk.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid): + external_files.download_content(url, test_file) + + # Another writer produced the file; the cached failure no longer applies + # and the network error now falls back to the on-disk copy. + test_file.write_bytes(b"appeared") + assert external_files.download_content(url, test_file) == b"appeared" + + +def test_download_content_network_error_fallback_memoizes( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Falling back to a cached file memoizes, so a flaky host is hit once.""" + test_file = setup_core / "memo.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_called_once() + + +def test_download_content_not_changed_uses_cache( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A 304 not-changed check serves the cached file without a GET.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + mock_has_remote_file_changed.return_value = False + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_not_called() + + +def test_head_failure_fallback_is_stale_not_fresh( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A HEAD network failure serves the copy once and memoizes it as stale.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_head.assert_called_once() + mock_requests_get.assert_not_called() + + +def test_allow_stale_false_rejects_unverified_copy( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False raises instead of building from an unverified copy.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + + # A strict caller gets its own attempt at the network rather than + # inheriting the stale memo's verdict. + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + assert mock_requests_get.call_count == 2 + + # A caller that tolerates stale copies still gets the cached bytes. + assert external_files.download_content(url, test_file) == b"cached content" + + +def test_allow_stale_false_rejects_head_failure_fallback( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False also rejects a copy the HEAD could not confirm.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="cannot be verified"): + external_files.download_content(url, test_file, allow_stale=False) + mock_requests_get.assert_not_called() + + +def test_download_content_many_forwards_per_file_allow_stale( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """Each RemoteFile's own allow_stale reaches download_content.""" + files = [ + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile( + "https://example.com/b", setup_core / "b", allow_stale=False + ), + ] + external_files.download_content_many(files) + forwarded = { + call.args[1]: call.kwargs["allow_stale"] + for call in mock_download_content.call_args_list + } + assert forwarded == {setup_core / "a": True, setup_core / "b": False} + + +def test_download_content_many_dedupe_keeps_strictest( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """A strict duplicate wins over a permissive one for the same path.""" + path = setup_core / "fw.bin" + files = [ + external_files.RemoteFile("https://example.com/fw", path, allow_stale=False), + external_files.RemoteFile("https://example.com/fw", path), + ] + external_files.download_content_many(files) + mock_download_content.assert_called_once() + assert mock_download_content.call_args.kwargs["allow_stale"] is False + + +def test_successful_head_revalidation_clears_stale( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A confirmed 304 supersedes an earlier failed revalidation.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + ok_304 = MagicMock(status_code=304, headers={}) + mock_requests_head.side_effect = [ + requests.exceptions.RequestException("blip"), + ok_304, + ] + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + # The stale memo short-circuits tolerant callers; a strict caller + # triggers a fresh HEAD, which now succeeds and clears the marker. + assert ( + external_files.download_content(url, test_file, allow_stale=False) + == b"cached content" + ) + # Verified now: served from the fresh memo with no more network. + assert external_files.download_content(url, test_file) == b"cached content" + assert mock_requests_head.call_count == 2 + mock_requests_get.assert_not_called() + + +def test_failed_path_replay_names_the_other_url( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A shared cache path replays the failure naming the original URL.""" + test_file = setup_core / "shared.bin" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + with pytest.raises(Invalid, match="first-url"): + external_files.download_content("https://example.com/first-url", test_file) + with pytest.raises(Invalid, match="earlier download of.*first-url"): + external_files.download_content("https://example.com/second-url", test_file) + mock_requests_get.assert_called_once() diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 08751879c2..2022c15bfe 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -12,7 +12,7 @@ from pathlib import Path import subprocess import sys import tarfile -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import zipfile import pytest @@ -23,6 +23,7 @@ from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, _detect_archive_root, + _is_transient_download_error, _rename_with_retry, _tar_extract_all, _zip_extract_all, @@ -187,6 +188,24 @@ def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" +def test_run_command_pops_inherited_pythonpath(mock_subprocess_run: Mock) -> None: + """A PYTHONPATH from the parent environment must not leak into subprocesses.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"]) + assert "PYTHONPATH" not in mock_subprocess_run.call_args[1]["env"] + + +def test_run_command_env_pythonpath_preferred_over_pop( + mock_subprocess_run: Mock, +) -> None: + """A PYTHONPATH set explicitly via ``env`` is passed through.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"], env={"PYTHONPATH": "/idf/tools"}) + assert mock_subprocess_run.call_args[1]["env"]["PYTHONPATH"] == "/idf/tools" + + def test_run_command_passes_cwd(mock_subprocess_run: Mock, tmp_path: Path) -> None: mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") run_command(["cmd"], cwd=str(tmp_path)) @@ -515,16 +534,23 @@ class TestArchiveExtractAll: # --------------------------------------------------------------------------- -def _mock_response(content: bytes, ok: bool = True) -> MagicMock: +def _mock_response( + content: bytes, ok: bool = True, status: int | None = None +) -> MagicMock: + """A fake requests response. The HTTPError carries the response (as + ``raise_for_status`` on a real response) so the transient classifier + can see its ``status``; failures default to a permanent 404.""" + if status is None: + status = 200 if ok else 404 r = MagicMock() r.__enter__.return_value = r r.__exit__.return_value = False - r.status_code = 200 + r.status_code = status r.ok = ok if ok: r.raise_for_status.return_value = None else: - r.raise_for_status.side_effect = req.HTTPError("503") + r.raise_for_status.side_effect = req.HTTPError(str(status), response=r) r.headers = {"content-length": "0"} # suppress ProgressBar r.iter_content.return_value = [content] if content else [] return r @@ -1419,6 +1445,191 @@ class TestDownloadFromMirrors: assert target.exists() assert target.read_bytes() == b"" + @pytest.mark.parametrize("target_kind", ["path", "file-like"]) + def test_transient_failure_retries_mirror_sweep( + self, tmp_path: Path, target_kind: str + ) -> None: + """A transient connect error on the only applicable mirror retries the + whole mirror list with backoff instead of failing the build.""" + target = tmp_path / "idf.tar.xz" if target_kind == "path" else io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("Remote end closed connection"), + _mock_response(b"data"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, target) + assert url == "https://mirror1.com/f" + data = target.read_bytes() if target_kind == "path" else target.getvalue() + assert data == b"data" + assert mock_get.call_count == 2 + mock_sleep.assert_called_once_with(2) + + def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None: + """An HTTP 404 will not heal on its own; fail after a single pass.""" + with ( + patch( + "requests.get", return_value=_mock_response(b"", ok=False, status=404) + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 1 + mock_sleep.assert_not_called() + + def test_transient_failure_exhausts_sweeps(self, tmp_path: Path) -> None: + """A persistent transient error gives up after the configured number + of passes, with 2s/4s backoff, and still lists the attempted URL.""" + with ( + patch("requests.get", side_effect=req.ConnectionError("down")) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 3 + assert mock_sleep.call_args_list == [call(2), call(4)] + assert "https://mirror1.com/f" in str(ei.value) + + def test_mixed_permanent_and_transient_retries_sweep(self, tmp_path: Path) -> None: + """One mirror 404s permanently while another hits a transient error; + the transient failure makes the whole list worth another pass.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=404), + req.ConnectionError("down"), + _mock_response(b"", ok=False, status=404), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest + ) + assert url == "https://mirror2.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_http_5xx_retries_sweep(self, tmp_path: Path) -> None: + """A real 5xx (response attached to the HTTPError) is transient.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=503), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, dest) + assert url == "https://mirror1.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_error_reports_failure_modes_from_all_sweeps(self, tmp_path: Path) -> None: + """A failure mode that changes between sweeps stays in the final + error; the first failure (the one that started the retries) is + chained as the cause.""" + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("dropped by middlebox"), + _mock_response(b"", ok=False, status=404), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert "dropped by middlebox" in str(ei.value) + assert "404" in str(ei.value) + assert isinstance(ei.value.__cause__, req.ConnectionError) + mock_sleep.assert_called_once_with(2) + + def test_exhausted_mid_stream_attempts_not_swept(self) -> None: + """A file-like mirror that spent all its mid-stream attempts is not + retried again at the sweep level (unlike a path target, it has no + part file to resume from on a later sweep).""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[_interrupted_response(b"1234") for _ in range(3)], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 3 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 3 + mock_sleep.assert_not_called() + + def test_mid_stream_drop_then_connect_error_not_swept(self) -> None: + """A connect error on a later attempt (after a mid-stream drop spent + one) also counts as spent budget and does not re-arm the sweep.""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234"), + req.ConnectionError("down"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 2 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 2 + mock_sleep.assert_not_called() + + +def _http_error(status: int) -> req.HTTPError: + """An HTTPError carrying a response with the given status, as raised by + ``raise_for_status`` on a real response.""" + resp = MagicMock() + resp.status_code = status + return req.HTTPError(str(status), response=resp) + + +class TestIsTransientDownloadError: + def test_connection_errors_are_transient(self) -> None: + assert _is_transient_download_error(req.ConnectionError("reset")) + assert _is_transient_download_error(req.Timeout("timed out")) + assert _is_transient_download_error( + req.exceptions.ChunkedEncodingError("dropped") + ) + + def test_http_statuses(self) -> None: + assert not _is_transient_download_error(_http_error(404)) + assert not _is_transient_download_error(_http_error(403)) + assert _is_transient_download_error(_http_error(429)) + assert _is_transient_download_error(_http_error(503)) + + def test_http_error_without_response_is_permanent(self) -> None: + assert not _is_transient_download_error(req.HTTPError("boom")) + + def test_exhausted_resume_attempts_are_permanent(self) -> None: + """download_with_resume already spent its own resume attempts; its + EsphomeError wrapper is not retried again at the sweep level.""" + wrapped = EsphomeError("Failed to download after 3 attempts") + wrapped.__cause__ = req.ConnectionError("down") + assert not _is_transient_download_error(wrapped) + + def test_unrelated_errors_are_permanent(self) -> None: + assert not _is_transient_download_error(OSError("disk full")) + assert not _is_transient_download_error(EsphomeError("size mismatch")) + def test_importing_framework_helpers_does_not_import_requests() -> None: """Importing framework_helpers must not drag in requests. diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 211fbf5112..6e00e5b80f 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -14,7 +14,7 @@ import pytest from esphome import helpers from esphome.address_cache import AddressCache from esphome.core import CORE, EsphomeError -from esphome.helpers import ProgressBar +from esphome.helpers import ProgressBar, format_ip_url @pytest.mark.parametrize( @@ -135,6 +135,22 @@ def test_is_ip_address__invalid(host): assert actual is False +@pytest.mark.parametrize( + ("family", "sockaddr", "expected"), + ( + (socket.AF_INET, ("192.168.1.5", 80), "http://192.168.1.5:80/events"), + (socket.AF_INET6, ("2001:db8::1", 80, 0, 0), "http://[2001:db8::1]:80/events"), + ( + socket.AF_INET6, + ("fe80::1", 8080, 0, 7), + "http://[fe80::1%257]:8080/events", + ), + ), +) +def test_format_ip_url(family, sockaddr, expected): + assert format_ip_url(family, sockaddr, sockaddr[1], "/events") == expected + + @settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_is_ip_address__valid(value): diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 41dd462678..74515e9d4c 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome.component_aliases import COMPONENT_ALIASES from esphome.loader import ( AliasMeta, ComponentManifest, @@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None: assert meta["rp2040"].removal_version == "2027.7.0" +def test_alias_registry_matches_component_tree() -> None: + """The checked-in registry must match a live scan of the component tree.""" + _, meta_map = _build_alias_map() + expected = { + alias: (meta.canonical, meta.removal_version) + for alias, meta in meta_map.items() + } + assert expected == COMPONENT_ALIASES, ( + "esphome/component_aliases.py is out of date; " + "run script/build_alias_registry.py" + ) + + +def test_alias_map_built_from_registry() -> None: + """The runtime alias map comes from the generated registry, not a scan.""" + with ( + patch( + "esphome.component_aliases.COMPONENT_ALIASES", + {"legacy": ("modern", "2099.1.0")}, + ), + patch("esphome.loader._ALIAS_META_CACHE", None), + ): + assert get_alias_metadata() == { + "legacy": AliasMeta(canonical="modern", removal_version="2099.1.0") + } + + def test_get_component_resolves_alias() -> None: """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 14b49a1a05..a40341e194 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,6 +25,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _split_network_devices, _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, @@ -52,6 +53,7 @@ from esphome.__main__ import ( has_non_ip_address, has_ota, has_resolvable_address, + has_web_server_logging, has_web_server_ota, mqtt_get_ip, parse_args, @@ -80,6 +82,7 @@ from esphome.const import ( CONF_DISABLED, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -94,6 +97,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, @@ -816,6 +820,30 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_web_server_only_ip() -> None: + """A web_server-only device with a static IP resolves to that IP for logs.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_logging_web_server_only_mdns() -> None: + """A web_server-only device with a .local name resolves to that hostname.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="test.local") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["test.local"] + + def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: """A resolvable device with only ota: fails logs with a missing-api message.""" setup_core( @@ -855,6 +883,17 @@ def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: assert "set 'use_address'" in msg +def test_unresolved_default_error_logging_suggests_web_server() -> None: + """The missing-api log message lists web_server among the remediations.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "no 'api:' component is configured" in msg + assert "'web_server:'" in msg + + def test_unresolved_default_error_upload_with_ota_is_generic() -> None: """With ota: present the upload error stays generic, not transport-specific.""" setup_core( @@ -2534,6 +2573,30 @@ def test_has_web_server_ota_returns_false_without_config() -> None: assert has_ota() is True +def test_has_web_server_logging_default() -> None: + """has_web_server_logging is True for a default web_server (v2, log on).""" + setup_core(config={CONF_WEB_SERVER: {}}) + assert has_web_server_logging() is True + + +def test_has_web_server_logging_without_config() -> None: + """has_web_server_logging is False when web_server is not configured.""" + setup_core(config={CONF_API: {}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_v1_has_no_events_stream() -> None: + """has_web_server_logging is False for v1, which has no /events endpoint.""" + setup_core(config={CONF_WEB_SERVER: {CONF_VERSION: 1}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_respects_log_disabled() -> None: + """has_web_server_logging is False when the web_server log option is off.""" + setup_core(config={CONF_WEB_SERVER: {CONF_LOG: False}}) + assert has_web_server_logging() is False + + def test_upload_program_web_server_only_auto_dispatches( mock_run_web_server_ota: Mock, mock_run_ota: Mock, @@ -2817,7 +2880,9 @@ def test_upload_program_ota_with_mqtt_resolution( assert exit_code == 0 assert host == "192.168.1.100" - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) @@ -2864,7 +2929,9 @@ def test_upload_program_ota_with_mqtt_empty_broker( assert exit_code == 0 assert host == "192.168.1.50" # Verify MQTT was attempted but failed gracefully - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify we fell back to the IP address expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" @@ -2953,7 +3020,10 @@ def test_show_logs_api( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.1.101"], subscribe_states=True + CORE.config, + ["192.168.1.100", "192.168.1.101"], + subscribe_states=True, + mqtt_resolver=None, ) @@ -2980,7 +3050,7 @@ def test_show_logs_api_no_states( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -3007,7 +3077,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled( assert result == 0 # Should use the FQDN directly, not try MQTT lookup mock_run_logs.assert_called_once_with( - CORE.config, ["device.example.com"], subscribe_states=True + CORE.config, ["device.example.com"], subscribe_states=True, mqtt_resolver=None ) @@ -3035,9 +3105,44 @@ def test_show_logs_api_with_mqtt_fallback( result = show_logs(CORE.config, args, devices) assert result == 0 - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.200"], subscribe_states=True + CORE.config, ["192.168.1.200"], subscribe_states=True, mqtt_resolver=None + ) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_api_mqtt_only_resolve_failure_falls_back_to_mqtt_logs( + mock_mqtt_show_logs: Mock, + mock_mqtt_get_ip: Mock, +) -> None: + """With no addresses at all after a failed MQTT lookup, MQTT logging is used.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT") + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + devices = ["MQTT", "MQTTIP"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" ) @@ -3102,6 +3207,77 @@ def test_show_logs_network_with_mqtt_only( ) +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server( + mock_run_logs: Mock, +) -> None: + """A web_server-only device streams logs over the HTTP SSE endpoint.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + # No API or MQTT configured + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 80, None, None) + + +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server_with_auth_and_port( + mock_run_logs: Mock, +) -> None: + """web_server port and basic-auth credentials are forwarded to the streamer.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + }, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 8080, "admin", "secret") + + +@patch("esphome.web_server_logs.run_logs") +@patch("esphome.mqtt.show_logs") +def test_show_logs_mqtt_preferred_over_web_server( + mock_mqtt_show_logs: Mock, + mock_run_logs: Mock, +) -> None: + """With both MQTT logging and web_server, MQTT wins (API > MQTT > web_server).""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + result = show_logs(CORE.config, args, ["192.168.1.100"]) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once() + mock_run_logs.assert_not_called() + + def test_show_logs_no_method_configured() -> None: """Test show_logs when no remote logging method is configured.""" setup_core( @@ -3333,7 +3509,9 @@ def test_mqtt_get_ip() -> None: result = mqtt_get_ip(config, "user", "pass", "client-id") assert result == ["192.168.1.100", "192.168.1.101"] - mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id") + mock_get_ip.assert_called_once_with( + config, "user", "pass", "client-id", stop_event=None + ) def test_has_resolvable_address() -> None: @@ -3714,6 +3892,37 @@ def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None: assert result == ["unknown.local", "192.168.1.50"] +def test_split_network_devices_direct_only(tmp_path: Path) -> None: + """Direct addresses pass through deduped, with no MQTT flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["192.168.1.50", "device.local", "192.168.1.50"]) == ( + ["192.168.1.50", "device.local"], + False, + ) + + +def test_split_network_devices_mqtt_only(tmp_path: Path) -> None: + """MQTT magic strings produce no direct addresses, only the flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["MQTTIP", "MQTT"]) == ([], True) + + +def test_split_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None: + """Hostnames in ``CORE.address_cache`` are expanded like _resolve_network_devices.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache( + mdns_cache={ + "device-abc123.local": ["10.0.0.1", "10.0.0.2"], + } + ) + + assert _split_network_devices( + ["device-abc123.local", "MQTTIP", "192.168.1.50", "device-abc123.local"] + ) == (["10.0.0.1", "10.0.0.2", "192.168.1.50"], True) + + def test_await_discovery_timeout_returns_empty( caplog: pytest.LogCaptureFixture, ) -> None: @@ -4889,7 +5098,9 @@ def test_upload_program_ota_static_ip_with_mqttip( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with both IPs expected_firmware = ( @@ -4936,7 +5147,9 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( assert host == "192.168.2.50" # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with all unique IPs expected_firmware = ( @@ -4983,7 +5196,9 @@ def test_upload_program_ota_mqttip_deduplication( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100) # Note: Current implementation doesn't dedupe, so we'll get the IP twice @@ -5003,7 +5218,9 @@ def test_show_logs_api_static_ip_with_mqttip( This tests the scenario where a device has manual_ip (static IP) configured and MQTT is also configured. The devices list contains both the static IP - and "MQTTIP" magic string. + and "MQTTIP" magic string. The MQTT lookup must not block startup; it is + handed to run_logs as a deferred resolver instead (issue #18311), while + still being reachable as a fallback for a stale static IP. """ setup_core( config={ @@ -5024,12 +5241,19 @@ def test_show_logs_api_static_ip_with_mqttip( assert result == 0 - # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # The broker must not be contacted before run_logs starts + mock_mqtt_get_ip.assert_not_called() - # Verify run_logs was called with both IPs - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.2.50"], subscribe_states=True + # run_logs gets the static IP immediately plus a deferred MQTT resolver + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) + assert mock_run_logs.call_args.kwargs["subscribe_states"] is True + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + + # Invoking the resolver performs the MQTT lookup (the #11260 fallback) + assert resolver(None) == ["192.168.2.50"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5038,7 +5262,7 @@ def test_show_logs_api_multiple_mqttip_resolves_once( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings.""" + """Test that multiple MQTT magic strings collapse into one deferred resolver.""" setup_core( config={ "logger": {}, @@ -5058,16 +5282,16 @@ def test_show_logs_api_multiple_mqttip_resolves_once( assert result == 0 - # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # Note: "MQTT" is a different magic string from "MQTTIP", but both defer + # to the same single resolver; the broker is not contacted eagerly + mock_mqtt_get_ip.assert_not_called() + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs) - # Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution - # The _resolve_network_devices helper filters out both after first resolution - mock_run_logs.assert_called_once_with( - CORE.config, - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], - subscribe_states=True, + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == ["192.168.2.50", "192.168.2.51"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5105,7 +5329,9 @@ def test_upload_program_ota_mqtt_timeout_fallback( assert host == "192.168.1.100" # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with only the static IP (MQTT failed) expected_firmware = ( @@ -5121,7 +5347,7 @@ def test_show_logs_api_mqtt_timeout_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test show_logs falls back to other devices when MQTT times out.""" + """Test show_logs proceeds with the static IP when MQTT times out.""" setup_core( config={ "logger": {}, @@ -5140,15 +5366,17 @@ def test_show_logs_api_mqtt_timeout_fallback( result = show_logs(CORE.config, args, devices) - # Should succeed using the static IP even though MQTT failed + # Logs start on the static IP without waiting for the broker assert result == 0 + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") - - # Verify run_logs was called with only the static IP (MQTT failed) - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + # The deferred resolver owns the failure policy: it logs a warning and + # returns no addresses so the session keeps running on the known ones + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == [] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -6631,7 +6859,7 @@ def test_command_run_passes_no_states_to_show_logs( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -6672,7 +6900,7 @@ def test_command_run_defaults_subscribe_states_true( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + CORE.config, ["192.168.1.100"], subscribe_states=True, mqtt_resolver=None ) diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 4c2c34dff1..1ae10d0eb5 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -2,6 +2,11 @@ from __future__ import annotations +import json +import threading +import time +from unittest.mock import MagicMock, patch + import pytest from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME @@ -89,3 +94,260 @@ def test_get_esphome_device_ip_missing_name() -> None: match="Cannot discover IP via MQTT as the config does not include the device name:", ): get_esphome_device_ip(config) + + +def _discovery_config() -> dict: + return { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + +def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None: + """Deliver a discovery answer as soon as the network loop starts.""" + + def deliver(*args, **kwargs): + msg = MagicMock() + msg.payload = payload + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = deliver + + +def test_get_esphome_device_ip_success() -> None: + """A device answer on the discovery topic returns its IPs.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + {"name": "test-device", "ip": "10.0.0.5", "ip1": "10.0.0.6"} + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5", "10.0.0.6"] + client.loop_stop.assert_called_once_with() + # Once from on_message on receiving the answer, once from the finally + assert client.disconnect.call_count == 2 + + +def test_get_esphome_device_ip_preset_stop_event_skips_lookup() -> None: + """A stop event set before the call returns [] without touching the broker.""" + stop_event = threading.Event() + stop_event.set() + + with patch("esphome.mqtt.prepare") as mock_prepare: + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + mock_prepare.assert_not_called() + + +def test_get_esphome_device_ip_stop_event_aborts_wait() -> None: + """A stop event set mid-wait exits quietly with no addresses.""" + stop_event = threading.Event() + client = MagicMock() + # Simulate teardown starting right after the network loop spins up + client.loop_start.side_effect = stop_event.set + + start = time.monotonic() + with patch("esphome.mqtt.prepare", return_value=client): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + # An abort is not a failure and must be nowhere near the 25s timeout + assert result == [] + assert time.monotonic() - start < 5 + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_timeout_raises() -> None: + """No answer within the timeout raises EsphomeError (default stop event path).""" + client = MagicMock() + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_stop_during_connect_skips_wait() -> None: + """A stop event set while the broker connect is in flight still cleans up.""" + stop_event = threading.Event() + client = MagicMock() + + def prepare_and_stop(*args): + stop_event.set() + return client + + with patch("esphome.mqtt.prepare", side_effect=prepare_and_stop): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + client.loop_start.assert_not_called() + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_replaces_reconnect_handler( + caplog: pytest.LogCaptureFixture, +) -> None: + """The one-shot discovery client must not inherit the reconnect-forever + handler, which would make loop_stop() join the network thread forever; + its replacement still reports a broker-initiated disconnect.""" + client = MagicMock() + prepare_handler = MagicMock() + client.on_disconnect = prepare_handler + + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + assert client.on_disconnect is not prepare_handler + client.on_disconnect(client, None, 0) + assert "Disconnected from MQTT broker" not in caplog.text + client.on_disconnect(client, None, 5) + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_answer_without_ip_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A device answer with no IP fields fails promptly, not at the timeout.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, client, json.dumps({"name": "test-device"}).encode() + ) + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Device answer did not include an IP address" in caplog.text + + +@pytest.mark.parametrize("payload", [b"not json {", b"123", b"null"]) +def test_get_esphome_device_ip_unparsable_payload_ignored( + caplog: pytest.LogCaptureFixture, + payload: bytes, +) -> None: + """Garbage on the discovery topic must not kill paho's network thread.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start(mock_prepare, client, payload) + + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=0) + + assert "Ignoring unparsable discovery payload" in caplog.text + + +def test_get_esphome_device_ip_broker_disconnect_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broker-initiated disconnect aborts the wait instead of timing out.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client): + + def drop_connection(*args, **kwargs): + client.on_disconnect(client, None, 5) + + client.loop_start.side_effect = drop_connection + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_sends_discovery_ping() -> None: + """Connecting publishes the discovery ping for the device.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + + def connect_then_answer(*args, **kwargs): + on_connect = mock_prepare.call_args.args[3] + on_connect(client, None, None, 0) + msg = MagicMock() + msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode() + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = connect_then_answer + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.publish.assert_called_once_with( + "esphome/ping/test-device", None, retain=False + ) + + +def test_get_esphome_device_ip_disconnect_error_does_not_mask_result( + caplog: pytest.LogCaptureFixture, +) -> None: + """A cleanup failure must not replace the discovery result.""" + client = MagicMock() + # First disconnect (from on_message) succeeds; the finally's fails + client.disconnect.side_effect = [None, OSError("socket already closed")] + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_invalid_address_values_skipped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Non-string or non-printable ip values are skipped, valid ones kept.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + { + "name": "test-device", + "ip": 1234, + "ip1": "x\n[00:00:00][I][forged] fake line", + "ip2": " 10.0.0.5 ", + } + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + assert caplog.text.count("Ignoring invalid address in discovery answer") == 2 + assert "forged" not in "".join( + r.getMessage() for r in caplog.records if "Found IP" in r.getMessage() + ) diff --git a/tests/unit_tests/test_preference_hash_stability.py b/tests/unit_tests/test_preference_hash_stability.py index d3e5fac36a..d8506afae7 100644 --- a/tests/unit_tests/test_preference_hash_stability.py +++ b/tests/unit_tests/test_preference_hash_stability.py @@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on firmware upgrades, or break entity state routing to API clients. Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85): -1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1). - Existing devices have preferences stored under keys derived from it; slot-based - backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it. -2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes). - Sent to API clients and used as the preference key base on key-lookup backends. +1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1). + The entity key sent to API clients and the base of every stored preference key. +2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta + firmware stored preferences under keys derived from it; a future key migration + must reconstruct those keys to recover that data. DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm, the change breaks backward compatibility and will cause data loss. @@ -124,8 +124,9 @@ def test_entity_object_id_hash_stability( """Verify fnv1_hash_object_id produces stable hashes for entity names. CRITICAL: These expected values MUST NOT CHANGE. Existing devices have - preferences stored under keys derived from this legacy hash; changing it - breaks the old-to-new key migration and loses stored preferences. + preferences stored under keys derived from this hash, and it is the entity + key sent to API clients; changing it loses stored preferences and breaks + entity state routing. """ actual = fnv1_hash_object_id(entity_name) assert actual == expected_object_id_hash, ( @@ -144,9 +145,8 @@ def compute_legacy_preference_key( ) -> int: """Compute the legacy preference key: (object_id_hash ^ device_id) ^ version. - This is the key existing devices have data stored under. Slot-based backends - (ESP8266, RP2040) still use it directly; key-lookup backends compute it as the - migration source in EntityBase::make_entity_preference_() (entity_base.cpp). + This is the key EntityBase::make_entity_preference_() (entity_base.cpp) + stores every entity preference under. """ object_id_hash = fnv1_hash_object_id(entity_name) preference_hash = object_id_hash ^ device_id @@ -179,8 +179,8 @@ def test_legacy_preference_key_computation( ) -> None: """Verify legacy preference key computation matches expected values. - This test ensures the formula doesn't change, which would break both slot-based - preference storage and the migration source keys on key-lookup backends. + This test ensures the formula doesn't change, which would lose stored + preferences on every platform. """ actual_key = compute_legacy_preference_key(entity_name, version, device_id) @@ -215,12 +215,12 @@ def test_legacy_preference_key_computation( ], ) def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None: - """Verify fnv1_hash_name produces stable entity keys. + """Verify fnv1_hash_name produces stable raw-name hashes. - CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to - API clients and is the new preference key base; changing the algorithm - would break state routing and lose stored preferences. - Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h. + CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored + preferences under keys derived from this hash; a future key migration must + reconstruct those keys, and changing the algorithm would strand that data. + Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores. """ actual = fnv1_hash_name(entity_name) assert actual == expected_key, ( diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 01683507c1..857795d02f 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -915,3 +915,102 @@ def test_storage_json_load_area(tmp_path: Path) -> None: legacy = storage_json.StorageJSON.load(legacy_path) assert legacy is not None assert legacy.area is None + + +def test_from_esphome_core_without_claiming_a_build(setup_core: Path) -> None: + """claim_build=False carries the build artifact fields from the old + sidecar while validation-derived fields still stamp from CORE.""" + mock_core = MagicMock() + mock_core.name = "my_device" + mock_core.friendly_name = "My Device" + mock_core.comment = None + mock_core.address = "my_device.local" + mock_core.web_port = None + mock_core.target_platform = "esp8266" + mock_core.is_esp32 = False + mock_core.is_nrf52 = False + mock_core.build_path = "/build/my_device" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} + mock_core.target_framework = "arduino" + mock_core.toolchain = Toolchain.PLATFORMIO + mock_core.area = None + + old = storage_json.StorageJSON.from_wizard( + name="my_device", + friendly_name="My Device", + address="my_device.local", + platform="ESP8266", + ) + old.esphome_version = "2025.1.0" + old.firmware_bin_path = Path("/old/firmware.bin") + + result = storage_json.StorageJSON.from_esphome_core( + mock_core, old, claim_build=False + ) + + # Build artifact fields carry from the old sidecar, not this run. + assert result.esphome_version == "2025.1.0" + assert result.firmware_bin_path == Path("/old/firmware.bin") + # Validation-derived fields stamp from CORE. + assert result.build_path == "/build/my_device" + assert result.toolchain == "platformio" + assert result.core_platform == "esp8266" + + # With no old sidecar, no build is claimed at all. + bare = storage_json.StorageJSON.from_esphome_core( + mock_core, None, claim_build=False + ) + assert bare.esphome_version is None + assert bare.firmware_bin_path is None + + +def test_load_strict_distinguishes_missing_from_unreadable(tmp_path: Path) -> None: + """load_strict returns None only for a missing file; corrupt raises.""" + assert storage_json.StorageJSON.load_strict(tmp_path / "missing.json") is None + + corrupt = tmp_path / "corrupt.json" + corrupt.write_text("{truncated") + with pytest.raises(ValueError): + storage_json.StorageJSON.load_strict(corrupt) + + +def test_as_dict_serializes_unset_paths_as_null(setup_core: Path) -> None: + """Unset build/firmware paths serialize as JSON null, not str(None).""" + storage = storage_json.StorageJSON.from_wizard( + name="wiz", + friendly_name="Wiz", + address="wiz.local", + platform="ESP32", + ) + + result = storage.as_dict() + + assert result["build_path"] is None + assert result["firmware_bin_path"] is None + + +def test_load_treats_legacy_none_string_paths_as_unset(tmp_path: Path) -> None: + """Sidecars written before as_dict emitted null hold str(None); those + must load as unset, not as Path("None").""" + file_path = tmp_path / "legacy_none.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "wiz", + "friendly_name": "Wiz", + "esp_platform": "ESP32", + "core_platform": "esp32", + "build_path": "None", + "firmware_bin_path": "None", + } + ) + ) + + result = storage_json.StorageJSON.load(file_path) + + assert result is not None + assert result.build_path is None + assert result.firmware_bin_path is None diff --git a/tests/unit_tests/test_web_server_helpers.py b/tests/unit_tests/test_web_server_helpers.py new file mode 100644 index 0000000000..0280630d69 --- /dev/null +++ b/tests/unit_tests/test_web_server_helpers.py @@ -0,0 +1,64 @@ +"""Unit tests for esphome.web_server_helpers module.""" + +from __future__ import annotations + +import socket + +import pytest + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import EsphomeError +from esphome.web_server_helpers import ( + get_web_server_connection, + resolve_web_server_urls, +) + + +def test_resolve_web_server_urls_maps_ipv4_and_ipv6( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each resolved address becomes an (ip, url) pair with IPv6 bracketing.""" + addr_infos = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80)), + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 7)), + ] + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + assert resolve_web_server_urls("dev.local", 80, "/events") == [ + ("192.168.1.5", "http://192.168.1.5:80/events"), + ("fe80::1", "http://[fe80::1%257]:80/events"), + ] + + +def test_get_web_server_connection_without_auth() -> None: + """Port is returned and credentials are None when no auth is configured.""" + config = {CONF_WEB_SERVER: {CONF_PORT: 80}} + + assert get_web_server_connection(config) == (80, None, None) + + +def test_get_web_server_connection_with_auth() -> None: + """Port and HTTP Basic credentials are returned when auth is configured.""" + config = { + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + } + } + + assert get_web_server_connection(config) == (8080, "admin", "secret") + + +def test_get_web_server_connection_missing_component() -> None: + """A config without web_server raises a clear error.""" + with pytest.raises(EsphomeError, match="web_server.*not configured"): + get_web_server_connection({}) diff --git a/tests/unit_tests/test_web_server_logs.py b/tests/unit_tests/test_web_server_logs.py new file mode 100644 index 0000000000..bbdf37bed7 --- /dev/null +++ b/tests/unit_tests/test_web_server_logs.py @@ -0,0 +1,397 @@ +"""Unit tests for esphome.web_server_logs module.""" + +from __future__ import annotations + +from collections.abc import Iterator +import logging +import socket +from typing import Self +from unittest.mock import MagicMock + +import pytest +import requests +from requests.auth import HTTPBasicAuth + +from esphome import web_server_logs +from esphome.core import EsphomeError +from esphome.web_server_logs import ( + EVENTS_PATH, + WebServerLogsError, + _build_urls, + _consume, + _stream, + run_logs, +) + +# A realistic slice of the web_server /events SSE stream: an initial ping +# carrying the config, a state frame, two log frames (one multi-line), plus +# comment/id/retry lines that must be ignored. +SSE_LINES = [ + "retry: 30000", + "id: 12345", + "event: ping", + 'data: {"title":"dev","log":true}', + "", + "event: state", + 'data: {"id":"sensor-x","state":"ON"}', + "", + "event: log", + "data: \x1b[0;32m[I][main:001]: hello\x1b[0m", + "", + ": keepalive-comment", + "event: log", + "data: line one", + "data: line two", + "", +] + + +class _FakeResponse: + """Minimal stand-in for a streamed ``requests`` response.""" + + def __init__(self, status_code: int, lines: list[str]) -> None: + self.status_code = status_code + self._lines = lines + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def iter_lines(self) -> Iterator[bytes]: + for line in self._lines: + yield line.encode("utf8") + + +@pytest.fixture +def fake_parser() -> MagicMock: + """A LogParser whose parse_line returns the raw line unchanged.""" + parser = MagicMock() + parser.parse_line.side_effect = lambda line, time_str: line + return parser + + +def _patch_resolve( + monkeypatch: pytest.MonkeyPatch, + addr_infos: list[tuple[int, int, int, str, tuple]], +) -> None: + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + +# --------------------------------------------------------------------------- +# _build_urls +# --------------------------------------------------------------------------- + + +def test_build_urls_ipv4(monkeypatch: pytest.MonkeyPatch) -> None: + """An IPv4 host resolves to a plain http://ip:port/events URL.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80))], + ) + + assert _build_urls(["dev.local"], 80) == [ + ("192.168.1.5", f"http://192.168.1.5:80{EVENTS_PATH}") + ] + + +def test_build_urls_ipv6_brackets_and_zone(monkeypatch: pytest.MonkeyPatch) -> None: + """IPv6 literals are bracketed; link-local addresses get a %25 zone index.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 8080, 0, 7))], + ) + + assert _build_urls(["dev.local"], 8080) == [ + ("fe80::1", f"http://[fe80::1%257]:8080{EVENTS_PATH}") + ] + + +def test_build_urls_dedups_and_skips_unresolvable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Duplicate resolved IPs collapse to one URL; resolve errors are skipped.""" + calls: list[str] = [] + + def fake_resolve(host: str, port: int, **kwargs: object) -> list[tuple]: + calls.append(host) + if host == "bad": + raise EsphomeError("nope") + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", port))] + + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", fake_resolve) + + # "good" and "dup" both resolve to 10.0.0.1, "bad" raises. + assert _build_urls(["good", "bad", "dup"], 80) == [ + ("10.0.0.1", f"http://10.0.0.1:80{EVENTS_PATH}") + ] + assert calls == ["good", "bad", "dup"] + + +# --------------------------------------------------------------------------- +# _consume (SSE parsing) +# --------------------------------------------------------------------------- + + +def test_consume_emits_only_log_frames( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """Only event: log data lines are printed; ping/state/comments are ignored.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, SSE_LINES), fake_parser) + + assert printed == [ + "\x1b[0;32m[I][main:001]: hello\x1b[0m", + "line one", + "line two", + ] + + +def test_consume_ignores_unterminated_trailing_frame( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """A log frame without its terminating blank line is not emitted.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, ["event: log", "data: dangling"]), fake_parser) + + assert printed == [] + + +# --------------------------------------------------------------------------- +# _stream +# --------------------------------------------------------------------------- + + +def test_stream_returns_false_when_connect_fails( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A failed connection logs a warning and reports not-connected.""" + + def boom(*args: object, **kwargs: object) -> _FakeResponse: + raise requests.ConnectionError("refused") + + monkeypatch.setattr(requests, "get", boom) + + with caplog.at_level(logging.WARNING): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is False + ) + assert "Could not connect to 10.0.0.1" in caplog.text + + +def test_stream_returns_true_when_established_then_dropped( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A mid-stream drop after connecting reports connected so we reconnect.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + class _DroppingResponse(_FakeResponse): + def iter_lines(self) -> Iterator[bytes]: + yield b"event: log" + yield b"data: before-drop" + yield b"" + raise requests.exceptions.ChunkedEncodingError("connection lost") + + monkeypatch.setattr(requests, "get", lambda *a, **kw: _DroppingResponse(200, [])) + + with caplog.at_level(logging.INFO): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is True + ) + assert printed == ["before-drop"] + assert "reconnecting" in caplog.text + + +# --------------------------------------------------------------------------- +# run_logs +# --------------------------------------------------------------------------- + + +def test_run_logs_streams_then_reconnects_until_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A dropped stream reconnects; KeyboardInterrupt during the pause exits 0.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(200, SSE_LINES)) + + def stop(_delay: float) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", stop) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # The single stream was consumed before the reconnect pause interrupted us. + # run_logs renders through the real LogParser, which prefixes a timestamp, + # so assert on the payloads rather than exact equality. + assert len(printed) == 3 + assert "[I][main:001]: hello" in printed[0] + assert "line one" in printed[1] + assert "line two" in printed[2] + + +def test_run_logs_passes_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None: + """Username + password are forwarded as HTTP Basic auth on the request.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + captured["url"] = url + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, "admin", "secret") == 0 + auth = captured["auth"] + assert isinstance(auth, HTTPBasicAuth) + assert (auth.username, auth.password) == ("admin", "secret") + assert captured["stream"] is True + assert captured["headers"] == {"Accept": "text/event-stream"} + + +def test_run_logs_no_auth_when_credentials_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No auth object is sent when username/password are not configured.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, None, None) == 0 + assert captured["auth"] is None + + +def test_run_logs_raises_on_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 401 aborts with a clear error rather than reconnecting forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(401, [])) + + with pytest.raises(WebServerLogsError, match="Authentication failed"): + run_logs(["dev.local"], 80, "admin", "bad") + + +def test_run_logs_retries_on_transient_status( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A transient non-200 (e.g. 503) is logged and the loop retries.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(503, [])) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert "Unexpected HTTP 503" in caplog.text + + +@pytest.mark.parametrize("status", (403, 404)) +def test_run_logs_raises_on_permanent_status( + monkeypatch: pytest.MonkeyPatch, status: int +) -> None: + """A permanent 403/404 aborts instead of retrying the endpoint forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(status, [])) + + with pytest.raises(WebServerLogsError, match=str(status)): + run_logs(["dev.local"], 80, None, None) + + +def test_run_logs_backs_off_on_repeated_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Consecutive unreachable attempts grow the reconnect delay up to the cap.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + delays: list[float] = [] + + def record(delay: float) -> None: + delays.append(delay) + if len(delays) >= 4: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", record) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # 1 -> 2 -> 4 -> 8 ... doubling, capped at MAX_RECONNECT_DELAY (10.0). + assert delays == [2.0, 4.0, 8.0, 10.0] + + +def test_run_logs_reports_unresolvable( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """When no host resolves, an error is logged and the loop pauses/retries.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + + # Let the first reconnect pause pass so the loop continues, then interrupt + # on the second so the retry path (the ``continue``) is exercised. + sleeps = {"n": 0} + + def sleep(_delay: float) -> None: + sleeps["n"] += 1 + if sleeps["n"] >= 2: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", sleep) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert sleeps["n"] == 2 + assert "Could not resolve" in caplog.text diff --git a/tests/unit_tests/test_web_server_ota.py b/tests/unit_tests/test_web_server_ota.py index 606905e36e..bde04f4db7 100644 --- a/tests/unit_tests/test_web_server_ota.py +++ b/tests/unit_tests/test_web_server_ota.py @@ -46,7 +46,7 @@ def _patch_resolve( for host, port in hosts ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) @@ -475,7 +475,7 @@ def test_run_ota_resolution_failure( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -491,7 +491,7 @@ def test_run_ota_resolution_failure_dashboard_mode( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) monkeypatch.setattr(CORE, "dashboard", True) try: exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -541,7 +541,7 @@ def test_run_ota_multiple_hosts_first_fails( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) with patch( "esphome.web_server_ota.requests.post", @@ -570,7 +570,7 @@ def test_run_ota_all_hosts_return_failure_no_exception( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware) @@ -633,7 +633,7 @@ def test_run_ota_ipv6_url_brackets_host( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("2001:db8::1", 80, 0, 0)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch( @@ -656,7 +656,7 @@ def test_run_ota_ipv6_link_local_includes_scope_id( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 3)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch(