mirror of
https://github.com/esphome/esphome.git
synced 2026-10-06 10:56:43 +00:00
Merge branch 'dev' into proxy-subscribe-acks
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ®isters) {
|
||||
this->command_written_at_ = millis();
|
||||
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
|
||||
registers.push_back(command->pressed_value);
|
||||
registers.push_back(0x0000);
|
||||
registers.push_back(command->pressed_value_2);
|
||||
return;
|
||||
}
|
||||
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
|
||||
// Still inside the key-press window, so keep presenting 0x0000.
|
||||
// Between the two events there is nothing to report, including in the second register.
|
||||
push_zeros(registers, 2);
|
||||
return;
|
||||
}
|
||||
@@ -177,8 +206,12 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) {
|
||||
ESP_LOGD(TAG, "Released '%s' command", command->name);
|
||||
this->command_written_at_ = 0;
|
||||
this->next_command_ = nullptr;
|
||||
// A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left
|
||||
// to wait for, so it must not re-arm the watchdog.
|
||||
if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0)
|
||||
this->light_toggle_released_at_ = millis();
|
||||
registers.push_back(command->released_value);
|
||||
registers.push_back(0x0000);
|
||||
registers.push_back(command->released_value_2);
|
||||
}
|
||||
|
||||
void HoermannHcp::on_position_reg_(uint16_t value) {
|
||||
@@ -225,6 +258,13 @@ void HoermannHcp::on_state_reg_(uint16_t value) {
|
||||
ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
|
||||
}
|
||||
|
||||
// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records
|
||||
// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here.
|
||||
void HoermannHcp::on_light_reg_(uint16_t value) {
|
||||
this->set_light_seen_(true);
|
||||
this->set_light_on_((value & 0x0010) != 0);
|
||||
}
|
||||
|
||||
bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
if (!this->valid_) {
|
||||
// Queueing now would fire the command whenever the controller comes back, which may be much later.
|
||||
@@ -236,7 +276,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
return false;
|
||||
}
|
||||
// A new command supersedes any half-open target the door was still travelling to.
|
||||
this->clear_target_();
|
||||
if (command.clears_target)
|
||||
this->clear_target_();
|
||||
this->next_command_ = &command;
|
||||
this->command_queued_at_ = millis();
|
||||
return true;
|
||||
@@ -245,6 +286,31 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
|
||||
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
|
||||
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
|
||||
bool HoermannHcp::toggle_light() {
|
||||
if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) {
|
||||
ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one");
|
||||
return false;
|
||||
}
|
||||
if (!this->queue_command_(COMMAND_TOGGLE_LAMP))
|
||||
return false;
|
||||
this->light_toggles_in_flight_++;
|
||||
return true;
|
||||
}
|
||||
bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; }
|
||||
|
||||
uint8_t HoermannHcp::unsent_light_toggles_() const {
|
||||
return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
bool HoermannHcp::cancel_light_toggle() {
|
||||
// Once the pressed value has been presented the key press is already on the wire, so only an untouched
|
||||
// command can be withdrawn.
|
||||
if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0)
|
||||
return false;
|
||||
ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name);
|
||||
this->drop_command_();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HoermannHcp::stop_door() {
|
||||
if (!is_moving(this->door_state_)) {
|
||||
@@ -270,6 +336,7 @@ bool HoermannHcp::set_position(float position) {
|
||||
if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE))
|
||||
return false;
|
||||
this->target_position_ = position;
|
||||
this->target_queued_at_ = millis();
|
||||
this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING;
|
||||
// A door already travelling that way is on its way; one moving the other way has to turn around first.
|
||||
this->target_started_ = this->door_state_ == this->target_direction_;
|
||||
@@ -292,9 +359,48 @@ void HoermannHcp::set_valid_(bool valid) {
|
||||
}
|
||||
ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_);
|
||||
// Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect.
|
||||
this->drop_command_();
|
||||
// The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards.
|
||||
this->clear_target_();
|
||||
this->forget_light_toggles_();
|
||||
// The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted.
|
||||
this->set_light_seen_(false);
|
||||
this->short_broadcast_logged_ = false;
|
||||
}
|
||||
|
||||
void HoermannHcp::drop_command_() {
|
||||
const bool was_light_toggle = this->is_light_toggle_pending_();
|
||||
// Cleared first so the settling below no longer counts this command among the toggles still to be sent.
|
||||
this->next_command_ = nullptr;
|
||||
this->command_written_at_ = 0;
|
||||
this->clear_target_();
|
||||
if (was_light_toggle) {
|
||||
// A lamp toggle says nothing about where the door was going, so it leaves the target alone.
|
||||
this->light_toggle_settled_();
|
||||
} else {
|
||||
this->clear_target_();
|
||||
}
|
||||
}
|
||||
|
||||
void HoermannHcp::light_toggle_settled_() {
|
||||
if (this->light_toggles_in_flight_ == 0)
|
||||
return;
|
||||
this->light_toggles_in_flight_--;
|
||||
// Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for.
|
||||
if (this->light_toggles_in_flight_ == this->unsent_light_toggles_())
|
||||
this->light_toggle_released_at_ = 0;
|
||||
// The light was showing where the lamp was heading, so it has to be told to look again.
|
||||
this->changed_ = true;
|
||||
}
|
||||
|
||||
void HoermannHcp::forget_light_toggles_() {
|
||||
// Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever.
|
||||
this->light_toggle_released_at_ = 0;
|
||||
// A toggle the door has not been shown yet is still going to fire, so it keeps counting.
|
||||
const uint8_t unsent = this->unsent_light_toggles_();
|
||||
if (this->light_toggles_in_flight_ == unsent)
|
||||
return;
|
||||
this->light_toggles_in_flight_ = unsent;
|
||||
this->changed_ = true;
|
||||
}
|
||||
|
||||
void HoermannHcp::set_door_state_(DoorState state) {
|
||||
@@ -333,4 +439,26 @@ void HoermannHcp::clear_target_() {
|
||||
this->target_started_ = false;
|
||||
}
|
||||
|
||||
void HoermannHcp::set_light_on_(bool on) {
|
||||
if (this->light_on_ == on)
|
||||
return;
|
||||
this->light_on_ = on;
|
||||
this->changed_ = true;
|
||||
if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) {
|
||||
// The door has not been shown a toggle that could explain this, so the lamp was switched at the door.
|
||||
ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on));
|
||||
return;
|
||||
}
|
||||
// The door acted, so one of the toggles it has seen has arrived. Any others still count.
|
||||
this->light_toggle_settled_();
|
||||
}
|
||||
|
||||
void HoermannHcp::set_light_seen_(bool seen) {
|
||||
if (this->light_seen_ == seen)
|
||||
return;
|
||||
this->light_seen_ = seen;
|
||||
// A resting door changes nothing else, so without this the light would never hear about it.
|
||||
this->changed_ = true;
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
|
||||
@@ -22,11 +22,15 @@ enum class DoorState : uint8_t {
|
||||
};
|
||||
|
||||
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
|
||||
// short delay the released value. The second command register remains zero.
|
||||
// short delay the released value. Each half also carries a second register, which only the lamp command uses.
|
||||
struct HoermannHcpCommand {
|
||||
const char *name;
|
||||
uint16_t pressed_value;
|
||||
uint16_t released_value;
|
||||
uint16_t pressed_value_2{0x0000};
|
||||
uint16_t released_value_2{0x0000};
|
||||
// A door command supersedes a half-open target; the lamp has no bearing on where the door is going.
|
||||
bool clears_target{true};
|
||||
};
|
||||
|
||||
class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
@@ -52,19 +56,41 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
bool impulse_door();
|
||||
bool stop_door();
|
||||
bool set_position(float position);
|
||||
bool toggle_light();
|
||||
|
||||
DoorState get_door_state() const { return this->door_state_; }
|
||||
float get_current_position() const { return this->current_position_; }
|
||||
bool is_valid() const { return this->valid_; }
|
||||
bool is_light_on() const { return this->light_on_; }
|
||||
// False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection
|
||||
// valid without saying anything about the lamp, so is_light_on() would still be its default.
|
||||
bool is_light_known() const { return this->light_seen_; }
|
||||
// Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the
|
||||
// lamp still reads as its old self, so this is what a request has to be judged against.
|
||||
bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); }
|
||||
// Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright
|
||||
// instead of fighting it. Returns false if there is nothing to cancel.
|
||||
bool cancel_light_toggle();
|
||||
|
||||
protected:
|
||||
// True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert.
|
||||
bool is_light_toggle_pending_() const;
|
||||
// Toggles the door has not been shown yet, which is at most the one still waiting in the command slot.
|
||||
uint8_t unsent_light_toggles_() const;
|
||||
void record_response_();
|
||||
// Returns false when the bus controller has not fetched the previous command yet.
|
||||
bool queue_command_(const HoermannHcpCommand &command);
|
||||
// Throws away the pending command, taking any armed target with it unless the command was the lamp toggle.
|
||||
void drop_command_();
|
||||
// One outstanding toggle reached the lamp, was withdrawn, or was thrown away.
|
||||
void light_toggle_settled_();
|
||||
// Stops expecting the toggles the door has already been shown to reach the lamp.
|
||||
void forget_light_toggles_();
|
||||
// Appends the two key-press registers and advances the pending command's press/release state.
|
||||
void push_command_registers_(modbus::RegisterValues ®isters);
|
||||
void on_position_reg_(uint16_t value);
|
||||
void on_state_reg_(uint16_t value);
|
||||
void on_light_reg_(uint16_t value);
|
||||
|
||||
void set_valid_(bool valid);
|
||||
void set_door_state_(DoorState state);
|
||||
@@ -72,6 +98,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
void update_current_position_();
|
||||
bool has_target_() const { return this->target_position_ != 0.0f; }
|
||||
void clear_target_();
|
||||
void set_light_on_(bool on);
|
||||
void set_light_seen_(bool seen);
|
||||
|
||||
CallbackManager<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
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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,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,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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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; \
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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])));
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+59
@@ -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"}})
|
||||
@@ -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
|
||||
|
||||
@@ -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 ®isters) {
|
||||
this->door.on_write_registers(BROADCAST_REG, registers);
|
||||
this->pump();
|
||||
}
|
||||
|
||||
void report_lamp(bool on) { this->report_broadcast(lamp_broadcast(on ? 0x0010 : 0x0000)); }
|
||||
|
||||
// Runs the hub's notification pass and lets the resulting publishes settle.
|
||||
void pump() {
|
||||
this->door.update();
|
||||
this->settle();
|
||||
}
|
||||
|
||||
void settle() {
|
||||
for (int i = 0; i < 4; i++)
|
||||
this->state.loop();
|
||||
}
|
||||
|
||||
bool entity_on() { return this->state.remote_values.is_on(); }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// The lamp state lives in the low byte of register 6; only 0x14 and 0x10 mean lit.
|
||||
TEST(HoermannHcpLightTest, LampStateIsDecodedFromTheBroadcast) {
|
||||
HoermannHcp door;
|
||||
EXPECT_FALSE(door.is_light_on());
|
||||
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0014));
|
||||
EXPECT_TRUE(door.is_light_on());
|
||||
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
EXPECT_FALSE(door.is_light_on());
|
||||
}
|
||||
|
||||
// The lamp command is the only one that drives the second command register, on both halves of the press.
|
||||
TEST(HoermannHcpLightTest, LampCommandUsesTheSecondRegister) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
ASSERT_FALSE(door.is_light_on());
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0100);
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
auto [released, released_2] = poll_command(door);
|
||||
EXPECT_EQ(released, 0x0800);
|
||||
EXPECT_EQ(released_2, 0x0200);
|
||||
|
||||
// The command is spent, so the next poll carries nothing.
|
||||
auto [idle, idle_2] = poll_command(door);
|
||||
EXPECT_EQ(idle, 0x0000);
|
||||
EXPECT_EQ(idle_2, 0x0000);
|
||||
}
|
||||
|
||||
// Toggling the lamp must not disturb a cover position the door is still travelling to.
|
||||
TEST(HoermannHcpLightTest, LampToggleKeepsTheCoverTarget) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
|
||||
ASSERT_TRUE(door.set_position(0.5f));
|
||||
consume_command(door);
|
||||
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
consume_command(door);
|
||||
|
||||
// Past the target: the door still has to be stopped despite the lamp command in between.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE
|
||||
EXPECT_EQ(pressed_2, 0x0000);
|
||||
}
|
||||
|
||||
// A lamp toggle occupies the single command slot, so a target stop falling due while it waits to be fetched
|
||||
// has to wait too. The target stays armed and the stop goes out on the next position report, which costs the
|
||||
// door a little overshoot but never loses the stop.
|
||||
TEST(HoermannHcpLightTest, LampToggleDelaysButDoesNotLoseTheTargetStop) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
|
||||
ASSERT_TRUE(door.set_position(0.5f));
|
||||
consume_command(door);
|
||||
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
// The door passes the target while the lamp toggle still holds the slot, so the lamp goes out first.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0100);
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(door);
|
||||
|
||||
// The target survived the refusal, so the next position report still stops the door.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0079, 0x0100}));
|
||||
auto [stop, stop_2] = poll_command(door);
|
||||
EXPECT_EQ(stop, 0x0240); // COMMAND_IMPULSE
|
||||
EXPECT_EQ(stop_2, 0x0000);
|
||||
}
|
||||
|
||||
// The target's start deadline is its own, so toggling the lamp cannot keep a stale target alive.
|
||||
TEST(HoermannHcpLightTest, LampToggleDoesNotExtendTheTargetWatchdog) {
|
||||
TestableHoermannHcp door;
|
||||
door.connection_timeout_ms_ = 20;
|
||||
connect_controller(door);
|
||||
// The door is closing, so an opening target is armed but not yet under way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
|
||||
ASSERT_TRUE(door.set_position(0.5f));
|
||||
consume_command(door);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
consume_command(door);
|
||||
door.update();
|
||||
|
||||
// The target expired on its own schedule, so a later opening move runs freely.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100}));
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0000);
|
||||
EXPECT_EQ(pressed_2, 0x0000);
|
||||
}
|
||||
|
||||
// Without a bus controller the command cannot be delivered, and the caller is told.
|
||||
TEST(HoermannHcpLightTest, LampCommandIsRefusedWhileDisconnected) {
|
||||
HoermannHcp door;
|
||||
EXPECT_FALSE(door.toggle_light());
|
||||
}
|
||||
|
||||
// Switching the entity on sends one toggle, and the door's own report does not send a second.
|
||||
TEST(HoermannHcpLightPlatformTest, CommandTogglesOnceAndSettles) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
auto [pressed, pressed_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(pressed, 0x0100);
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(fixture.door); // release, clearing the slot
|
||||
|
||||
// The lamp is now on, and the resulting broadcast must not queue another toggle.
|
||||
fixture.report_lamp(true);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
auto [idle, idle_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(idle, 0x0000);
|
||||
EXPECT_EQ(idle_2, 0x0000);
|
||||
}
|
||||
|
||||
// A broadcast arriving while a toggle is queued must not reconcile against the not-yet-inverted lamp, which
|
||||
// would cancel the user's own command.
|
||||
TEST(HoermannHcpLightPlatformTest, BroadcastDuringPendingToggleKeepsTheCommand) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
|
||||
// A door movement sets changed_, firing the state callback while the toggle is still queued.
|
||||
fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100}));
|
||||
|
||||
EXPECT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A lamp switched on at the door itself has to reach the entity.
|
||||
TEST(HoermannHcpLightPlatformTest, DoorDrivenChangeReachesTheEntity) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
ASSERT_FALSE(fixture.entity_on());
|
||||
|
||||
fixture.report_lamp(true);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
|
||||
fixture.report_lamp(false);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A refused command must leave the entity showing the lamp, not the request.
|
||||
TEST(HoermannHcpLightPlatformTest, RefusedCommandRepublishesTheLamp) {
|
||||
LightFixture fixture; // never connected, so the hub refuses every command
|
||||
|
||||
fixture.command(true);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A reversing press once the toggle is already on the wire cannot stop it, so the entity has to end up
|
||||
// showing the lamp rather than the request that was refused.
|
||||
TEST(HoermannHcpLightPlatformTest, RefusedPressAfterFetchShowsWhereTheLampIsHeading) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
poll_command(fixture.door); // the controller fetches the press, so it can no longer be cancelled
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
|
||||
fixture.command(false);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
|
||||
// A door movement while the refused toggle is still on the wire must not pull the entity back either.
|
||||
fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100}));
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
|
||||
// The toggle lands and the door confirms it; the entity must already agree.
|
||||
fixture.report_lamp(true);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// The lamp is only reported some time after the key press is released, so an unrelated door broadcast in
|
||||
// that gap must not publish the state the lamp is about to leave.
|
||||
TEST(HoermannHcpLightPlatformTest, DoorMovementDoesNotFlipTheEntityBeforeTheLampReports) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
poll_command(fixture.door);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(fixture.door); // release, so nothing is pending any more
|
||||
ASSERT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
ASSERT_FALSE(fixture.door.is_light_on()); // the lamp has still not been reported
|
||||
|
||||
fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100}));
|
||||
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A toggle the controller never fetches is eventually dropped, and nothing else will ever report the lamp
|
||||
// moving, so the entity has to be brought back to what the lamp actually is.
|
||||
TEST(HoermannHcpLightPlatformTest, DroppedToggleReturnsTheEntityToTheLamp) {
|
||||
LightFixture fixture;
|
||||
fixture.door.connection_timeout_ms_ = 20;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
|
||||
// The controller keeps broadcasting but never fetches the command, so the connection stays up.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
fixture.pump();
|
||||
|
||||
EXPECT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// Losing the bus controller discards the queued toggle too, so the entity must not keep showing it once the
|
||||
// controller is back and still reporting the lamp unchanged.
|
||||
TEST(HoermannHcpLightPlatformTest, ToggleLostWithTheConnectionReturnsTheEntityToTheLamp) {
|
||||
LightFixture fixture;
|
||||
fixture.door.connection_timeout_ms_ = 20;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
fixture.pump(); // the connection times out and the command goes with it
|
||||
ASSERT_FALSE(fixture.door.is_valid());
|
||||
|
||||
connect_controller(fixture.door);
|
||||
fixture.report_lamp(false);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// The lamp can be switched at the door while the bus is quiet, so what was read before an outage must not
|
||||
// decide whether a toggle is needed after it.
|
||||
TEST(HoermannHcpLightPlatformTest, LampIsNotTrustedAcrossAConnectionLoss) {
|
||||
LightFixture fixture;
|
||||
fixture.door.connection_timeout_ms_ = 20;
|
||||
fixture.bring_up();
|
||||
fixture.report_lamp(true);
|
||||
ASSERT_TRUE(fixture.entity_on());
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
fixture.pump();
|
||||
ASSERT_FALSE(fixture.door.is_valid());
|
||||
|
||||
// Back on the bus, but nothing has said what the lamp is doing yet.
|
||||
connect_controller(fixture.door);
|
||||
fixture.pump();
|
||||
ASSERT_TRUE(fixture.door.is_valid());
|
||||
ASSERT_FALSE(fixture.door.is_light_known());
|
||||
|
||||
fixture.command(false);
|
||||
auto [idle, idle_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(idle, 0x0000);
|
||||
EXPECT_EQ(idle_2, 0x0000);
|
||||
}
|
||||
|
||||
// A door that never reports the lamp leaves the entity unable to do anything, so it must not look healthy.
|
||||
TEST(HoermannHcpLightPlatformTest, UnreportedLampIsFlaggedOnTheEntity) {
|
||||
LightFixture fixture;
|
||||
connect_controller(fixture.door);
|
||||
fixture.pump();
|
||||
ASSERT_TRUE(fixture.door.is_valid());
|
||||
EXPECT_TRUE(fixture.output.status_has_warning());
|
||||
|
||||
fixture.report_lamp(false);
|
||||
EXPECT_FALSE(fixture.output.status_has_warning());
|
||||
}
|
||||
|
||||
// Two outstanding toggles leave the lamp where it started, so a third tap has to be judged against that and
|
||||
// withdraw the one still waiting rather than deciding nothing is needed.
|
||||
TEST(HoermannHcpLightPlatformTest, ThirdTapWithTwoTogglesOutstandingIsHonoured) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
poll_command(fixture.door);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(fixture.door); // the first toggle is released but not reported back
|
||||
fixture.command(false);
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2);
|
||||
|
||||
// Two toggles cancel out, so asking for on again means withdrawing the second one.
|
||||
fixture.command(true);
|
||||
EXPECT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
EXPECT_EQ(fixture.door.light_toggles_in_flight_, 1);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// The boot replay is the first write and nothing else, so a real command arriving before the hub's next poll
|
||||
// must not be mistaken for it and swallowed.
|
||||
TEST(HoermannHcpLightPlatformTest, CommandBeforeTheFirstPollIsNotMistakenForTheBootReplay) {
|
||||
LightFixture fixture;
|
||||
connect_controller(fixture.door);
|
||||
fixture.settle(); // the boot replay lands here, while the lamp is still unknown
|
||||
|
||||
// The first status broadcast arrives, but the hub has not polled yet, so no callback has fired.
|
||||
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(fixture.door.is_light_known());
|
||||
|
||||
fixture.command(true);
|
||||
auto [pressed, pressed_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
}
|
||||
|
||||
// On boot the restored state is replayed through write_state() before the lamp has ever been read. A lamp
|
||||
// that is already on must not be switched off by that replay.
|
||||
TEST(HoermannHcpLightPlatformTest, RestoredStateOnBootDoesNotCommandTheLamp) {
|
||||
LightFixture fixture;
|
||||
// The controller is already up and reporting the lamp lit before the entity's first loop.
|
||||
connect_controller(fixture.door);
|
||||
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
||||
ASSERT_TRUE(fixture.door.is_light_on());
|
||||
|
||||
fixture.settle();
|
||||
auto [idle, idle_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(idle, 0x0000);
|
||||
EXPECT_EQ(idle_2, 0x0000);
|
||||
// Once the platform has read the lamp the entity follows it, still without commanding anything.
|
||||
fixture.pump();
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// Bus traffic makes the connection valid without saying anything about the lamp, so a request arriving before
|
||||
// the first status broadcast must not be judged against a lamp state that was never read.
|
||||
TEST(HoermannHcpLightPlatformTest, RequestBeforeTheLampIsReportedDoesNotCommandTheLamp) {
|
||||
LightFixture fixture;
|
||||
// The controller polls for commands, which is enough to connect but carries no lamp register.
|
||||
connect_controller(fixture.door);
|
||||
fixture.pump();
|
||||
ASSERT_TRUE(fixture.door.is_valid());
|
||||
ASSERT_FALSE(fixture.door.is_light_known());
|
||||
|
||||
fixture.command(true);
|
||||
auto [idle, idle_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(idle, 0x0000);
|
||||
EXPECT_EQ(idle_2, 0x0000);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A toggle that has been released onto the wire is no longer pending, but the lamp has not reported it yet.
|
||||
// A reversing request in that window is a real request and has to be sent, not swallowed.
|
||||
TEST(HoermannHcpLightPlatformTest, ReversingRequestAfterReleaseQueuesASecondToggle) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
poll_command(fixture.door);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(fixture.door); // released, so nothing is pending and the lamp is still unreported
|
||||
ASSERT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
ASSERT_FALSE(fixture.door.is_light_on());
|
||||
|
||||
fixture.command(false);
|
||||
auto [pressed, pressed_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
|
||||
// The first toggle lands and is reported, but the entity is already heading for off.
|
||||
fixture.report_lamp(true);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
|
||||
// The second toggle lands too, and the lamp finally agrees with the request.
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(fixture.door);
|
||||
fixture.report_lamp(false);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A refusal that has no toggle on the wire leaves nothing outstanding, so it must not latch the entity
|
||||
// against the next lamp change the door reports.
|
||||
TEST(HoermannHcpLightPlatformTest, RefusalWithoutAToggleStillFollowsTheLamp) {
|
||||
LightFixture fixture;
|
||||
fixture.door.connection_timeout_ms_ = 20;
|
||||
fixture.bring_up();
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
fixture.pump();
|
||||
ASSERT_FALSE(fixture.door.is_valid());
|
||||
|
||||
// Refused because the bus is down, so no toggle is heading for the lamp.
|
||||
fixture.command(true);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
|
||||
// The controller returns and reports the lamp switched on at the door itself.
|
||||
connect_controller(fixture.door);
|
||||
fixture.report_lamp(true);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A lamp toggle carries no target, so dropping it unfetched must leave the cover's target alone.
|
||||
TEST(HoermannHcpLightTest, DroppedLampToggleKeepsTheCoverTarget) {
|
||||
TestableHoermannHcp door;
|
||||
door.connection_timeout_ms_ = 20;
|
||||
connect_controller(door);
|
||||
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
|
||||
ASSERT_TRUE(door.set_position(0.5f));
|
||||
consume_command(door);
|
||||
|
||||
// The controller keeps broadcasting but stops fetching, so the lamp toggle expires on its own.
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100}));
|
||||
door.update();
|
||||
|
||||
// The target survived the lamp toggle being dropped, so the door is still stopped on the way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE
|
||||
EXPECT_EQ(pressed_2, 0x0000);
|
||||
}
|
||||
|
||||
// A door that takes the key press but never actually switches the lamp must not leave the entity showing the
|
||||
// request for ever; the wait has to end so the entity can settle back on what the door reports.
|
||||
TEST(HoermannHcpLightPlatformTest, ToggleTheDoorIgnoresStopsBeingWaitedFor) {
|
||||
LightFixture fixture;
|
||||
fixture.door.connection_timeout_ms_ = 20;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
consume_command(fixture.door); // the door takes press and release, then does nothing
|
||||
ASSERT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
fixture.report_lamp(false); // the lamp is still off, and keeps saying so
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A resting door's first broadcast changes nothing except the lamp finally being reported, so unless that
|
||||
// counts as a change the light never hears about it and swallows the first command.
|
||||
TEST(HoermannHcpLightPlatformTest, FirstLampReportReachesTheEntity) {
|
||||
LightFixture fixture;
|
||||
// A command poll connects the controller without saying anything about the lamp.
|
||||
connect_controller(fixture.door);
|
||||
fixture.pump();
|
||||
ASSERT_FALSE(fixture.door.is_light_known());
|
||||
|
||||
// Closed, at rest, lamp off: every field matches the defaults the hub started with.
|
||||
fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000}));
|
||||
ASSERT_TRUE(fixture.door.is_light_known());
|
||||
|
||||
fixture.command(true);
|
||||
auto [pressed, pressed_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
}
|
||||
|
||||
// A lost connection means the door can travel unwatched, so a target left armed would stop it long afterwards.
|
||||
// Which command happened to be in the slot must not change that.
|
||||
TEST(HoermannHcpLightTest, ConnectionLossWithALampTogglePendingClearsTheTarget) {
|
||||
TestableHoermannHcp door;
|
||||
door.connection_timeout_ms_ = 20;
|
||||
connect_controller(door);
|
||||
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
|
||||
ASSERT_TRUE(door.set_position(0.5f));
|
||||
consume_command(door);
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
door.update();
|
||||
ASSERT_FALSE(door.is_valid());
|
||||
|
||||
// Back on the bus and travelling past where the target was: nothing should stop the door now.
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0000);
|
||||
EXPECT_EQ(pressed_2, 0x0000);
|
||||
}
|
||||
|
||||
// Withdrawing a later toggle must not take the deadline of the one already on the wire with it, or a door
|
||||
// that never reports the lamp would leave the entity waiting for ever.
|
||||
TEST(HoermannHcpLightPlatformTest, WithdrawingALaterToggleKeepsTheWatchdogArmed) {
|
||||
LightFixture fixture;
|
||||
fixture.door.connection_timeout_ms_ = 20;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
consume_command(fixture.door); // the first toggle is released but never reported back
|
||||
fixture.command(false);
|
||||
ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2);
|
||||
fixture.command(true); // withdraws the second, leaving the first outstanding
|
||||
ASSERT_EQ(fixture.door.light_toggles_in_flight_, 1);
|
||||
|
||||
// The door still says nothing about the lamp, so the wait has to time out on its own.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
fixture.report_lamp(false);
|
||||
EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A request refused while the lamp is unknown must leave the entity idle. Republishing unconditionally would
|
||||
// re-enter write_state() on every loop, so the platform would never stop asking to be written.
|
||||
TEST(HoermannHcpLightPlatformTest, RefusedRequestLeavesTheEntityIdle) {
|
||||
LightFixture fixture;
|
||||
connect_controller(fixture.door);
|
||||
fixture.settle();
|
||||
ASSERT_FALSE(fixture.door.is_light_known());
|
||||
|
||||
// The lamp is unknown and the entity already shows off, so asking for off cannot be serviced or displayed.
|
||||
fixture.command(false);
|
||||
const int settled_writes = fixture.output.writes;
|
||||
fixture.settle();
|
||||
EXPECT_EQ(fixture.output.writes, settled_writes);
|
||||
}
|
||||
|
||||
// A door that acts on the key press and reports the lamp before the release is even fetched leaves nothing
|
||||
// outstanding. Arming the watchdog on that release anyway would leave it firing on every poll and abandoning
|
||||
// the next toggle the moment it is queued.
|
||||
TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarmed) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
poll_command(door); // the door is shown the key press
|
||||
|
||||
// The door acts on it and reports the lamp straight away, which settles the count.
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
||||
ASSERT_EQ(door.light_toggles_in_flight_, 0);
|
||||
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(door); // the release, with nothing left to wait for
|
||||
EXPECT_EQ(door.light_toggle_released_at_, 0u);
|
||||
}
|
||||
|
||||
// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be
|
||||
// adopted back to what is known rather than turned into a command.
|
||||
TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) {
|
||||
LightFixture fixture{light::LIGHT_ALWAYS_ON};
|
||||
connect_controller(fixture.door);
|
||||
fixture.settle();
|
||||
|
||||
auto [idle, idle_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(idle, 0x0000);
|
||||
EXPECT_EQ(idle_2, 0x0000);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A reversing press before the toggle is fetched cancels it, so the lamp never moves.
|
||||
TEST(HoermannHcpLightPlatformTest, ReversingPressCancelsTheQueuedToggle) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
|
||||
fixture.command(false);
|
||||
EXPECT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
|
||||
// Nothing is left for the controller to fetch, so the lamp stays off as asked.
|
||||
auto [pressed, pressed_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(pressed, 0x0000);
|
||||
EXPECT_EQ(pressed_2, 0x0000);
|
||||
}
|
||||
|
||||
// A lamp switched at the door itself is not one of our toggles landing, so a toggle the door has not even
|
||||
// been shown has to keep counting.
|
||||
TEST(HoermannHcpLightTest, DoorSideLampChangeLeavesAnUnsentToggleCounted) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
||||
|
||||
EXPECT_EQ(door.light_toggles_in_flight_, 1);
|
||||
// The toggle still in the slot will invert what the door just reported.
|
||||
EXPECT_FALSE(door.is_light_heading_on());
|
||||
}
|
||||
|
||||
// Once the toggles left over are all still waiting in the slot, nothing the door has seen is outstanding,
|
||||
// so the wait has to end rather than time out against toggles the door was never shown.
|
||||
TEST(HoermannHcpLightTest, SettlingTheLastSentToggleEndsTheWait) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
consume_command(door); // shown to the door, so the wait for a lamp report starts
|
||||
ASSERT_TRUE(door.toggle_light()); // queued behind it, never shown
|
||||
ASSERT_NE(door.light_toggle_released_at_, 0u);
|
||||
|
||||
// The door reports the lamp change the first toggle caused, leaving only the unsent one.
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
||||
|
||||
ASSERT_EQ(door.light_toggles_in_flight_, 1);
|
||||
EXPECT_EQ(door.light_toggle_released_at_, 0u);
|
||||
}
|
||||
|
||||
// The watchdog gives up on the toggles the door was shown, but one still waiting in the command slot is
|
||||
// going to fire, so it keeps counting.
|
||||
TEST(HoermannHcpLightTest, WatchdogKeepsAToggleTheDoorHasNotSeen) {
|
||||
TestableHoermannHcp door;
|
||||
// Wide enough that the toggle queued after the sleep cannot expire before update() runs.
|
||||
door.connection_timeout_ms_ = 200;
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
consume_command(door); // shown to the door, which then says nothing about the lamp
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(220));
|
||||
// Queued just now, so only the wait for the first toggle is overdue.
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
door.update();
|
||||
|
||||
EXPECT_EQ(door.light_toggles_in_flight_, 1);
|
||||
EXPECT_TRUE(door.is_light_toggle_pending_());
|
||||
EXPECT_TRUE(door.is_light_heading_on());
|
||||
}
|
||||
|
||||
// Only the parity of the outstanding count says where the lamp is heading, so the count must not run away.
|
||||
TEST(HoermannHcpLightTest, TogglesAreRefusedOnceTooManyAreOutstanding) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
|
||||
// The door takes every key press but never reports the lamp, so nothing is ever confirmed.
|
||||
for (int i = 0; i < 4; i++) {
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
consume_command(door);
|
||||
}
|
||||
|
||||
EXPECT_FALSE(door.toggle_light());
|
||||
EXPECT_EQ(door.light_toggles_in_flight_, 4);
|
||||
}
|
||||
|
||||
// A controller that stops carrying the lamp register leaves nothing refreshing it, so the entity has to flag
|
||||
// itself rather than command against what was read before.
|
||||
TEST(HoermannHcpLightPlatformTest, BroadcastWithoutTheLampRegisterMarksItUnknown) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
ASSERT_TRUE(fixture.door.is_light_known());
|
||||
|
||||
fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000}));
|
||||
|
||||
EXPECT_FALSE(fixture.door.is_light_known());
|
||||
EXPECT_TRUE(fixture.output.status_has_warning());
|
||||
}
|
||||
|
||||
// A publish of ours only reaches write_state() a loop pass later. If the lamp changed at the door in that
|
||||
// gap, the write still carries the old value and must not be taken for a request to invert the lamp.
|
||||
TEST(HoermannHcpLightPlatformTest, PublishOvertakenByTheLampIsNotARequest) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
// A door command holds the only command slot, so the request below is refused and the lamp published back.
|
||||
ASSERT_TRUE(fixture.door.open_door());
|
||||
|
||||
auto call = fixture.state.make_call();
|
||||
call.set_state(true);
|
||||
call.perform();
|
||||
fixture.state.loop(); // the refusal happens here and schedules the publish for a later pass
|
||||
|
||||
// The slot frees up and the lamp is switched on at the door before that publish arrives.
|
||||
consume_command(fixture.door);
|
||||
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
||||
fixture.settle();
|
||||
|
||||
EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp::testing
|
||||
@@ -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
-1
@@ -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}"
|
||||
)
|
||||
|
||||
+46
-43
@@ -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
Reference in New Issue
Block a user