mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edeaded161 | ||
|
|
09a3b1a3d1 | ||
|
|
10d232dc40 | ||
|
|
663f462877 | ||
|
|
b05465145f | ||
|
|
990fc402fd | ||
|
|
dd51624fbb | ||
|
|
45a056e337 | ||
|
|
37782f7206 | ||
|
|
945c2458b3 | ||
|
|
7420d23867 | ||
|
|
191686c5b3 | ||
|
|
137351fa8d | ||
|
|
db5173697a | ||
|
|
c7940382a9 | ||
|
|
87045ab9c0 | ||
|
|
f1c4086778 | ||
|
|
f337d0acff | ||
|
|
9cc05b30d4 | ||
|
|
e0b68c4d6d | ||
|
|
8e624b4117 | ||
|
|
787a909aa4 | ||
|
|
905485b673 | ||
|
|
99677390e0 | ||
|
|
f2121130f9 | ||
|
|
1a01c34ec4 | ||
|
|
3c46cc9c35 | ||
|
|
e192ec8fee | ||
|
|
8a1aa5753d |
@@ -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
|
||||
|
||||
@@ -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.0b2
|
||||
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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,6 @@ from logging import getLogger
|
||||
from esphome import automation, core
|
||||
from esphome.automation import Condition, maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server, zigbee
|
||||
from esphome.components.const import CONF_ON_STATE_CHANGE
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -28,7 +27,6 @@ from esphome.const import (
|
||||
CONF_STATE,
|
||||
CONF_TIMING,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_WEB_SERVER,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_BATTERY_CHARGING,
|
||||
DEVICE_CLASS_CARBON_MONOXIDE,
|
||||
@@ -59,9 +57,11 @@ from esphome.const import (
|
||||
DEVICE_CLASS_VIBRATION,
|
||||
DEVICE_CLASS_WINDOW,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers
|
||||
from esphome.core.entity_helpers import (
|
||||
entity_duplicate_validator,
|
||||
lazy_load_validator,
|
||||
mqtt_component_class,
|
||||
queue_entity_register,
|
||||
setup_device_class,
|
||||
setup_entity,
|
||||
@@ -433,14 +433,14 @@ def validate_publish_initial_state(value):
|
||||
|
||||
|
||||
_BINARY_SENSOR_SCHEMA = (
|
||||
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
|
||||
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
|
||||
.extend(cv.MQTT_COMPONENT_SCHEMA)
|
||||
.extend(zigbee.BINARY_SENSOR_SCHEMA)
|
||||
.extend(entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BinarySensor),
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
|
||||
mqtt.MQTTBinarySensorComponent
|
||||
mqtt_component_class("MQTTBinarySensorComponent")
|
||||
),
|
||||
cv.Exclusive(
|
||||
CONF_PUBLISH_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
|
||||
@@ -505,7 +505,7 @@ _BINARY_SENSOR_SCHEMA = (
|
||||
|
||||
|
||||
_BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor"))
|
||||
_BINARY_SENSOR_SCHEMA.add_extra(zigbee.validate_binary_sensor)
|
||||
_BINARY_SENSOR_SCHEMA.add_extra(lazy_load_validator("zigbee", "validate_binary_sensor"))
|
||||
|
||||
|
||||
def binary_sensor_schema(
|
||||
@@ -607,14 +607,12 @@ async def setup_binary_sensor_core_(var, config):
|
||||
|
||||
CORE.add_job(_build_binary_sensor_automations, var, config)
|
||||
|
||||
if mqtt_id := config.get(CONF_MQTT_ID):
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
await mqtt.register_mqtt_component(mqtt_, config)
|
||||
await entity_helpers.setup_entity_integrations(var, config)
|
||||
|
||||
if web_server_config := config.get(CONF_WEB_SERVER):
|
||||
await web_server.add_entity_config(var, web_server_config)
|
||||
if "zigbee" in CORE.loaded_integrations:
|
||||
from esphome.components import zigbee
|
||||
|
||||
await zigbee.setup_binary_sensor(var, config)
|
||||
await zigbee.setup_binary_sensor(var, config)
|
||||
|
||||
|
||||
async def register_binary_sensor(var, config):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -63,7 +63,6 @@ from esphome.const import (
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["network"]
|
||||
@@ -333,68 +332,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
# Platforms whose MQTT components subscribe to an object_id-derived command topic.
|
||||
# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus
|
||||
# text, whose MQTT component subscribes a command topic that cannot be overridden.
|
||||
_COMMAND_TOPIC_PLATFORMS = frozenset(
|
||||
{
|
||||
"alarm_control_panel",
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
"datetime",
|
||||
"fan",
|
||||
"light",
|
||||
"lock",
|
||||
"number",
|
||||
"select",
|
||||
"switch",
|
||||
"text",
|
||||
"update",
|
||||
"valve",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Platforms whose MQTT components derive extra sub-topics (position/command,
|
||||
# mode/command, speed/command, ...) from the object_id, each with its own config
|
||||
# key; custom state and command topics cannot exempt them from conflicting.
|
||||
_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"})
|
||||
|
||||
|
||||
def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool:
|
||||
"""Check whether more than one entity actually uses an object_id-derived topic.
|
||||
|
||||
An empty topic_prefix disables default topics entirely, custom state and
|
||||
command topics avoid the default topics, and disabling discovery (globally
|
||||
or per entity) avoids the discovery config topic.
|
||||
"""
|
||||
if config[CONF_TOPIC_PREFIX]:
|
||||
platform = entities[0].platform
|
||||
if platform in _SUB_TOPIC_PLATFORMS:
|
||||
return True
|
||||
if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1:
|
||||
return True
|
||||
if (
|
||||
platform in _COMMAND_TOPIC_PLATFORMS
|
||||
and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1
|
||||
):
|
||||
return True
|
||||
if not config[CONF_DISCOVERY]:
|
||||
return False
|
||||
discovery_entities = sum(
|
||||
entity.config.get(CONF_DISCOVERY, True) for entity in entities
|
||||
)
|
||||
return discovery_entities > 1
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts(
|
||||
"mqtt builds default topics and discovery topics from the entity object_id, "
|
||||
"which is the name converted to ASCII",
|
||||
conflict_filter=_topics_conflict,
|
||||
)
|
||||
|
||||
|
||||
def exp_mqtt_message(config):
|
||||
if config is None:
|
||||
return cg.optional(cg.TemplateArguments(MQTTMessage))
|
||||
|
||||
@@ -3,7 +3,6 @@ from esphome.components import web_server_base
|
||||
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL
|
||||
from esphome.core.entity_helpers import validate_no_object_id_conflicts
|
||||
from esphome.cpp_types import EntityBase
|
||||
|
||||
AUTO_LOAD = ["web_server_base"]
|
||||
@@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
},
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts(
|
||||
"prometheus builds metric labels from the entity object_id, "
|
||||
"which is the name converted to ASCII"
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID])
|
||||
|
||||
@@ -99,8 +99,12 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) {
|
||||
// Forward received RF data to API server
|
||||
#if defined(USE_API) && defined(USE_RADIO_FREQUENCY)
|
||||
if (api::global_api_server != nullptr) {
|
||||
api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(),
|
||||
&data.get_raw_data());
|
||||
#ifdef USE_DEVICES
|
||||
uint32_t device_id = this->get_device_id();
|
||||
#else
|
||||
uint32_t device_id = 0;
|
||||
#endif
|
||||
api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data());
|
||||
}
|
||||
#endif
|
||||
return false; // Don't consume the event, allow other listeners to process it
|
||||
|
||||
@@ -3,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"
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -10,7 +10,11 @@ from esphome.components import web_server_base
|
||||
from esphome.components.logger import request_log_listener
|
||||
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
|
||||
# Re-exported so entity components and external components keep importing
|
||||
# these from web_server; defined outside this package so entity base
|
||||
# schemas do not need to import it.
|
||||
from esphome.const import ( # noqa: F401
|
||||
CONF_AUTH,
|
||||
CONF_COMPRESSION,
|
||||
CONF_CSS_INCLUDE,
|
||||
@@ -26,6 +30,8 @@ from esphome.const import (
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_SORTING_GROUP_ID,
|
||||
CONF_SORTING_WEIGHT,
|
||||
CONF_TYPE,
|
||||
CONF_USERNAME,
|
||||
CONF_VERSION,
|
||||
@@ -39,6 +45,7 @@ from esphome.const import (
|
||||
PLATFORM_RTL87XX,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import WEBSERVER_SORTING_SCHEMA # noqa: F401
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -49,9 +56,7 @@ AUTO_LOAD = ["json", "web_server_base"]
|
||||
AUTH_TYPE_BASIC = "basic"
|
||||
AUTH_TYPE_DIGEST = "digest"
|
||||
|
||||
CONF_SORTING_GROUP_ID = "sorting_group_id"
|
||||
CONF_SORTING_GROUPS = "sorting_groups"
|
||||
CONF_SORTING_WEIGHT = "sorting_weight"
|
||||
CONF_ALLOWED_ORIGINS = "allowed_origins"
|
||||
|
||||
|
||||
@@ -225,27 +230,6 @@ sorting_group = {
|
||||
cv.Optional(CONF_SORTING_WEIGHT): cv.float_,
|
||||
}
|
||||
|
||||
WEBSERVER_SORTING_SCHEMA = cv.Schema(
|
||||
{
|
||||
# The per-entity web_server block is cosmetic dashboard ordering —
|
||||
# mark the whole block advanced; the children inherit via the cascade.
|
||||
cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema(
|
||||
{
|
||||
cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer),
|
||||
cv.Optional(CONF_SORTING_WEIGHT): cv.All(
|
||||
cv.requires_component("web_server"),
|
||||
cv.float_,
|
||||
),
|
||||
cv.Optional(CONF_SORTING_GROUP_ID): cv.All(
|
||||
cv.requires_component("web_server"),
|
||||
cv.use_id(cg.int_),
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -14,10 +14,12 @@ from esphome.components.esp32.const import (
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .const import (
|
||||
# CONF_ENDPOINT, CONF_MAX_EP_NUMBER, CONF_REPORT, CONF_USE_DEVICE_TYPE and
|
||||
# REPORT are re-exported for existing consumers of this package's namespace.
|
||||
from .const import ( # noqa: F401
|
||||
CONF_ENDPOINT,
|
||||
CONF_MAX_EP_NUMBER,
|
||||
CONF_ON_JOIN,
|
||||
@@ -45,12 +47,7 @@ from .zigbee_esp32 import (
|
||||
validate_sensor_esp32,
|
||||
zigbee_require_vfs_select,
|
||||
)
|
||||
from .zigbee_zephyr import (
|
||||
zephyr_binary_sensor,
|
||||
zephyr_number,
|
||||
zephyr_sensor,
|
||||
zephyr_switch,
|
||||
)
|
||||
from .zigbee_zephyr import zephyr_number, zephyr_sensor, zephyr_switch
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,35 +56,10 @@ CODEOWNERS = ["@luar123", "@tomaszduda23"]
|
||||
CONFLICTS_WITH = ["openthread"]
|
||||
|
||||
|
||||
def _check_report_deprecation(value: str) -> str:
|
||||
if str(value).lower() in ("coordinator", "enable"):
|
||||
_LOGGER.warning(
|
||||
"Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_REPORT): cv.All(
|
||||
cv.requires_component("zigbee"),
|
||||
cv.requires_component("esp32"),
|
||||
_check_report_deprecation,
|
||||
cv.enum(REPORT, lower=True),
|
||||
),
|
||||
cv.Optional(CONF_ENDPOINT): cv.All(
|
||||
cv.requires_component("zigbee"),
|
||||
cv.requires_component("esp32"),
|
||||
cv.int_range(1, CONF_MAX_EP_NUMBER),
|
||||
),
|
||||
cv.Optional(CONF_USE_DEVICE_TYPE): cv.All(
|
||||
cv.requires_component("zigbee"),
|
||||
cv.requires_component("esp32"),
|
||||
cv.boolean,
|
||||
),
|
||||
}
|
||||
)
|
||||
BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_binary_sensor)
|
||||
# Defined in esphome.core.entity_helpers so entity base schemas can reference
|
||||
# them without importing this package; re-exported here for existing consumers.
|
||||
BASE_SCHEMA = entity_helpers.ZIGBEE_BASE_ENTITY_SCHEMA
|
||||
BINARY_SENSOR_SCHEMA = entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA
|
||||
SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_sensor)
|
||||
SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch)
|
||||
NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number)
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
from enum import IntEnum
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.const import (
|
||||
|
||||
# The entity schema keys and report enum live outside this package so
|
||||
# entity base schemas can use them without importing it; re-imported here
|
||||
# so zigbee code keeps its existing import paths.
|
||||
from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
|
||||
CONF_ENDPOINT,
|
||||
CONF_REPORT,
|
||||
CONF_USE_DEVICE_TYPE,
|
||||
DEVICE_CLASS_CURRENT,
|
||||
DEVICE_CLASS_DURATION,
|
||||
DEVICE_CLASS_ENERGY,
|
||||
@@ -48,6 +55,10 @@ from esphome.const import (
|
||||
UNIT_WATT,
|
||||
UNIT_WATT_HOURS,
|
||||
)
|
||||
from esphome.core.entity_helpers import ( # noqa: F401 # pylint: disable=unused-import
|
||||
ZIGBEE_MAX_EP_NUMBER as CONF_MAX_EP_NUMBER,
|
||||
ZIGBEE_REPORT as REPORT,
|
||||
)
|
||||
|
||||
zigbee_ns = cg.esphome_ns.namespace("zigbee")
|
||||
ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component)
|
||||
@@ -56,22 +67,10 @@ BinaryAttrs = zigbee_ns.struct("BinaryAttrs")
|
||||
AnalogAttrs = zigbee_ns.struct("AnalogAttrs")
|
||||
AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput")
|
||||
|
||||
report = zigbee_ns.enum("ZigbeeReportT")
|
||||
REPORT = {
|
||||
"coordinator": report.ZIGBEE_REPORT_COORDINATOR,
|
||||
"enable": report.ZIGBEE_REPORT_ENABLE,
|
||||
"force": report.ZIGBEE_REPORT_FORCE,
|
||||
"default": report.ZIGBEE_REPORT_DEFAULT,
|
||||
}
|
||||
|
||||
CONF_ENDPOINT = "endpoint"
|
||||
CONF_MAX_EP_NUMBER = 239
|
||||
CONF_ON_JOIN = "on_join"
|
||||
CONF_WIPE_ON_BOOT = "wipe_on_boot"
|
||||
CONF_REPORT = "report"
|
||||
CONF_ROUTER = "router"
|
||||
CONF_POWER_SOURCE = "power_source"
|
||||
CONF_USE_DEVICE_TYPE = "use_device_type"
|
||||
POWER_SOURCE = {
|
||||
"UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN
|
||||
"MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# These two schema keys live in esphome.components.const so entity base
|
||||
# schemas can use them without importing this package.
|
||||
from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
|
||||
CONF_ZIGBEE_BINARY_SENSOR,
|
||||
CONF_ZIGBEE_ID,
|
||||
)
|
||||
|
||||
CONF_MAX_EP_NUMBER_ZEPHYR = 8
|
||||
CONF_ZIGBEE_ID = "zigbee_id"
|
||||
CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
|
||||
CONF_ZIGBEE_SENSOR = "zigbee_sensor"
|
||||
CONF_ZIGBEE_SWITCH = "zigbee_switch"
|
||||
CONF_ZIGBEE_NUMBER = "zigbee_number"
|
||||
|
||||
@@ -57,15 +57,6 @@ ZigbeeSensor = zigbee_ns.class_("ZigbeeSensor", cg.Component)
|
||||
ZigbeeSwitch = zigbee_ns.class_("ZigbeeSwitch", cg.Component)
|
||||
ZigbeeNumber = zigbee_ns.class_("ZigbeeNumber", cg.Component)
|
||||
|
||||
zephyr_binary_sensor = cv.Schema(
|
||||
{
|
||||
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent),
|
||||
cv.OnlyWith(CONF_ZIGBEE_BINARY_SENSOR, ["nrf52", "zigbee"]): cv.declare_id(
|
||||
ZigbeeBinarySensor
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
zephyr_sensor = cv.Schema(
|
||||
{
|
||||
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent),
|
||||
|
||||
+8
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.8.0b2"
|
||||
__version__ = "2026.9.0-dev"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
@@ -387,6 +387,7 @@ CONF_ENABLE_PRIVATE_NETWORK_ACCESS = "enable_private_network_access"
|
||||
CONF_ENABLE_RRM = "enable_rrm"
|
||||
CONF_ENABLE_TIME = "enable_time"
|
||||
CONF_ENCRYPTION = "encryption"
|
||||
CONF_ENDPOINT = "endpoint"
|
||||
CONF_ENERGY = "energy"
|
||||
CONF_ENTITY_CATEGORY = "entity_category"
|
||||
CONF_ENTITY_ID = "entity_id"
|
||||
@@ -885,6 +886,7 @@ CONF_REFERENCE_VOLTAGE = "reference_voltage"
|
||||
CONF_REFRESH = "refresh"
|
||||
CONF_RELABEL = "relabel"
|
||||
CONF_REPEAT = "repeat"
|
||||
CONF_REPORT = "report"
|
||||
CONF_REPOSITORY = "repository"
|
||||
CONF_RESET = "reset"
|
||||
CONF_RESET_DURATION = "reset_duration"
|
||||
@@ -962,6 +964,8 @@ CONF_SLEEP_DURATION = "sleep_duration"
|
||||
CONF_SLEEP_PIN = "sleep_pin"
|
||||
CONF_SLEEP_WHEN_DONE = "sleep_when_done"
|
||||
CONF_SONY = "sony"
|
||||
CONF_SORTING_GROUP_ID = "sorting_group_id"
|
||||
CONF_SORTING_WEIGHT = "sorting_weight"
|
||||
CONF_SOURCE = "source"
|
||||
CONF_SOURCE_ID = "source_id"
|
||||
CONF_SPEAKER = "speaker"
|
||||
@@ -1093,6 +1097,7 @@ CONF_UPDATE_ON_BOOT = "update_on_boot"
|
||||
CONF_URL = "url"
|
||||
CONF_USE_ABBREVIATIONS = "use_abbreviations"
|
||||
CONF_USE_ADDRESS = "use_address"
|
||||
CONF_USE_DEVICE_TYPE = "use_device_type"
|
||||
CONF_USE_DMA = "use_dma"
|
||||
CONF_USE_FAHRENHEIT = "use_fahrenheit"
|
||||
CONF_USERNAME = "username"
|
||||
@@ -1144,6 +1149,8 @@ CONF_Y = "y"
|
||||
CONF_Y_GRID = "y_grid"
|
||||
CONF_YEAR = "year"
|
||||
CONF_ZERO = "zero"
|
||||
CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
|
||||
CONF_ZIGBEE_ID = "zigbee_id"
|
||||
|
||||
TYPE_GIT = "git"
|
||||
TYPE_LOCAL = "local"
|
||||
|
||||
@@ -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
|
||||
|
||||
+245
-112
@@ -1,6 +1,8 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
import functools
|
||||
from importlib import import_module
|
||||
from importlib.util import find_spec
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
@@ -9,12 +11,22 @@ from esphome.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DISABLED_BY_DEFAULT,
|
||||
CONF_ENDPOINT,
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_INTERNAL,
|
||||
CONF_MQTT_ID,
|
||||
CONF_NAME,
|
||||
CONF_REPORT,
|
||||
CONF_SORTING_GROUP_ID,
|
||||
CONF_SORTING_WEIGHT,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USE_DEVICE_TYPE,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WEB_SERVER_ID,
|
||||
CONF_ZIGBEE_BINARY_SENSOR,
|
||||
CONF_ZIGBEE_ID,
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.config import (
|
||||
@@ -22,89 +34,28 @@ from esphome.core.config import (
|
||||
ICON_MAX_LENGTH,
|
||||
UNIT_OF_MEASUREMENT_MAX_LENGTH,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
|
||||
from esphome.cpp_generator import MockObj, MockObjClass, 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 +318,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 +338,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 +447,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 +568,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 +602,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 +633,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,
|
||||
@@ -667,3 +647,156 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-integration entity schema fragments
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Entity base schemas offer mqtt/web_server/zigbee options, but importing
|
||||
# those packages pulls their full dependency chains (mqtt and zigbee both
|
||||
# import the esp32 package) into every entity component import. The
|
||||
# fragments below are built from cheap primitives instead: MockObjClass
|
||||
# identity is string-based, so the class handles here are interchangeable
|
||||
# with the ones the integrations declare, and every key is guarded by
|
||||
# ``cv.requires_component``/``cv.OnlyWith``, which consult
|
||||
# ``CORE.loaded_integrations`` at validation time without importing. The
|
||||
# owning integrations re-export the shared schema names and key strings so
|
||||
# each stays defined once.
|
||||
|
||||
_WebServer = cg.esphome_ns.namespace("web_server").class_(
|
||||
"WebServer", cg.Component, cg.Controller
|
||||
)
|
||||
|
||||
WEBSERVER_SORTING_SCHEMA = cv.Schema(
|
||||
{
|
||||
# The per-entity web_server block is cosmetic dashboard ordering —
|
||||
# mark the whole block advanced; the children inherit via the cascade.
|
||||
cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema(
|
||||
{
|
||||
cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(_WebServer),
|
||||
cv.Optional(CONF_SORTING_WEIGHT): cv.All(
|
||||
cv.requires_component("web_server"),
|
||||
cv.float_,
|
||||
),
|
||||
cv.Optional(CONF_SORTING_GROUP_ID): cv.All(
|
||||
cv.requires_component("web_server"),
|
||||
cv.use_id(cg.int_),
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
_mqtt_ns = cg.esphome_ns.namespace("mqtt")
|
||||
_MQTTComponent = _mqtt_ns.class_("MQTTComponent", cg.Component)
|
||||
|
||||
|
||||
def mqtt_component_class(name: str) -> MockObjClass:
|
||||
"""Handle for a per-entity mqtt::<name> companion class."""
|
||||
return _mqtt_ns.class_(name, _MQTTComponent)
|
||||
|
||||
|
||||
ZIGBEE_MAX_EP_NUMBER = 239
|
||||
|
||||
_zigbee_ns = cg.esphome_ns.namespace("zigbee")
|
||||
_ZigbeeComponent = _zigbee_ns.class_("ZigbeeComponent", cg.Component)
|
||||
_zigbee_report = _zigbee_ns.enum("ZigbeeReportT")
|
||||
ZIGBEE_REPORT = {
|
||||
"coordinator": _zigbee_report.ZIGBEE_REPORT_COORDINATOR,
|
||||
"enable": _zigbee_report.ZIGBEE_REPORT_ENABLE,
|
||||
"force": _zigbee_report.ZIGBEE_REPORT_FORCE,
|
||||
"default": _zigbee_report.ZIGBEE_REPORT_DEFAULT,
|
||||
}
|
||||
|
||||
|
||||
def _check_report_deprecation(value: str) -> str:
|
||||
if str(value).lower() in ("coordinator", "enable"):
|
||||
_LOGGER.warning(
|
||||
"Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
ZIGBEE_BASE_ENTITY_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_REPORT): cv.All(
|
||||
cv.requires_component("zigbee"),
|
||||
cv.requires_component("esp32"),
|
||||
_check_report_deprecation,
|
||||
cv.enum(ZIGBEE_REPORT, lower=True),
|
||||
),
|
||||
cv.Optional(CONF_ENDPOINT): cv.All(
|
||||
cv.requires_component("zigbee"),
|
||||
cv.requires_component("esp32"),
|
||||
cv.int_range(1, ZIGBEE_MAX_EP_NUMBER),
|
||||
),
|
||||
cv.Optional(CONF_USE_DEVICE_TYPE): cv.All(
|
||||
cv.requires_component("zigbee"),
|
||||
cv.requires_component("esp32"),
|
||||
cv.boolean,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Entity platform -> (config key, zigbee C++ class). A unit test checks each
|
||||
# class against the owning declaration in zigbee_zephyr.
|
||||
_ZIGBEE_ENTITY_CLASSES = {
|
||||
"binary_sensor": (CONF_ZIGBEE_BINARY_SENSOR, "ZigbeeBinarySensor"),
|
||||
}
|
||||
|
||||
|
||||
def _zigbee_entity_schema(platform: str) -> cv.Schema:
|
||||
conf_key, class_name = _ZIGBEE_ENTITY_CLASSES[platform]
|
||||
return ZIGBEE_BASE_ENTITY_SCHEMA.extend(
|
||||
{
|
||||
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(
|
||||
_ZigbeeComponent
|
||||
),
|
||||
cv.OnlyWith(conf_key, ["nrf52", "zigbee"]): cv.declare_id(
|
||||
_zigbee_ns.class_(class_name, cg.Component)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
ZIGBEE_BINARY_SENSOR_SCHEMA = _zigbee_entity_schema("binary_sensor")
|
||||
|
||||
|
||||
def lazy_load_validator(
|
||||
component: str, name: str
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Schema extra delegating to ``components.<component>.<name>`` when loaded."""
|
||||
if find_spec(f"esphome.components.{component}") is None:
|
||||
raise ValueError(f"No such component {component!r}")
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
if component not in CORE.loaded_integrations:
|
||||
return config
|
||||
module = import_module(f"esphome.components.{component}")
|
||||
if (delegate := getattr(module, name, None)) is None:
|
||||
raise ValueError(f"{component} has no validator {name!r}")
|
||||
return delegate(config)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
async def setup_entity_integrations(var: MockObj, config: ConfigType) -> MockObj | None:
|
||||
"""Register the mqtt companion and web_server entry for an entity.
|
||||
|
||||
Imports the integrations lazily; returns the mqtt companion (or None)
|
||||
so callers can apply integration specific options to it.
|
||||
"""
|
||||
mqtt_ = None
|
||||
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
|
||||
from esphome.components import mqtt
|
||||
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
await mqtt.register_mqtt_component(mqtt_, config)
|
||||
|
||||
if web_server_config := config.get(CONF_WEB_SERVER):
|
||||
from esphome.components import web_server
|
||||
|
||||
await web_server.add_entity_config(var, web_server_config)
|
||||
|
||||
return mqtt_
|
||||
|
||||
+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
|
||||
|
||||
+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
|
||||
|
||||
|
||||
|
||||
+5
-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"))
|
||||
|
||||
|
||||
+24
-37
@@ -269,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
|
||||
@@ -329,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>``
|
||||
@@ -344,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
|
||||
|
||||
|
||||
@@ -367,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
|
||||
|
||||
|
||||
@@ -537,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)
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.3.1
|
||||
click==8.3.3
|
||||
aioesphomeapi==45.10.1
|
||||
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}")
|
||||
+1
-1
@@ -557,7 +557,7 @@ def lint_constants_usage():
|
||||
# Maximum allowed CONF_ constants in esphome/const.py.
|
||||
# This file is frozen — new constants go in esphome/components/const/__init__.py.
|
||||
# Decrease this number when constants are moved out of const.py.
|
||||
CONST_PY_MAX_CONF = 1017
|
||||
CONST_PY_MAX_CONF = 1024
|
||||
|
||||
|
||||
@lint_content_check(include=["esphome/const.py"])
|
||||
|
||||
@@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None:
|
||||
|
||||
|
||||
def test_esp32_defaults_are_valid() -> None:
|
||||
"""esp32 pins the ESP-IDF reference rate and exposes active (default on)."""
|
||||
"""esp32 pins the ESP-IDF reference rate and exposes active (default on).
|
||||
|
||||
Without wifi loaded, the conditional window default falls back to the
|
||||
historical 30 ms; the wifi-aware resolution is covered by the
|
||||
esp32_ble_tracker component tests.
|
||||
"""
|
||||
config = ESP32_SCHEMA({})
|
||||
assert to_ble_units(config["interval"]) == 512
|
||||
assert to_ble_units(config["window"]) == 48
|
||||
|
||||
@@ -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"}})
|
||||
@@ -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)
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Tests for the MQTT object_id conflict filter.
|
||||
|
||||
MQTT still builds default topics and discovery topics from the sanitized
|
||||
object_id, so entity names that only differ in characters lost during
|
||||
sanitizing conflict there; _topics_conflict() exempts entities that never
|
||||
use an object_id-derived topic. See https://github.com/esphome/backlog/issues/85
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.mqtt import (
|
||||
_COMMAND_TOPIC_PLATFORMS,
|
||||
_SUB_TOPIC_PLATFORMS,
|
||||
_topics_conflict,
|
||||
)
|
||||
from esphome.config_validation import Invalid
|
||||
from esphome.const import (
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_DISCOVERY,
|
||||
CONF_NAME,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_TOPIC_PREFIX,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.core.entity_helpers import (
|
||||
entity_duplicate_validator,
|
||||
validate_no_object_id_conflicts,
|
||||
)
|
||||
|
||||
COMPONENTS_DIR = Path(__file__).parents[4] / "esphome" / "components"
|
||||
|
||||
REASON = "mqtt builds default topics from the entity object_id"
|
||||
|
||||
|
||||
# MQTT infrastructure sources, not entity components
|
||||
_NON_ENTITY_MQTT_SOURCES = {"mqtt_client", "mqtt_component"}
|
||||
# The date, time and datetime MQTT components all belong to the datetime platform
|
||||
_DATETIME_STEMS = {"date", "time", "datetime"}
|
||||
|
||||
|
||||
def test_command_topic_platforms_in_sync() -> None:
|
||||
"""Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe.
|
||||
|
||||
Drift silently reintroduces shared subscribe topics, so this derives the set
|
||||
from the C++ components that actually call subscribe(); that also catches
|
||||
platforms like text that subscribe a command topic without exposing a
|
||||
command_topic key in their schema.
|
||||
"""
|
||||
expected: set[str] = set()
|
||||
for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"):
|
||||
if path.stem in _NON_ENTITY_MQTT_SOURCES:
|
||||
continue
|
||||
if "this->subscribe" not in path.read_text(encoding="utf-8"):
|
||||
continue
|
||||
stem = path.stem.removeprefix("mqtt_")
|
||||
expected.add("datetime" if stem in _DATETIME_STEMS else stem)
|
||||
assert expected == _COMMAND_TOPIC_PLATFORMS
|
||||
|
||||
|
||||
def test_sub_topic_platforms_in_sync() -> None:
|
||||
"""Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics.
|
||||
|
||||
Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra
|
||||
topics such as position/command from the object_id.
|
||||
"""
|
||||
expected = {
|
||||
path.stem.removeprefix("mqtt_")
|
||||
for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h")
|
||||
if path.stem != "mqtt_component"
|
||||
and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8")
|
||||
}
|
||||
assert expected == _SUB_TOPIC_PLATFORMS
|
||||
|
||||
|
||||
def test_conflict_filter_exempts_custom_topics() -> None:
|
||||
"""Test that custom state topics with discovery off avoid the conflict."""
|
||||
validator = entity_duplicate_validator("sensor")
|
||||
# Both entities have custom state topics and discovery disabled per entity,
|
||||
# so no object_id-derived MQTT topic is used
|
||||
validator(
|
||||
{
|
||||
CONF_NAME: "Датчик открытия",
|
||||
CONF_STATE_TOPIC: "custom/topic/a",
|
||||
CONF_DISCOVERY: False,
|
||||
}
|
||||
)
|
||||
validator(
|
||||
{
|
||||
CONF_NAME: "Датчик закрытия",
|
||||
CONF_STATE_TOPIC: "custom/topic/b",
|
||||
CONF_DISCOVERY: False,
|
||||
}
|
||||
)
|
||||
|
||||
component_validator = validate_no_object_id_conflicts(
|
||||
REASON, conflict_filter=_topics_conflict
|
||||
)
|
||||
config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
|
||||
assert component_validator(config) is config
|
||||
|
||||
# Without the filter the same conflicts are fatal
|
||||
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
|
||||
validate_no_object_id_conflicts(REASON)({})
|
||||
|
||||
|
||||
def test_conflict_on_default_command_topic() -> None:
|
||||
"""Test that commandable platforms conflict through their default command topic.
|
||||
|
||||
Custom state topics with discovery off are not enough for platforms that also
|
||||
subscribe to an object_id-derived command topic.
|
||||
"""
|
||||
validator = entity_duplicate_validator("switch")
|
||||
validator(
|
||||
{
|
||||
CONF_NAME: "Датчик открытия",
|
||||
CONF_STATE_TOPIC: "custom/topic/a",
|
||||
CONF_DISCOVERY: False,
|
||||
}
|
||||
)
|
||||
validator(
|
||||
{
|
||||
CONF_NAME: "Датчик закрытия",
|
||||
CONF_STATE_TOPIC: "custom/topic/b",
|
||||
CONF_DISCOVERY: False,
|
||||
}
|
||||
)
|
||||
|
||||
component_validator = validate_no_object_id_conflicts(
|
||||
REASON, conflict_filter=_topics_conflict
|
||||
)
|
||||
mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
|
||||
# Both switches share the default command topic: rejected
|
||||
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
|
||||
component_validator(mqtt_config)
|
||||
|
||||
# With custom command topics as well, nothing derives from the object_id
|
||||
CORE.reset()
|
||||
validator = entity_duplicate_validator("switch")
|
||||
validator(
|
||||
{
|
||||
CONF_NAME: "Датчик открытия",
|
||||
CONF_STATE_TOPIC: "custom/topic/a",
|
||||
CONF_COMMAND_TOPIC: "custom/cmd/a",
|
||||
CONF_DISCOVERY: False,
|
||||
}
|
||||
)
|
||||
validator(
|
||||
{
|
||||
CONF_NAME: "Датчик закрытия",
|
||||
CONF_STATE_TOPIC: "custom/topic/b",
|
||||
CONF_COMMAND_TOPIC: "custom/cmd/b",
|
||||
CONF_DISCOVERY: False,
|
||||
}
|
||||
)
|
||||
assert component_validator(mqtt_config) is mqtt_config
|
||||
|
||||
|
||||
def test_conflict_on_sub_topic_platforms() -> None:
|
||||
"""Test that platforms with extra object_id sub-topics always conflict.
|
||||
|
||||
Covers derive topics like position/command from the object_id through their
|
||||
own config keys, so custom state and command topics cannot exempt them.
|
||||
"""
|
||||
validator = entity_duplicate_validator("cover")
|
||||
validator(
|
||||
{
|
||||
CONF_NAME: "Датчик открытия",
|
||||
CONF_STATE_TOPIC: "custom/topic/a",
|
||||
CONF_COMMAND_TOPIC: "custom/cmd/a",
|
||||
CONF_DISCOVERY: False,
|
||||
}
|
||||
)
|
||||
validator(
|
||||
{
|
||||
CONF_NAME: "Датчик закрытия",
|
||||
CONF_STATE_TOPIC: "custom/topic/b",
|
||||
CONF_COMMAND_TOPIC: "custom/cmd/b",
|
||||
CONF_DISCOVERY: False,
|
||||
}
|
||||
)
|
||||
|
||||
component_validator = validate_no_object_id_conflicts(
|
||||
REASON, conflict_filter=_topics_conflict
|
||||
)
|
||||
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
|
||||
component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"})
|
||||
|
||||
|
||||
def test_no_conflict_on_disjoint_default_topics() -> None:
|
||||
"""Test that entities whose default topics are disjoint do not conflict.
|
||||
|
||||
One entity uses only the default command topic and the other only the default
|
||||
state topic, so they never share a topic.
|
||||
"""
|
||||
validator = entity_duplicate_validator("switch")
|
||||
validator(
|
||||
{
|
||||
CONF_NAME: "Датчик открытия",
|
||||
CONF_STATE_TOPIC: "custom/topic/a",
|
||||
CONF_DISCOVERY: False,
|
||||
}
|
||||
)
|
||||
validator(
|
||||
{
|
||||
CONF_NAME: "Датчик закрытия",
|
||||
CONF_COMMAND_TOPIC: "custom/cmd/b",
|
||||
CONF_DISCOVERY: False,
|
||||
}
|
||||
)
|
||||
|
||||
component_validator = validate_no_object_id_conflicts(
|
||||
REASON, conflict_filter=_topics_conflict
|
||||
)
|
||||
config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
|
||||
assert component_validator(config) is config
|
||||
|
||||
|
||||
def test_no_conflict_on_empty_topic_prefix() -> None:
|
||||
"""Test that an empty topic_prefix disables the default topic conflict.
|
||||
|
||||
With topic_prefix set to null no default topics exist at runtime, so entities
|
||||
without custom state topics cannot conflict; only discovery still matters.
|
||||
"""
|
||||
validator = entity_duplicate_validator("sensor")
|
||||
validator({CONF_NAME: "Датчик открытия"})
|
||||
validator({CONF_NAME: "Датчик закрытия"})
|
||||
|
||||
component_validator = validate_no_object_id_conflicts(
|
||||
REASON, conflict_filter=_topics_conflict
|
||||
)
|
||||
# No default topics and no discovery: valid
|
||||
config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""}
|
||||
assert component_validator(config) is config
|
||||
|
||||
# Discovery still uses object_id-derived config topics: rejected
|
||||
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
|
||||
component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""})
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Tests for entity helpers: name selection, entity key hashing, duplicate checks."""
|
||||
"""Test get_base_entity_object_id function matches C++ behavior."""
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -21,21 +23,23 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, entity_helpers
|
||||
from esphome.core.entity_helpers import (
|
||||
_check_report_deprecation,
|
||||
_register_string,
|
||||
_setup_entity_impl,
|
||||
entity_duplicate_validator,
|
||||
finalize_entity_strings,
|
||||
get_base_entity_name,
|
||||
get_base_entity_object_id,
|
||||
lazy_load_validator,
|
||||
mqtt_component_class,
|
||||
register_device_class,
|
||||
register_icon,
|
||||
register_unit_of_measurement,
|
||||
setup_device_class,
|
||||
setup_entity,
|
||||
setup_unit_of_measurement,
|
||||
validate_no_object_id_conflicts,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
|
||||
from esphome.helpers import fnv1_hash, sanitize, snake_case
|
||||
|
||||
from .common import load_config_from_fixture
|
||||
|
||||
@@ -58,26 +62,206 @@ def restore_core_state() -> Generator[None, None, None]:
|
||||
CORE.friendly_name = original_friendly_name
|
||||
|
||||
|
||||
def test_get_base_entity_name_priority_order() -> None:
|
||||
def test_with_entity_name() -> None:
|
||||
"""Test when entity has its own name - should use entity name."""
|
||||
# Simple name
|
||||
assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor"
|
||||
assert (
|
||||
get_base_entity_object_id("Temperature Sensor", "Device Name")
|
||||
== "temperature_sensor"
|
||||
)
|
||||
# Even with device name, entity name takes precedence
|
||||
assert (
|
||||
get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device")
|
||||
== "temperature_sensor"
|
||||
)
|
||||
|
||||
# Name with special characters
|
||||
assert (
|
||||
get_base_entity_object_id("Temp!@#$%^&*()Sensor", None)
|
||||
== "temp__________sensor"
|
||||
)
|
||||
assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123"
|
||||
|
||||
# Already snake_case
|
||||
assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor"
|
||||
|
||||
# Mixed case
|
||||
assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor"
|
||||
assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor"
|
||||
|
||||
|
||||
def test_empty_name_with_device_name() -> None:
|
||||
"""Test when entity has empty name and is on a sub-device - should use device name."""
|
||||
# C++ behavior: when has_own_name is false and device is set, uses device->get_name()
|
||||
assert (
|
||||
get_base_entity_object_id("", "Friendly Device", "Sub Device 1")
|
||||
== "sub_device_1"
|
||||
)
|
||||
assert (
|
||||
get_base_entity_object_id("", "Kitchen Controller", "controller_1")
|
||||
== "controller_1"
|
||||
)
|
||||
assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123"
|
||||
|
||||
|
||||
def test_empty_name_with_friendly_name() -> None:
|
||||
"""Test when entity has empty name and no device - should use friendly name."""
|
||||
# C++ behavior: when has_own_name is false, uses App.get_friendly_name()
|
||||
assert get_base_entity_object_id("", "Friendly Device") == "friendly_device"
|
||||
assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller"
|
||||
assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123"
|
||||
|
||||
# Special characters in friendly name
|
||||
assert get_base_entity_object_id("", "Device!@#$%") == "device_____"
|
||||
|
||||
|
||||
def test_empty_name_no_friendly_name() -> None:
|
||||
"""Test when entity has empty name and no friendly name - should use device name."""
|
||||
# Test with CORE.name set
|
||||
CORE.name = "device-name"
|
||||
assert get_base_entity_object_id("", None) == "device-name"
|
||||
|
||||
CORE.name = "Test Device"
|
||||
assert get_base_entity_object_id("", None) == "test_device"
|
||||
|
||||
|
||||
def test_edge_cases() -> None:
|
||||
"""Test edge cases."""
|
||||
# Only spaces
|
||||
assert get_base_entity_object_id(" ", None) == "___"
|
||||
|
||||
# Unicode characters (should be replaced)
|
||||
assert get_base_entity_object_id("Température", None) == "temp_rature"
|
||||
assert get_base_entity_object_id("测试", None) == "__"
|
||||
|
||||
# Empty string with empty friendly name (empty friendly name is treated as None)
|
||||
# Falls back to CORE.name
|
||||
CORE.name = "device"
|
||||
assert get_base_entity_object_id("", "") == "device"
|
||||
|
||||
# Very long name (should work fine)
|
||||
long_name = "a" * 100 + " " + "b" * 100
|
||||
expected = "a" * 100 + "_" + "b" * 100
|
||||
assert get_base_entity_object_id(long_name, None) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("Temperature Sensor", "temperature_sensor"),
|
||||
("Living Room Light", "living_room_light"),
|
||||
("Test-Device_123", "test-device_123"),
|
||||
("Special!@#Chars", "special___chars"),
|
||||
("UPPERCASE NAME", "uppercase_name"),
|
||||
("lowercase name", "lowercase_name"),
|
||||
("Mixed Case Name", "mixed_case_name"),
|
||||
(" Spaces ", "___spaces___"),
|
||||
],
|
||||
)
|
||||
def test_matches_cpp_helpers(name: str, expected: str) -> None:
|
||||
"""Test that the logic matches using snake_case and sanitize directly."""
|
||||
# For non-empty names, verify our function produces same result as direct snake_case + sanitize
|
||||
assert get_base_entity_object_id(name, None) == sanitize(snake_case(name))
|
||||
assert get_base_entity_object_id(name, None) == expected
|
||||
|
||||
|
||||
def test_empty_name_fallback() -> None:
|
||||
"""Test empty name handling which falls back to friendly_name or CORE.name."""
|
||||
# Empty name is handled specially - it doesn't just use sanitize(snake_case(""))
|
||||
# Instead it falls back to friendly_name or CORE.name
|
||||
assert sanitize(snake_case("")) == "" # Direct conversion gives empty string
|
||||
# But our function returns a fallback
|
||||
CORE.name = "device"
|
||||
assert get_base_entity_object_id("", None) == "device" # Uses device name
|
||||
|
||||
|
||||
def test_name_add_mac_suffix_behavior() -> None:
|
||||
"""Test behavior related to name_add_mac_suffix.
|
||||
|
||||
In C++, an entity's object_id is computed from its name_ via
|
||||
write_object_id_to() (sanitized snake_case). When an entity has no name,
|
||||
configure_entity_() sets name_ from the friendly name, with the MAC suffix
|
||||
appended when name_add_mac_suffix is enabled. Our function always returns
|
||||
the same result since we're calculating the base for duplicate tracking.
|
||||
"""
|
||||
# The function should always return the same result regardless of
|
||||
# name_add_mac_suffix setting, as we're calculating the base object_id
|
||||
assert get_base_entity_object_id("", "Test Device") == "test_device"
|
||||
assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name"
|
||||
|
||||
|
||||
def test_priority_order() -> None:
|
||||
"""Test the priority order: entity name > device name > friendly name > CORE.name."""
|
||||
CORE.name = "core-device"
|
||||
|
||||
# 1. Entity name has highest priority and is used as-is, no transformations
|
||||
# 1. Entity name has highest priority
|
||||
assert (
|
||||
get_base_entity_name("Entity Name", "Friendly Name", "Device Name")
|
||||
== "Entity Name"
|
||||
get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name")
|
||||
== "entity_name"
|
||||
)
|
||||
assert get_base_entity_name("Température", None) == "Température"
|
||||
|
||||
# 2. Device name is next priority (when entity name is empty)
|
||||
assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name"
|
||||
assert (
|
||||
get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name"
|
||||
)
|
||||
|
||||
# 3. Friendly name is next (when entity and device names are empty)
|
||||
assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name"
|
||||
assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name"
|
||||
|
||||
# 4. CORE.name is last resort; an empty friendly name falls through to it
|
||||
assert get_base_entity_name("", None, None) == "core-device"
|
||||
assert get_base_entity_name("", "") == "core-device"
|
||||
# 4. CORE.name is last resort
|
||||
assert get_base_entity_object_id("", None, None) == "core-device"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "friendly_name", "device_name", "expected"),
|
||||
[
|
||||
# name, friendly_name, device_name, expected
|
||||
("Living Room Light", None, None, "living_room_light"),
|
||||
("", "Kitchen Controller", None, "kitchen_controller"),
|
||||
(
|
||||
"",
|
||||
"ESP32 Device",
|
||||
"controller_1",
|
||||
"controller_1",
|
||||
), # Device name takes precedence
|
||||
("GPIO2 Button", None, None, "gpio2_button"),
|
||||
("WiFi Signal", "My Device", None, "wifi_signal"),
|
||||
("", None, "esp32_node", "esp32_node"),
|
||||
("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"),
|
||||
],
|
||||
)
|
||||
def test_real_world_examples(
|
||||
name: str, friendly_name: str | None, device_name: str | None, expected: str
|
||||
) -> None:
|
||||
"""Test real-world entity naming scenarios."""
|
||||
result = get_base_entity_object_id(name, friendly_name, device_name)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_issue_6953_scenarios() -> None:
|
||||
"""Test specific scenarios from issue #6953."""
|
||||
# Scenario 1: Multiple empty names on main device with name_add_mac_suffix
|
||||
# The Python code calculates the base, C++ might append MAC suffix dynamically
|
||||
CORE.name = "device-name"
|
||||
CORE.friendly_name = "Friendly Device"
|
||||
|
||||
# All empty names should resolve to same base
|
||||
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
|
||||
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
|
||||
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
|
||||
|
||||
# Scenario 2: Empty names on sub-devices
|
||||
assert (
|
||||
get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1"
|
||||
)
|
||||
assert (
|
||||
get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2"
|
||||
)
|
||||
|
||||
# Scenario 3: xyz duplicates
|
||||
assert get_base_entity_object_id("xyz", None) == "xyz"
|
||||
assert get_base_entity_object_id("xyz", "Device") == "xyz"
|
||||
|
||||
|
||||
# Tests for setup_entity function
|
||||
@@ -336,10 +520,9 @@ def test_entity_duplicate_validator() -> None:
|
||||
config1 = {CONF_NAME: "Temperature"}
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
|
||||
assert temperature_key in CORE.unique_ids
|
||||
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
# Check metadata was stored
|
||||
metadata = CORE.unique_ids[temperature_key]
|
||||
metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))]
|
||||
assert metadata["name"] == "Temperature"
|
||||
assert metadata["platform"] == "sensor"
|
||||
|
||||
@@ -347,9 +530,8 @@ def test_entity_duplicate_validator() -> None:
|
||||
config2 = {CONF_NAME: "Humidity"}
|
||||
validated2 = validator(config2)
|
||||
assert validated2 == config2
|
||||
humidity_key = ("", "sensor", fnv1_hash_name("Humidity"))
|
||||
assert humidity_key in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[humidity_key]
|
||||
assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))]
|
||||
assert metadata2["name"] == "Humidity"
|
||||
|
||||
# Duplicate entity should fail
|
||||
@@ -360,6 +542,34 @@ def test_entity_duplicate_validator() -> None:
|
||||
validator(config3)
|
||||
|
||||
|
||||
def test_entity_duplicate_validator_hash_collision() -> None:
|
||||
"""Test that two different object_ids with the same FNV-1 hash are rejected."""
|
||||
# Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4
|
||||
name_a = "Sensor aooxzi"
|
||||
name_b = "Sensor baraia"
|
||||
object_id_a = sanitize(snake_case(name_a))
|
||||
object_id_b = sanitize(snake_case(name_b))
|
||||
assert object_id_a != object_id_b
|
||||
assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b)
|
||||
|
||||
validator = entity_duplicate_validator("sensor")
|
||||
|
||||
config1 = {CONF_NAME: name_a}
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
|
||||
config2 = {CONF_NAME: name_b}
|
||||
with pytest.raises(
|
||||
Invalid,
|
||||
match=re.compile(
|
||||
r"Duplicate sensor entity with name 'Sensor baraia' found.*"
|
||||
r"produce the same entity key hash \(0xe95747e4\)",
|
||||
re.DOTALL,
|
||||
),
|
||||
):
|
||||
validator(config2)
|
||||
|
||||
|
||||
def test_entity_duplicate_validator_with_devices() -> None:
|
||||
"""Test entity_duplicate_validator with devices."""
|
||||
# Create validator for sensor platform
|
||||
@@ -370,19 +580,18 @@ def test_entity_duplicate_validator_with_devices() -> None:
|
||||
device2 = ID("device2", type="Device")
|
||||
|
||||
# Same name on different devices should pass
|
||||
name_hash = fnv1_hash_name("Temperature")
|
||||
config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1}
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
assert ("device1", "sensor", name_hash) in CORE.unique_ids
|
||||
metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)]
|
||||
assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))]
|
||||
assert metadata1["device_id"] == "device1"
|
||||
|
||||
config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2}
|
||||
validated2 = validator(config2)
|
||||
assert validated2 == config2
|
||||
assert ("device2", "sensor", name_hash) in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)]
|
||||
assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))]
|
||||
assert metadata2["device_id"] == "device2"
|
||||
|
||||
# Duplicate on same device should fail
|
||||
@@ -434,33 +643,6 @@ def test_entity_different_platforms_yaml_validation(
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_object_id_conflict_mqtt_yaml_validation(
|
||||
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test that names sanitizing to the same object_id fail when mqtt is configured."""
|
||||
result = load_config_from_fixture(
|
||||
yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR
|
||||
)
|
||||
assert result is None
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert (
|
||||
"mqtt builds default topics and discovery topics from the entity object_id"
|
||||
in captured.out
|
||||
)
|
||||
|
||||
|
||||
def test_object_id_conflict_without_mqtt_yaml_validation(
|
||||
yaml_file: Callable[[str], str],
|
||||
) -> None:
|
||||
"""Test that names sanitizing to the same object_id pass without mqtt/prometheus."""
|
||||
result = load_config_from_fixture(
|
||||
yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR
|
||||
)
|
||||
# This should succeed
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_entity_duplicate_validator_error_message() -> None:
|
||||
"""Test that duplicate entity error messages include helpful metadata."""
|
||||
# Create validator for sensor platform
|
||||
@@ -519,8 +701,7 @@ def test_entity_duplicate_validator_internal_entities() -> None:
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
# New format includes device_id (empty string for main device)
|
||||
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
|
||||
assert temperature_key in CORE.unique_ids
|
||||
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
|
||||
# Internal entity with same name should pass (not added to unique_ids)
|
||||
config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
|
||||
@@ -528,7 +709,9 @@ def test_entity_duplicate_validator_internal_entities() -> None:
|
||||
assert validated2 == config2
|
||||
# Internal entity should not be added to unique_ids
|
||||
# Count how many times the key appears (should still be 1)
|
||||
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
|
||||
count = sum(
|
||||
1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature"))
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
# Another internal entity with same name should also pass
|
||||
@@ -536,7 +719,9 @@ def test_entity_duplicate_validator_internal_entities() -> None:
|
||||
validated3 = validator(config3)
|
||||
assert validated3 == config3
|
||||
# Still only one entry in unique_ids (from the non-internal entity)
|
||||
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
|
||||
count = sum(
|
||||
1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature"))
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
# Non-internal entity with same name should fail
|
||||
@@ -564,148 +749,30 @@ def test_empty_or_null_device_id_on_entity() -> None:
|
||||
|
||||
|
||||
def test_entity_duplicate_validator_non_ascii_names() -> None:
|
||||
"""Test that distinct non-ASCII names no longer collide.
|
||||
|
||||
These names used to be rejected because both sanitize to only underscores;
|
||||
the entity key now hashes the raw name so they stay distinct.
|
||||
"""
|
||||
"""Test that non-ASCII names show helpful error messages."""
|
||||
# Create validator for binary_sensor platform
|
||||
validator = entity_duplicate_validator("binary_sensor")
|
||||
|
||||
# Both Russian sensors should pass even though they sanitize identically
|
||||
# First Russian sensor should pass
|
||||
config1 = {CONF_NAME: "Датчик открытия основного крана"}
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
|
||||
# Second Russian sensor with different text but same ASCII conversion should fail
|
||||
config2 = {CONF_NAME: "Датчик закрытия основного крана"}
|
||||
validated2 = validator(config2)
|
||||
assert validated2 == config2
|
||||
|
||||
# An exact duplicate still fails
|
||||
config3 = {CONF_NAME: "Датчик открытия основного крана"}
|
||||
with pytest.raises(
|
||||
Invalid,
|
||||
match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found",
|
||||
):
|
||||
validator(config3)
|
||||
|
||||
|
||||
def test_entity_duplicate_validator_hash_collision() -> None:
|
||||
"""Test that two different names with the same FNV-1 hash are rejected."""
|
||||
# Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b
|
||||
name_a = "Sensor m2CZ"
|
||||
name_b = "Sensor qCaa"
|
||||
assert name_a != name_b
|
||||
assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b)
|
||||
|
||||
validator = entity_duplicate_validator("sensor")
|
||||
|
||||
config1 = {CONF_NAME: name_a}
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
|
||||
config2 = {CONF_NAME: name_b}
|
||||
with pytest.raises(
|
||||
Invalid,
|
||||
match=re.compile(
|
||||
rf"Duplicate sensor entity with name '{name_b}' found.*"
|
||||
rf"The names '{name_b}' and '{name_a}' produce the.*"
|
||||
r"same entity key hash \(0x0ee5ff7b\).*"
|
||||
r"To fix: Rename one of the entities",
|
||||
r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*"
|
||||
r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*"
|
||||
r"Both convert to ASCII ID: '_______________________________'.*"
|
||||
r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)",
|
||||
re.DOTALL,
|
||||
),
|
||||
):
|
||||
validator(config2)
|
||||
|
||||
|
||||
def test_object_id_conflicts_rejected_by_component_validator() -> None:
|
||||
"""Test that object_id conflicts pass entity validation but fail for mqtt/prometheus."""
|
||||
validator = entity_duplicate_validator("sensor")
|
||||
|
||||
# Both names validate fine in general (distinct raw names, distinct keys)
|
||||
validator({CONF_NAME: "Датчик открытия"})
|
||||
validator({CONF_NAME: "Датчик закрытия"})
|
||||
|
||||
# A component that addresses entities by object_id must reject the config
|
||||
component_validator = validate_no_object_id_conflicts(
|
||||
"mqtt builds default topics from the entity object_id"
|
||||
)
|
||||
with pytest.raises(
|
||||
Invalid,
|
||||
match=re.compile(
|
||||
r"mqtt builds default topics from the entity object_id.*"
|
||||
r"sensor entities 'Датчик открытия', 'Датчик закрытия' "
|
||||
r"share the object_id '_______________'.*"
|
||||
r"To fix: Add unique ASCII characters",
|
||||
re.DOTALL,
|
||||
),
|
||||
):
|
||||
component_validator({})
|
||||
|
||||
|
||||
def test_object_id_conflicts_skipped_in_testing_mode() -> None:
|
||||
"""Test that testing_mode skips the conflict check, as used for grouped testing."""
|
||||
validator = entity_duplicate_validator("sensor")
|
||||
validator({CONF_NAME: "Датчик открытия"})
|
||||
validator({CONF_NAME: "Датчик закрытия"})
|
||||
|
||||
component_validator = validate_no_object_id_conflicts(
|
||||
"mqtt builds default topics from the entity object_id"
|
||||
)
|
||||
CORE.testing_mode = True
|
||||
try:
|
||||
config: dict = {}
|
||||
assert component_validator(config) is config
|
||||
finally:
|
||||
CORE.testing_mode = False
|
||||
|
||||
|
||||
def test_object_id_conflicts_none_recorded() -> None:
|
||||
"""Test that distinct object_ids produce no conflicts."""
|
||||
validator = entity_duplicate_validator("sensor")
|
||||
validator({CONF_NAME: "Temperature"})
|
||||
validator({CONF_NAME: "Humidity"})
|
||||
|
||||
component_validator = validate_no_object_id_conflicts(
|
||||
"mqtt builds default topics from the entity object_id"
|
||||
)
|
||||
config: dict = {}
|
||||
assert component_validator(config) is config
|
||||
|
||||
|
||||
def test_object_id_conflicts_device_scoped() -> None:
|
||||
"""Test that the object_id conflict check is scoped per device.
|
||||
|
||||
Same-named entities on different sub-devices were accepted before entity keys
|
||||
moved to raw names, so the check keeps that scope; conflicts within one device
|
||||
are still reported with the device named in the message.
|
||||
"""
|
||||
validator = entity_duplicate_validator("sensor")
|
||||
validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")})
|
||||
validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")})
|
||||
|
||||
component_validator = validate_no_object_id_conflicts(
|
||||
"prometheus builds metric labels from the entity object_id"
|
||||
)
|
||||
config: dict = {}
|
||||
assert component_validator(config) is config
|
||||
|
||||
# Two names sanitizing identically on the same sub-device still conflict
|
||||
validator(
|
||||
{CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")}
|
||||
)
|
||||
validator(
|
||||
{CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")}
|
||||
)
|
||||
with pytest.raises(
|
||||
Invalid,
|
||||
match=re.compile(
|
||||
r"prometheus builds metric labels.*on device 'device1'", re.DOTALL
|
||||
),
|
||||
):
|
||||
component_validator({})
|
||||
|
||||
|
||||
def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None:
|
||||
"""Test that identical names don't show the enhanced message."""
|
||||
# Create validator for sensor platform
|
||||
@@ -763,7 +830,7 @@ async def test_setup_entity_empty_name_with_device(
|
||||
|
||||
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
|
||||
assert config.get("_entity_name") == ""
|
||||
assert config.get("_entity_key") == 0
|
||||
assert config.get("_entity_object_id_hash") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -792,7 +859,7 @@ async def test_setup_entity_empty_name_with_mac_suffix(
|
||||
|
||||
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
|
||||
assert config.get("_entity_name") == ""
|
||||
assert config.get("_entity_key") == 0
|
||||
assert config.get("_entity_object_id_hash") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -822,7 +889,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name(
|
||||
|
||||
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
|
||||
assert config.get("_entity_name") == ""
|
||||
assert config.get("_entity_key") == 0
|
||||
assert config.get("_entity_object_id_hash") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -853,7 +920,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name(
|
||||
|
||||
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
|
||||
assert config.get("_entity_name") == ""
|
||||
assert config.get("_entity_key") == 0
|
||||
assert config.get("_entity_object_id_hash") == 0
|
||||
|
||||
|
||||
def test_register_string_overflow() -> None:
|
||||
@@ -1174,3 +1241,82 @@ async def test_finalize_comment_sanitization(
|
||||
# Newline must be replaced to prevent breaking out of comment
|
||||
assert "\n" not in comment_line
|
||||
assert "INJECTED_CODE" in comment_line # still visible but safe in comment
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "warns"),
|
||||
[
|
||||
("coordinator", True),
|
||||
("enable", True),
|
||||
("force", False),
|
||||
("default", False),
|
||||
],
|
||||
)
|
||||
def test_check_report_deprecation(
|
||||
value: str, warns: bool, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Deprecated zigbee report options warn; the value always passes through."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _check_report_deprecation(value) == value
|
||||
assert ("deprecated" in caplog.text) is warns
|
||||
|
||||
|
||||
def test_lazy_load_validator_defers_import() -> None:
|
||||
"""The validator no-ops without importing unless the component is loaded."""
|
||||
validator = lazy_load_validator("zigbee", "validate_binary_sensor")
|
||||
config = {CONF_NAME: "test"}
|
||||
|
||||
with (
|
||||
patch.object(CORE, "loaded_integrations", set()),
|
||||
patch("esphome.core.entity_helpers.import_module") as import_mock,
|
||||
):
|
||||
assert validator(config) is config
|
||||
import_mock.assert_not_called()
|
||||
|
||||
CORE.loaded_integrations.add("zigbee")
|
||||
delegate = import_mock.return_value.validate_binary_sensor
|
||||
delegate.return_value = {CONF_NAME: "validated"}
|
||||
assert validator(config) == {CONF_NAME: "validated"}
|
||||
import_mock.assert_called_once_with("esphome.components.zigbee")
|
||||
delegate.assert_called_once_with(config)
|
||||
|
||||
|
||||
def test_lazy_load_validator_rejects_unknown_component() -> None:
|
||||
"""A typo in the component name fails at schema construction."""
|
||||
with pytest.raises(ValueError, match="no_such_component"):
|
||||
lazy_load_validator("no_such_component", "validate_binary_sensor")
|
||||
|
||||
|
||||
def test_lazy_load_validator_names_missing_hook() -> None:
|
||||
"""A missing hook raises a clear error naming the component and hook."""
|
||||
validator = lazy_load_validator("zigbee", "no_such_hook")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "loaded_integrations", {"zigbee"}),
|
||||
patch("esphome.core.entity_helpers.import_module") as import_mock,
|
||||
pytest.raises(ValueError, match="no_such_hook"),
|
||||
):
|
||||
del import_mock.return_value.no_such_hook
|
||||
validator({})
|
||||
|
||||
|
||||
def test_integration_class_handles_match_owning_definitions() -> None:
|
||||
"""The cheap class handles must stay string-equal to the integrations'
|
||||
own declarations, or use_id/declare_id resolution silently drifts."""
|
||||
from esphome.components import mqtt, web_server
|
||||
from esphome.components.zigbee import zigbee_zephyr
|
||||
from esphome.components.zigbee.const import ZigbeeComponent
|
||||
|
||||
mqtt_handle = mqtt_component_class("MQTTBinarySensorComponent")
|
||||
assert str(mqtt_handle) == str(mqtt.MQTTBinarySensorComponent)
|
||||
assert mqtt_handle.inherits_from(mqtt.MQTTComponent)
|
||||
assert mqtt.MQTTBinarySensorComponent.inherits_from(entity_helpers._MQTTComponent)
|
||||
|
||||
assert str(entity_helpers._WebServer) == str(web_server.WebServer)
|
||||
assert entity_helpers._WebServer.inherits_from(web_server.WebServer)
|
||||
assert web_server.WebServer.inherits_from(entity_helpers._WebServer)
|
||||
|
||||
assert str(entity_helpers._ZigbeeComponent) == str(ZigbeeComponent)
|
||||
for _conf_key, class_name in entity_helpers._ZIGBEE_ENTITY_CLASSES.values():
|
||||
owning = getattr(zigbee_zephyr, class_name)
|
||||
assert str(owning) == f"zigbee::{class_name}"
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
esphome:
|
||||
name: test-object-id-conflict
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
mqtt:
|
||||
broker: test.mosquitto.org
|
||||
|
||||
sensor:
|
||||
# Distinct raw names are fine in general, but both sanitize to the same
|
||||
# object_id, which MQTT still uses to build default topics - should fail
|
||||
- platform: template
|
||||
name: "Датчик открытия"
|
||||
lambda: return 21.0;
|
||||
- platform: template
|
||||
name: "Датчик закрытия"
|
||||
lambda: return 22.0;
|
||||
@@ -1,15 +0,0 @@
|
||||
esphome:
|
||||
name: test-object-id-ok
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
sensor:
|
||||
# Distinct raw names that sanitize to the same object_id are allowed when no
|
||||
# component addresses entities by object_id (no mqtt or prometheus configured)
|
||||
- platform: template
|
||||
name: "Датчик открытия"
|
||||
lambda: return 21.0;
|
||||
- platform: template
|
||||
name: "Датчик закрытия"
|
||||
lambda: return 22.0;
|
||||
@@ -44,13 +44,17 @@ def mock_file() -> io.BytesIO:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_time() -> Generator[None]:
|
||||
def mock_sleep() -> Generator[Mock]:
|
||||
"""Mock time.sleep so delays don't slow down tests."""
|
||||
with patch("time.sleep") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_time(mock_sleep: Mock) -> Generator[None]:
|
||||
"""Mock time-related functions for consistent testing."""
|
||||
# Provide enough values for multiple calls (tests may call perform_ota multiple times)
|
||||
with (
|
||||
patch("time.sleep"),
|
||||
patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]),
|
||||
):
|
||||
with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]):
|
||||
yield
|
||||
|
||||
|
||||
@@ -79,6 +83,28 @@ def mock_resolve_ip() -> Generator[Mock]:
|
||||
yield mock
|
||||
|
||||
|
||||
DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0)
|
||||
DUAL_STACK_SA4 = ("192.168.1.100", 3232)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock:
|
||||
"""Make resolve_ip_address return an IPv6 and an IPv4 address."""
|
||||
mock_resolve_ip.return_value = [
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4),
|
||||
]
|
||||
return mock_resolve_ip
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def firmware_file(tmp_path: Path) -> Path:
|
||||
"""Create a firmware file on disk for run_ota_impl_ tests."""
|
||||
firmware = tmp_path / "firmware.bin"
|
||||
firmware.write_bytes(b"firmware content")
|
||||
return firmware
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_perform_ota() -> Generator[Mock]:
|
||||
"""Mock perform_ota function for testing."""
|
||||
@@ -137,9 +163,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None:
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="receiving auth:.*Authentication invalid"
|
||||
):
|
||||
) as exc_info:
|
||||
espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK])
|
||||
|
||||
# Device-reported errors must stay plain OTAError, not the retryable kind
|
||||
assert not isinstance(exc_info.value, espota2.OTANetworkError)
|
||||
mock_socket.close.assert_called_once()
|
||||
|
||||
|
||||
@@ -147,10 +175,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly handles socket errors."""
|
||||
mock_socket.recv.side_effect = OSError("Connection reset")
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="receiving test response"):
|
||||
with pytest.raises(espota2.OTANetworkError, match="receiving test response"):
|
||||
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
|
||||
|
||||
|
||||
def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly handles socket errors after the first byte."""
|
||||
mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")]
|
||||
|
||||
with pytest.raises(espota2.OTANetworkError, match="receiving test:"):
|
||||
espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK)
|
||||
|
||||
|
||||
def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly raises OTANetworkError when the device closes the connection."""
|
||||
mock_socket.recv.return_value = b""
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTANetworkError, match="Device closed connection without responding"
|
||||
):
|
||||
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
|
||||
|
||||
mock_socket.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_code", "expected_msg"),
|
||||
[
|
||||
@@ -227,15 +275,15 @@ def test_check_error_unexpected_response() -> None:
|
||||
|
||||
|
||||
def test_check_error_empty_data() -> None:
|
||||
"""Test check_error raises error when device closes connection without responding."""
|
||||
"""Test check_error raises the retryable OTANetworkError when the device closes the connection."""
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="Device closed connection without responding"
|
||||
espota2.OTANetworkError, match="Device closed connection without responding"
|
||||
):
|
||||
espota2.check_error([], [espota2.RESPONSE_OK])
|
||||
|
||||
# Also test with empty bytes
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="Device closed connection without responding"
|
||||
espota2.OTANetworkError, match="Device closed connection without responding"
|
||||
):
|
||||
espota2.check_error(b"", [espota2.RESPONSE_OK])
|
||||
|
||||
@@ -530,6 +578,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
|
||||
def _no_auth_handshake(version: int) -> list[bytes]:
|
||||
"""Recv responses for a handshake without auth, up to the MD5 check."""
|
||||
return [
|
||||
bytes([espota2.RESPONSE_OK]), # First byte of version response
|
||||
bytes([version]), # Version number
|
||||
bytes([espota2.RESPONSE_HEADER_OK]), # Features response
|
||||
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
|
||||
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
|
||||
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None:
|
||||
"""Test OTA raises the retryable OTANetworkError when sending a chunk fails."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
|
||||
OSError("Connection reset"), # Probe for a pending error byte fails too
|
||||
]
|
||||
# Sends before the data phase: magic bytes, features, binary size, MD5;
|
||||
# fail on the fifth sendall, the first firmware chunk
|
||||
mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")]
|
||||
|
||||
with pytest.raises(espota2.OTANetworkError, match="sending data:"):
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_chunk_send_error_surfaces_device_error(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a device error byte pending behind a send failure becomes the cause."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed
|
||||
]
|
||||
mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")]
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="Writing OTA data to flash memory failed"
|
||||
) as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# The device-reported error is not retryable
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_final_chunk_ack_failure_not_retryable(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a lost ack for the final chunk is not retried."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
|
||||
OSError("Connection reset"), # Ack for the only (final) chunk is lost
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# The device already had the whole image, so it may be committing
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_intermediate_chunk_ack_failure_retryable(
|
||||
mock_socket: Mock,
|
||||
) -> None:
|
||||
"""Test a lost ack for a non-final chunk stays retryable."""
|
||||
# Two chunks: the firmware is larger than one upload block
|
||||
big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1))
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
|
||||
OSError("Connection reset"), # Ack for the first of two chunks is lost
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"):
|
||||
espota2.perform_ota(mock_socket, None, big_file, "test.bin")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_post_commit_failure_not_retryable(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a network failure after the device committed is a plain OTAError."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
|
||||
OSError("Connection reset"), # Connection lost waiting for end result
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="receiving update end result") as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# Must not be the retryable kind; the device is already rebooting
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_md5_mismatch_not_marked_committed(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test an MD5 mismatch keeps its own message and stays non-retryable."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
|
||||
bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# The device aborted without committing, so the message must not claim
|
||||
# the update may have been installed, and the error must not be retried
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
assert "committed" not in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_end_ack_send_failure_is_success(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a send failure on the final acknowledgement does not fail the OTA."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
|
||||
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed
|
||||
]
|
||||
# Sends: magic bytes, features, binary size, MD5, one firmware chunk;
|
||||
# fail on the sixth sendall, the end acknowledgement
|
||||
mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")]
|
||||
|
||||
# Must not raise; the device treats a missing acknowledgement as non-fatal
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
assert mock_socket.sendall.call_count == 6
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_successful(
|
||||
mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock
|
||||
@@ -564,21 +750,183 @@ def test_run_ota_impl_successful(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None:
|
||||
"""Test run_ota_impl_ when connection fails."""
|
||||
def test_run_ota_impl_connection_failed(
|
||||
mock_socket: Mock, firmware_file: Path, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ retries when connection fails and eventually gives up."""
|
||||
mock_socket.connect.side_effect = OSError("Connection refused")
|
||||
|
||||
# Create a real firmware file
|
||||
firmware_file = tmp_path / "firmware.bin"
|
||||
firmware_file.write_bytes(b"firmware content")
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
mock_socket.close.assert_called_once()
|
||||
# A single address gets the whole attempt budget, with a delay before
|
||||
# each revisit
|
||||
assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
|
||||
assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
|
||||
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS
|
||||
mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_connect_retry_succeeds(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ succeeds when a retry connects after a failed attempt."""
|
||||
mock_socket.connect.side_effect = [OSError("Connection timed out"), None]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
assert mock_socket.connect.call_count == 2
|
||||
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
mock_perform_ota.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_network_error_retry_succeeds(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ retries after a network error during the upload."""
|
||||
mock_perform_ota.side_effect = [
|
||||
espota2.OTANetworkError("receiving features: Device closed connection"),
|
||||
None,
|
||||
]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
assert mock_perform_ota.call_count == 2
|
||||
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_network_error_exhausts_attempts(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ gives up after all attempts hit network errors."""
|
||||
mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe")
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
|
||||
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
|
||||
def test_run_ota_impl_multiple_addresses_cycle(
|
||||
mock_socket: Mock, firmware_file: Path, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ visits every address and cycles for the retries."""
|
||||
mock_socket.connect.side_effect = OSError("No route to host")
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
# Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare
|
||||
# attempts cycle back through them; the budget is shared, not per address
|
||||
assert mock_socket.connect.call_args_list == [
|
||||
call(DUAL_STACK_SA6),
|
||||
call(DUAL_STACK_SA4),
|
||||
call(DUAL_STACK_SA6),
|
||||
call(DUAL_STACK_SA4),
|
||||
]
|
||||
# No connect ever reached the device, so the delay only applies before
|
||||
# the revisits
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
|
||||
def test_run_ota_impl_second_address_succeeds_without_delay(
|
||||
mock_socket: Mock,
|
||||
firmware_file: Path,
|
||||
mock_perform_ota: Mock,
|
||||
mock_sleep: Mock,
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ falls through to the next address with no pause."""
|
||||
mock_socket.connect.side_effect = [OSError("No route to host"), None]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
mock_sleep.assert_not_called()
|
||||
mock_perform_ota.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
|
||||
def test_run_ota_impl_pauses_after_reaching_device(
|
||||
mock_socket: Mock,
|
||||
firmware_file: Path,
|
||||
mock_perform_ota: Mock,
|
||||
mock_sleep: Mock,
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ pauses before the next address once the device was reached."""
|
||||
mock_perform_ota.side_effect = [
|
||||
espota2.OTANetworkError("sending data: connection reset"),
|
||||
None,
|
||||
]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
# The first attempt reached the device, so the next one waits first even
|
||||
# though it targets a fresh address
|
||||
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_device_error_not_retried(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ fails immediately on a device-reported error."""
|
||||
mock_perform_ota.side_effect = espota2.OTAError(
|
||||
"Authentication invalid. Is the password correct?"
|
||||
)
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
mock_perform_ota.assert_called_once()
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
def test_run_ota_impl_no_addresses(
|
||||
firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ fails cleanly when resolution yields no addresses."""
|
||||
mock_resolve_ip.return_value = []
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None:
|
||||
|
||||
@@ -190,6 +190,40 @@ def test_api_client_does_not_import_heavy_modules() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_binary_sensor_does_not_import_integrations() -> None:
|
||||
"""An entity component must not drag in its optional integrations.
|
||||
|
||||
binary_sensor's mqtt/web_server/zigbee schema fragments live in
|
||||
``esphome.core.entity_helpers``; the integration packages (and the
|
||||
esp32/logger chains mqtt and zigbee pull in) must only load when the
|
||||
user's config actually uses them.
|
||||
"""
|
||||
allowed = (
|
||||
"esphome.components",
|
||||
"esphome.components.binary_sensor",
|
||||
"esphome.components.const",
|
||||
)
|
||||
check = (
|
||||
"import sys; import esphome.components.binary_sensor; "
|
||||
f"leaked = [m for m in sys.modules "
|
||||
f"if m.startswith('esphome.components') and m not in {allowed!r}]; "
|
||||
"print(','.join(leaked))"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", check],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
leaked = result.stdout.strip()
|
||||
assert not leaked, (
|
||||
f"esphome.components.binary_sensor imports integration packages at "
|
||||
f"top level: {leaked}. Keep the shared schema fragments in "
|
||||
"esphome.core.entity_helpers and import the integration inside "
|
||||
"to_code instead."
|
||||
)
|
||||
|
||||
|
||||
def test_stacktrace_does_not_import_heavy_modules() -> None:
|
||||
"""``esphome.stacktrace`` guards its own docstring's contract.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.component_aliases import COMPONENT_ALIASES
|
||||
from esphome.loader import (
|
||||
AliasMeta,
|
||||
ComponentManifest,
|
||||
@@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None:
|
||||
assert meta["rp2040"].removal_version == "2027.7.0"
|
||||
|
||||
|
||||
def test_alias_registry_matches_component_tree() -> None:
|
||||
"""The checked-in registry must match a live scan of the component tree."""
|
||||
_, meta_map = _build_alias_map()
|
||||
expected = {
|
||||
alias: (meta.canonical, meta.removal_version)
|
||||
for alias, meta in meta_map.items()
|
||||
}
|
||||
assert expected == COMPONENT_ALIASES, (
|
||||
"esphome/component_aliases.py is out of date; "
|
||||
"run script/build_alias_registry.py"
|
||||
)
|
||||
|
||||
|
||||
def test_alias_map_built_from_registry() -> None:
|
||||
"""The runtime alias map comes from the generated registry, not a scan."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.component_aliases.COMPONENT_ALIASES",
|
||||
{"legacy": ("modern", "2099.1.0")},
|
||||
),
|
||||
patch("esphome.loader._ALIAS_META_CACHE", None),
|
||||
):
|
||||
assert get_alias_metadata() == {
|
||||
"legacy": AliasMeta(canonical="modern", removal_version="2099.1.0")
|
||||
}
|
||||
|
||||
|
||||
def test_get_component_resolves_alias() -> None:
|
||||
"""``get_component('rp2040')`` should return the rp2 manifest — every
|
||||
caller of the loader (dep checker, schema validator, codegen) hits
|
||||
|
||||
@@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on
|
||||
firmware upgrades, or break entity state routing to API clients.
|
||||
|
||||
Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85):
|
||||
1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1).
|
||||
Existing devices have preferences stored under keys derived from it; slot-based
|
||||
backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it.
|
||||
2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes).
|
||||
Sent to API clients and used as the preference key base on key-lookup backends.
|
||||
1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1).
|
||||
The entity key sent to API clients and the base of every stored preference key.
|
||||
2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta
|
||||
firmware stored preferences under keys derived from it; a future key migration
|
||||
must reconstruct those keys to recover that data.
|
||||
|
||||
DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm,
|
||||
the change breaks backward compatibility and will cause data loss.
|
||||
@@ -124,8 +124,9 @@ def test_entity_object_id_hash_stability(
|
||||
"""Verify fnv1_hash_object_id produces stable hashes for entity names.
|
||||
|
||||
CRITICAL: These expected values MUST NOT CHANGE. Existing devices have
|
||||
preferences stored under keys derived from this legacy hash; changing it
|
||||
breaks the old-to-new key migration and loses stored preferences.
|
||||
preferences stored under keys derived from this hash, and it is the entity
|
||||
key sent to API clients; changing it loses stored preferences and breaks
|
||||
entity state routing.
|
||||
"""
|
||||
actual = fnv1_hash_object_id(entity_name)
|
||||
assert actual == expected_object_id_hash, (
|
||||
@@ -144,9 +145,8 @@ def compute_legacy_preference_key(
|
||||
) -> int:
|
||||
"""Compute the legacy preference key: (object_id_hash ^ device_id) ^ version.
|
||||
|
||||
This is the key existing devices have data stored under. Slot-based backends
|
||||
(ESP8266, RP2040) still use it directly; key-lookup backends compute it as the
|
||||
migration source in EntityBase::make_entity_preference_() (entity_base.cpp).
|
||||
This is the key EntityBase::make_entity_preference_() (entity_base.cpp)
|
||||
stores every entity preference under.
|
||||
"""
|
||||
object_id_hash = fnv1_hash_object_id(entity_name)
|
||||
preference_hash = object_id_hash ^ device_id
|
||||
@@ -179,8 +179,8 @@ def test_legacy_preference_key_computation(
|
||||
) -> None:
|
||||
"""Verify legacy preference key computation matches expected values.
|
||||
|
||||
This test ensures the formula doesn't change, which would break both slot-based
|
||||
preference storage and the migration source keys on key-lookup backends.
|
||||
This test ensures the formula doesn't change, which would lose stored
|
||||
preferences on every platform.
|
||||
"""
|
||||
actual_key = compute_legacy_preference_key(entity_name, version, device_id)
|
||||
|
||||
@@ -215,12 +215,12 @@ def test_legacy_preference_key_computation(
|
||||
],
|
||||
)
|
||||
def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None:
|
||||
"""Verify fnv1_hash_name produces stable entity keys.
|
||||
"""Verify fnv1_hash_name produces stable raw-name hashes.
|
||||
|
||||
CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to
|
||||
API clients and is the new preference key base; changing the algorithm
|
||||
would break state routing and lose stored preferences.
|
||||
Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h.
|
||||
CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored
|
||||
preferences under keys derived from this hash; a future key migration must
|
||||
reconstruct those keys, and changing the algorithm would strand that data.
|
||||
Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores.
|
||||
"""
|
||||
actual = fnv1_hash_name(entity_name)
|
||||
assert actual == expected_key, (
|
||||
|
||||
Reference in New Issue
Block a user