Merge branch 'dev' into proxy-subscribe-acks

This commit is contained in:
Keith Burzinski
2026-08-15 01:19:38 -05:00
committed by GitHub
133 changed files with 7644 additions and 1796 deletions
+2 -1
View File
@@ -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');
}
@@ -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']);
});
});
// ---------------------------------------------------------------------------
+7 -2
View File
@@ -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
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.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
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5
RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0
RUN \
platformio settings set enable_telemetry No \
+164 -73
View File
@@ -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
+84 -3
View File
@@ -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,
)
)
+69 -8
View File
@@ -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
+10
View File
@@ -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"),
}
+5
View File
@@ -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"]
+5
View File
@@ -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"]
+3 -3
View File
@@ -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
+14 -6
View File
@@ -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,
+45 -19
View File
@@ -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"
@@ -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
+2
View File
@@ -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"
@@ -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"
+37 -16
View File
@@ -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):
+2
View File
@@ -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:
@@ -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,
)
+1 -2
View File
@@ -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
+3
View File
@@ -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",
+1 -1
View File
@@ -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,
+59 -22
View File
@@ -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)
+185 -61
View File
@@ -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(
+20 -2
View File
@@ -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)
+138 -10
View File
@@ -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<unsigned>(registers.size()));
}
return {};
}
@@ -165,11 +194,11 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues &registers) {
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 &registers) {
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
+38 -1
View File
@@ -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 &registers);
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<void()> 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
@@ -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)
@@ -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<bool> 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
@@ -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<bool> 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
+6 -2
View File
@@ -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
@@ -3,17 +3,76 @@
#include "esphome/core/log.h"
#include "internal_temperature.h"
#include "Arduino.h"
#include <cmath>
#include <hardware/adc.h>
#include <pico/time.h>
// 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 <pico.h> 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)) {
+8 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
+3
View File
@@ -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)",
+1 -1
View File
@@ -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<LVTouchListener *>(lv_indev_get_user_data(d));
+1 -2
View File
@@ -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):
@@ -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)
-63
View File
@@ -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))
+3
View File
@@ -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"
@@ -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])
@@ -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
+3
View File
@@ -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",
+1 -1
View File
@@ -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
@@ -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"
+73 -35
View File
@@ -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:
@@ -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);
}
@@ -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<uint8_t SZ> 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<uint8_t[SZ + 1]>(id);
bool hasdata = migrate_preference(this->pref_, reinterpret_cast<uint8_t *>(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<uint8_t[SZ + 1]>(old_id);
char temp[SZ + 1];
bool hasdata = this->pref_.load(&temp);
#endif
if (hasdata) {
size_t len = static_cast<uint8_t>(temp[0]);
+2 -2
View File
@@ -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<uint8_t> lc_vec(line_coding, line_coding + 7);
this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec);
@@ -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
}
@@ -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_; }
@@ -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<ManualIP> &manual_ip) {
@@ -523,6 +534,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
global_wifi_component->sta_state_ = static_cast<uint8_t>(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();
+126 -2
View File
@@ -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())
+4
View File
@@ -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
+1 -1
View File
@@ -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 = (
+4 -4
View File
@@ -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; \
+20 -32
View File
@@ -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<char, OBJECT_ID_MAX_LEN> 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
+38 -42
View File
@@ -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<T>() 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<T>() instead, or preferences won't be migrated.
* See https://github.com/esphome/backlog/issues/85
*/
ESPDEPRECATED("Use make_entity_preference<T>() 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
+79 -111
View File
@@ -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,
+4 -25
View File
@@ -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<uint8_t>(str[i]);
}
return hash;
}
/// Extend a FNV-1 hash with an integer (hashes each byte).
template<std::integral T> constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) {
using UnsignedT = std::make_unsigned_t<T>;
@@ -1026,20 +1013,12 @@ template<size_t N> 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<uint8_t>(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<uint8_t>(to_sanitized_char(to_snake_case_char(str[i])));
+7 -7
View File
@@ -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<typename T>
concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) {
{ prefs.load_from_key(type, data, len) } -> std::same_as<bool>;
-25
View File
@@ -1,25 +0,0 @@
#include "esphome/core/preferences.h"
#include "esphome/core/log.h"
#include <cinttypes>
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
-12
View File
@@ -56,17 +56,5 @@ namespace esphome {
static_assert(PreferencesKeyLookupContract<ESPPreferences>,
"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
+2
View File
@@ -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:
+128 -30
View File
@@ -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
+183 -41
View File
@@ -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
)
+178 -83
View File
@@ -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}"
+23 -10
View File
@@ -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.
+1 -1
View File
@@ -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:
+41 -38
View File
@@ -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.<legacy>``/``...<legacy>.<sub>``
@@ -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)
+77 -14
View File
@@ -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
+48 -8
View File
@@ -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.
+14
View File
@@ -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):
+43
View File
@@ -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)
+189
View File
@@ -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
+5 -14
View File
@@ -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 <IP>)")
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:
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+59
View File
@@ -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}")
@@ -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
@@ -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"}})
+6 -16
View File
@@ -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"
)
@@ -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<uint16_t> 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
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <chrono>
#include <initializer_list>
#include <thread>
#include <utility>
#include <gtest/gtest.h>
#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<uint16_t> 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<uint16_t, 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);
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
@@ -11,3 +11,7 @@ binary_sensor:
- platform: hoermann_hcp
is_connected:
name: Garage Connected
light:
- platform: hoermann_hcp
name: Garage Light
@@ -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<uint16_t> 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
@@ -3,51 +3,9 @@
#include <chrono>
#include <thread>
#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<uint16_t> 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
@@ -0,0 +1,761 @@
#include <gtest/gtest.h>
#include <chrono>
#include <thread>
#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 &registers) {
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
+13 -14
View File
@@ -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}"
@@ -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:
@@ -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());
@@ -1,5 +1,5 @@
esphome:
name: host-pref-key-migration
name: host-pref-key-stability
host:
api:
@@ -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:
@@ -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}"
@@ -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}"
)
@@ -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}"
)
@@ -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("<f", 42.5),
TEXT_OLD_KEY: text_pref_payload("hello"),
SWITCH_KEY: b"\x01", # bool: switch was ON
NUMBER_KEY: struct.pack("<f", 42.5),
TEXT_KEY: text_pref_payload("hello"),
},
)
switch_state, number_state, text_state = await boot_and_get_initial_states()
assert switch_state.state is True, (
"Switch state stored under the old preference key was lost"
"Switch state stored under the object_id preference key was lost"
)
assert number_state.state == 42.5, (
"Number value stored under the old preference key was lost"
"Number value stored under the object_id preference key was lost"
)
assert text_state.state == "hello", (
"Text value stored under the old preference key was lost"
"Text value stored under the object_id preference key was lost"
)
# --- Run 2: both keys present with different values. The NEW key holds
# the current data and must win; stale old-key data must never clobber it.
# --- Run 2: raw-name-hash entries from a 2026.8 beta device present
# alongside the object_id entries. The object_id data must win; the
# beta entries are never read.
write_host_prefs(
DEVICE_NAME,
{
SWITCH_OLD_KEY: b"\x00", # stale: OFF
SWITCH_NEW_KEY: b"\x01", # current: ON
NUMBER_OLD_KEY: struct.pack("<f", 42.5), # stale
NUMBER_NEW_KEY: struct.pack("<f", 13.5), # current
TEXT_OLD_KEY: text_pref_payload("hello"), # stale
TEXT_NEW_KEY: text_pref_payload("world"), # current
SWITCH_KEY: b"\x01", # current: ON
SWITCH_BETA_KEY: b"\x00", # beta leftover: OFF
NUMBER_KEY: struct.pack("<f", 13.5), # current
NUMBER_BETA_KEY: struct.pack("<f", 99.5), # beta leftover
TEXT_KEY: text_pref_payload("world"), # current
TEXT_BETA_KEY: text_pref_payload("ignored"), # beta leftover
},
)
switch_state, number_state, text_state = await boot_and_get_initial_states()
assert switch_state.state is True, (
"Stale old-key data overwrote the current new-key switch state"
"Beta raw-name-key data overrode the object_id switch state"
)
assert number_state.state == 13.5, (
"Stale old-key data overwrote the current new-key number value"
"Beta raw-name-key data overrode the object_id number value"
)
assert text_state.state == "world", (
"Stale old-key data overwrote the current new-key text value"
"Beta raw-name-key data overrode the object_id text value"
)
finally:
clear_host_prefs(DEVICE_NAME)

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