mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 14:46:20 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d1f1f517b | ||
|
|
fa910f0a32 | ||
|
|
3465ee3ca9 | ||
|
|
077041e072 | ||
|
|
1dc2bc47b4 | ||
|
|
f3a464b367 | ||
|
|
d5a19064ba | ||
|
|
e4728a1aa8 | ||
|
|
aa11809c39 | ||
|
|
c1481bbb5d | ||
|
|
0fbbee2e94 | ||
|
|
3ee8aaf77c | ||
|
|
740d60a4c5 | ||
|
|
9abe462173 | ||
|
|
1072d6b070 | ||
|
|
7b0541cd23 | ||
|
|
51ca5ffe49 | ||
|
|
69bcbe3ae3 | ||
|
|
965d9c940a |
@@ -70,7 +70,6 @@ async function isStackedPr(github, context) {
|
||||
async function detectMergeBranch(github, context) {
|
||||
const labels = new Set();
|
||||
const baseRef = context.payload.pull_request.base.ref;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
|
||||
if (baseRef === 'release') {
|
||||
labels.add('merging-to-release');
|
||||
@@ -79,7 +78,7 @@ async function detectMergeBranch(github, context) {
|
||||
} else if (await isStackedPr(github, context)) {
|
||||
// GitHub manages the merge order for a stack, so these are not blocked.
|
||||
labels.add('stacked-pr');
|
||||
} else if (baseRef !== defaultBranch) {
|
||||
} else if (baseRef !== 'dev') {
|
||||
// A chain built by hand: it must not merge until its base branch does.
|
||||
labels.add('chained-pr');
|
||||
}
|
||||
|
||||
@@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
|
||||
|
||||
// Builds a fresh context for detectMergeBranch tests instead of mutating the
|
||||
// shared CONTEXT fixture above (which other describe blocks rely on).
|
||||
function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) {
|
||||
function makeMergeContext(baseRef, { stack } = {}) {
|
||||
const pull_request = { number: 1, base: { ref: baseRef } };
|
||||
if (stack !== undefined) {
|
||||
pull_request.stack = stack;
|
||||
}
|
||||
return {
|
||||
repo: { owner: 'esphome', repo: 'esphome' },
|
||||
payload: { pull_request, repository: { default_branch: defaultBranch } }
|
||||
payload: { pull_request }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,21 +136,6 @@ describe('detectMergeBranch', () => {
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
assert.equal(state.calls, 1);
|
||||
});
|
||||
|
||||
it('base ref matches default branch adds no labels', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('other', { defaultBranch: 'other' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), []);
|
||||
});
|
||||
|
||||
it('base ref dev when the default branch is main adds chained-pr', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('dev', { defaultBranch: 'main' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -179,7 +179,6 @@ 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
|
||||
@@ -445,12 +444,8 @@ jobs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: >-
|
||||
github.repository == 'esphome/esphome' && (
|
||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
)
|
||||
# CodSpeed benchmarks require a CodSpeed account linked to the repository to run
|
||||
# (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself.
|
||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+14
-14
@@ -2732,8 +2732,7 @@ def run_esphome(argv):
|
||||
conf_path.name,
|
||||
)
|
||||
|
||||
cache_missed = config is None
|
||||
if cache_missed:
|
||||
if config is None:
|
||||
from esphome.config import read_config
|
||||
|
||||
config = read_config(
|
||||
@@ -2742,25 +2741,26 @@ def run_esphome(argv):
|
||||
# Snapshot only needed by `esphome config --no-defaults`.
|
||||
snapshot_user_config=getattr(args, "no_defaults", False),
|
||||
)
|
||||
if config is None:
|
||||
return 2
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
# sidecar is absent (no compile has run): the cache would
|
||||
# never be loaded back, so writing secrets to disk is wasted.
|
||||
if cache_eligible and config is not None:
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.storage_json import ext_storage_path
|
||||
|
||||
if ext_storage_path(conf_path.name).exists():
|
||||
save_compiled_config(config)
|
||||
if config is None:
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
# Fallback for platforms whose validators didn't set the toolchain
|
||||
# (only the esp32 component reads esp32.framework.toolchain). All
|
||||
# other platforms only support PlatformIO today. Must run before the
|
||||
# cache refresh below so its sidecar records the same toolchain a
|
||||
# compile would.
|
||||
# other platforms only support PlatformIO today.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config.
|
||||
if cache_eligible and cache_missed:
|
||||
from esphome.compiled_config import save_compiled_config_and_sidecar
|
||||
|
||||
save_compiled_config_and_sidecar(config)
|
||||
|
||||
if args.command not in POST_CONFIG_ACTIONS:
|
||||
safe_print(f"Unknown command {args.command}")
|
||||
return 1
|
||||
|
||||
@@ -18,9 +18,9 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from esphome.const import __version__ as ESPHOME_VERSION
|
||||
from esphome.core import CORE, EsphomeError, Lambda
|
||||
from esphome.core import CORE, Lambda
|
||||
from esphome.helpers import write_file
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path, storage_path
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -65,71 +65,7 @@ def save_compiled_config(config: ConfigType) -> None:
|
||||
# non-basic dict key), so every upload/logs pays the slow path.
|
||||
_LOGGER.warning("Cannot cache the validated config: %s", err)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Likely persistent (permissions, full disk): every upload/logs
|
||||
# pays the slow path until it clears, so surface it.
|
||||
_LOGGER.warning("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def save_compiled_config_and_sidecar(config: ConfigType) -> None:
|
||||
"""Refresh the cache from the upload/logs fallback (CORE.config must be set).
|
||||
|
||||
The cache is only written when a complete sidecar is on disk:
|
||||
load_compiled_config can't use it otherwise, and it holds resolved
|
||||
secrets.
|
||||
"""
|
||||
if _refresh_sidecar():
|
||||
save_compiled_config(config)
|
||||
|
||||
|
||||
def _refresh_sidecar() -> bool:
|
||||
"""Ensure a complete sidecar is on disk; True when one is.
|
||||
|
||||
Writes one (without claiming a build) when missing or wizard-only.
|
||||
Failures are non-fatal; the next upload/logs pays the slow path again.
|
||||
"""
|
||||
try:
|
||||
path = storage_path()
|
||||
try:
|
||||
old = StorageJSON.load_strict(path)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Present but unreadable: it may hold a real build's metadata,
|
||||
# and a fresh rewrite would also stop the next compile from
|
||||
# cleaning a possibly incoherent build tree.
|
||||
_LOGGER.warning(
|
||||
"Not caching: storage sidecar %s is unreadable (%s)", path, err
|
||||
)
|
||||
return False
|
||||
if old is not None and old.can_apply_to_core():
|
||||
# Compile-written; nothing to refresh.
|
||||
return True
|
||||
if CORE.build_path is not None and CORE.build_path.exists():
|
||||
# An unvalidated build tree: its absent or mismatched sidecar
|
||||
# is what makes the next compile wipe it, so don't vouch for
|
||||
# a build this run never saw.
|
||||
_LOGGER.warning(
|
||||
"Not caching: build tree %s has no matching sidecar; "
|
||||
"'esphome compile' will settle it",
|
||||
CORE.build_path,
|
||||
)
|
||||
return False
|
||||
new = StorageJSON.from_esphome_core(CORE, old, claim_build=False)
|
||||
if not new.can_apply_to_core():
|
||||
_LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete")
|
||||
return False
|
||||
new.save(path)
|
||||
return True
|
||||
except (OSError, EsphomeError) as err:
|
||||
# write_file wraps OSError into EsphomeError. Persistent
|
||||
# (unwritable storage dir), so surface that every upload/logs
|
||||
# pays the slow path.
|
||||
_LOGGER.warning("Could not refresh the storage sidecar: %s", err)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# A structural bug; keep the traceback so it isn't mistaken
|
||||
# for the I/O failure above.
|
||||
_LOGGER.warning(
|
||||
"Unexpected error refreshing the storage sidecar", exc_info=True
|
||||
)
|
||||
return False
|
||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
@@ -162,8 +98,11 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
if storage is None or not storage.can_apply_to_core():
|
||||
_LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete")
|
||||
if storage is None:
|
||||
return None
|
||||
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
|
||||
# sidecars leave both of these unset and can't drive upload/logs.
|
||||
if not storage.core_platform and not storage.target_platform:
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""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"),
|
||||
}
|
||||
@@ -497,11 +497,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
# and plaintext disabled. Only a factory reset can remove it.
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
cg.add_define("USE_API_NOISE")
|
||||
cg.add_library(
|
||||
"noise-c",
|
||||
None,
|
||||
"https://github.com/esphome-libs/noise-c#chachapoly-stack-scratch",
|
||||
)
|
||||
cg.add_library("esphome/noise-c", "0.1.11")
|
||||
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
||||
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
||||
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
||||
|
||||
@@ -160,6 +160,11 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
|
||||
#else
|
||||
#error "No frame helper defined"
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
if (camera::Camera::instance() != nullptr) {
|
||||
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIConnection::start() {
|
||||
@@ -443,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_object_id_hash();
|
||||
msg.key = entity->get_entity_key();
|
||||
#ifdef USE_DEVICES
|
||||
msg.device_id = entity->get_device_id();
|
||||
#endif
|
||||
@@ -454,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_object_id_hash();
|
||||
msg.key = entity->get_entity_key();
|
||||
|
||||
if (entity->has_own_name()) {
|
||||
msg.name = entity->get_name();
|
||||
@@ -1135,7 +1140,6 @@ void APIConnection::try_send_camera_image_() {
|
||||
if (!this->image_reader_)
|
||||
return;
|
||||
|
||||
const auto *cam = camera::Camera::instance();
|
||||
// Send as many chunks as possible without blocking
|
||||
while (this->image_reader_->available()) {
|
||||
if (!this->helper_->can_write_without_blocking())
|
||||
@@ -1145,11 +1149,11 @@ void APIConnection::try_send_camera_image_() {
|
||||
bool done = this->image_reader_->available() == to_send;
|
||||
|
||||
CameraImageResponse msg;
|
||||
msg.key = cam->get_object_id_hash();
|
||||
msg.key = camera::Camera::instance()->get_entity_key();
|
||||
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
|
||||
msg.done = done;
|
||||
#ifdef USE_DEVICES
|
||||
msg.device_id = cam->get_device_id();
|
||||
msg.device_id = camera::Camera::instance()->get_device_id();
|
||||
#endif
|
||||
|
||||
if (!this->send_message(msg)) {
|
||||
@@ -1165,19 +1169,15 @@ void APIConnection::try_send_camera_image_() {
|
||||
void APIConnection::set_camera_state(std::shared_ptr<camera::CameraImage> image) {
|
||||
if (!this->flags_.state_subscription)
|
||||
return;
|
||||
if (this->image_reader_ && this->image_reader_->available())
|
||||
if (!this->image_reader_)
|
||||
return;
|
||||
if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE))
|
||||
if (this->image_reader_->available())
|
||||
return;
|
||||
if (!this->image_reader_) {
|
||||
// Created on the first image this connection will send, so connections
|
||||
// that never receive one never pay for a reader. Only a registered
|
||||
// camera's listener can reach this, so instance() is non-null here.
|
||||
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
|
||||
if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) {
|
||||
this->image_reader_->set_image(std::move(image));
|
||||
// Try to send immediately to reduce latency
|
||||
this->try_send_camera_image_();
|
||||
}
|
||||
this->image_reader_->set_image(std::move(image));
|
||||
// Try to send immediately to reduce latency
|
||||
this->try_send_camera_image_();
|
||||
}
|
||||
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
|
||||
auto *camera = static_cast<camera::Camera *>(entity);
|
||||
|
||||
@@ -591,21 +591,18 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
||||
*/
|
||||
APIError APINoiseFrameHelper::init_handshake_() {
|
||||
int err;
|
||||
// Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack:
|
||||
// noise_handshakestate_new_by_id copies it, so a member would waste
|
||||
// 104 bytes per connection, and a static const would sit in RAM on
|
||||
// ESP8266 (.rodata is DRAM there).
|
||||
const NoiseProtocolId nid = {
|
||||
.prefix_id = NOISE_PREFIX_STANDARD,
|
||||
.pattern_id = NOISE_PATTERN_NN,
|
||||
.modifier_ids = {NOISE_MODIFIER_PSK0},
|
||||
.dh_id = NOISE_DH_CURVE25519,
|
||||
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
|
||||
.hash_id = NOISE_HASH_SHA256,
|
||||
.hybrid_id = NOISE_DH_NONE,
|
||||
};
|
||||
memset(&nid_, 0, sizeof(nid_));
|
||||
// const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
|
||||
// err = noise_protocol_name_to_id(&nid_, proto, strlen(proto));
|
||||
nid_.pattern_id = NOISE_PATTERN_NN;
|
||||
nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY;
|
||||
nid_.dh_id = NOISE_DH_CURVE25519;
|
||||
nid_.prefix_id = NOISE_PREFIX_STANDARD;
|
||||
nid_.hybrid_id = NOISE_DH_NONE;
|
||||
nid_.hash_id = NOISE_HASH_SHA256;
|
||||
nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0;
|
||||
|
||||
err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER);
|
||||
err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER);
|
||||
APIError aerr =
|
||||
handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||
if (aerr != APIError::OK)
|
||||
|
||||
@@ -63,6 +63,9 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
// Buffer for noise handshake prologue (released after handshake)
|
||||
APIBuffer prologue_;
|
||||
|
||||
// NoiseProtocolId (size depends on implementation)
|
||||
NoiseProtocolId nid_;
|
||||
|
||||
// Group small types together
|
||||
// Fixed-size header buffer for noise protocol:
|
||||
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
|
||||
|
||||
@@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
|
||||
on this component and contain no SDK calls of their own.
|
||||
|
||||
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
|
||||
(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in
|
||||
to_code; unknown families are capability-checked at compile time via
|
||||
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
|
||||
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
|
||||
fails with a clear #error.
|
||||
(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time,
|
||||
not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken
|
||||
BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only
|
||||
for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail
|
||||
with a clear #error.
|
||||
|
||||
No framework patch is needed: the LibreTiny beken-72xx builder already compiles
|
||||
and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x;
|
||||
@@ -21,16 +21,9 @@ import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import libretiny
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7231Q,
|
||||
FAMILY_BK7231T,
|
||||
FAMILY_BK7238,
|
||||
FAMILY_BK7251,
|
||||
)
|
||||
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["bk72xx"]
|
||||
@@ -57,32 +50,7 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
|
||||
|
||||
|
||||
def _unsupported_family_message(family: str) -> str | None:
|
||||
if family in (FAMILY_BK7231T, FAMILY_BK7251):
|
||||
return (
|
||||
f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 "
|
||||
"stack; a BLE 5.x SoC such as BK7231N or BK7238 is required"
|
||||
)
|
||||
if family == FAMILY_BK7231Q:
|
||||
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
|
||||
return None
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
# Warn only: a hard error here would break the validate-only CI fixtures,
|
||||
# which run on a BLE 4.2 board. The hard error is raised at codegen.
|
||||
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
|
||||
_LOGGER.warning("%s (this configuration cannot compile)", msg)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
|
||||
raise EsphomeError(msg)
|
||||
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#ifdef USE_BK72XX_BLE
|
||||
|
||||
// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error).
|
||||
#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h")
|
||||
#if !defined(CLANG_TIDY) && __has_include("ble_api.h")
|
||||
|
||||
extern "C" {
|
||||
#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t,
|
||||
@@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) {
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
#endif // !CLANG_TIDY && ble_api.h && app_ble.h
|
||||
#endif // !CLANG_TIDY && ble_api.h
|
||||
#endif // USE_BK72XX_BLE
|
||||
|
||||
@@ -34,26 +34,22 @@
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK-capability gate (not a chip allowlist).
|
||||
// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be
|
||||
// the probe: it ships for every SoC (driver/include) and merely switches on
|
||||
// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the
|
||||
// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports
|
||||
// any BLE-5.x chip — present or future — without a hard-coded list, and a
|
||||
// non-5.x build fails here with a clear message instead of a cryptic
|
||||
// "app_ble.h: No such file or directory".
|
||||
// This component drives the Beken BLE *5.x* controller via its public API,
|
||||
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
|
||||
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
|
||||
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
|
||||
// itself so any BLE-5.x Beken chip — present or future — is supported without a
|
||||
// hard-coded list, and a non-5.x build fails here with a clear message instead
|
||||
// of a cryptic "ble_api.h: No such file or directory".
|
||||
// ---------------------------------------------------------------------------
|
||||
#if defined(CLANG_TIDY)
|
||||
// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API
|
||||
// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing
|
||||
// accurate to analyze the SDK calls against — skip the file under analysis.
|
||||
#define BK72XX_BLE_NO_SDK
|
||||
#elif !__has_include("ble_api.h") || !__has_include("app_ble.h")
|
||||
// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2
|
||||
// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by
|
||||
// one and bury this message.
|
||||
#define BK72XX_BLE_NO_SDK
|
||||
#elif !__has_include("ble_api.h")
|
||||
#error \
|
||||
"bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
|
||||
"bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
|
||||
#endif
|
||||
|
||||
#ifndef BK72XX_BLE_NO_SDK
|
||||
|
||||
@@ -37,7 +37,7 @@ from esphome.const import (
|
||||
CONF_INTERVAL,
|
||||
KEY_TARGET_PLATFORM,
|
||||
)
|
||||
from esphome.core import CORE, ID, KEY_CORE, TimePeriod
|
||||
from esphome.core import CORE, ID, KEY_CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
@@ -243,27 +243,19 @@ 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 | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
|
||||
window_default: str = "30ms",
|
||||
) -> 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). 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.
|
||||
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.
|
||||
"""
|
||||
schema = {
|
||||
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
|
||||
|
||||
@@ -103,8 +103,7 @@ struct CameraImageSpec {
|
||||
/** Abstract camera base class. Collaborates with API.
|
||||
* 1) API server starts and registers as a listener (add_listener)
|
||||
* to receive new images from the camera.
|
||||
* 2) API connection creates an image reader (create_image_reader) when it receives
|
||||
* the first image it will send.
|
||||
* 2) New API client connects and creates a new image reader (create_image_reader).
|
||||
* 3) API connection receives protobuf CameraImageRequest and calls request_image.
|
||||
* 3.a) API connection receives protobuf CameraImageRequest and calls start_stream.
|
||||
* 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners.
|
||||
|
||||
@@ -22,7 +22,6 @@ 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"
|
||||
@@ -36,7 +35,6 @@ 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,7 +3,6 @@ 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
|
||||
@@ -31,6 +30,7 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base")
|
||||
|
||||
CONF_ROTARY = "rotary"
|
||||
CONF_JOYSTICK = "joystick"
|
||||
CONF_LABEL = "label"
|
||||
CONF_MENU = "menu"
|
||||
CONF_BACK = "back"
|
||||
CONF_SELECT = "select"
|
||||
|
||||
@@ -570,9 +570,6 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "Factory format (Previously Modern)",
|
||||
|
||||
@@ -360,6 +360,17 @@ static bool has_fault_addr() {
|
||||
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
|
||||
}
|
||||
|
||||
// Append both cores' backtrace addresses to buf; returns the new position.
|
||||
static int append_all_backtraces(char *buf, int size, int pos) {
|
||||
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
|
||||
s_raw_crash_data.reg_frame_count);
|
||||
#if SOC_CPU_CORES_NUM > 1
|
||||
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count,
|
||||
s_raw_crash_data.other_reg_frame_count);
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
|
||||
// The record was captured by a different firmware build (it survives soft
|
||||
// resets, including the OTA reboot), so symbolizing its addresses against the
|
||||
// current ELF would produce misleading symbols. Print them with lowercase
|
||||
@@ -432,23 +443,11 @@ void crash_handler_log() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Build addr2line hints for easy copy-paste. One line per core: the two
|
||||
// backtraces are separate stacks, and a combined list decodes as one
|
||||
// impossible call chain (and can overflow the buffer, dropping addresses).
|
||||
static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf";
|
||||
// Build addr2line hint with all captured addresses for easy copy-paste
|
||||
char hint[256];
|
||||
int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc);
|
||||
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
|
||||
s_raw_crash_data.reg_frame_count);
|
||||
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc);
|
||||
append_all_backtraces(hint, sizeof(hint), pos);
|
||||
ESP_LOGE(TAG, "%s", hint);
|
||||
#if SOC_CPU_CORES_NUM > 1
|
||||
if (s_raw_crash_data.other_backtrace_count > 0) {
|
||||
pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD);
|
||||
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace,
|
||||
s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count);
|
||||
ESP_LOGE(TAG, "%s", hint);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::esp32
|
||||
|
||||
@@ -648,8 +648,6 @@ 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,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from esphome import automation
|
||||
@@ -10,7 +8,6 @@ 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,
|
||||
)
|
||||
@@ -38,12 +35,10 @@ from esphome.const import (
|
||||
CONF_SERVICE_UUID,
|
||||
CONF_TRIGGER_ID,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority
|
||||
from esphome.core import CORE, CoroPriority, 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"]
|
||||
@@ -130,71 +125,10 @@ 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.
|
||||
# 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
|
||||
)
|
||||
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms")
|
||||
|
||||
# Codegen helpers are owned by ble_device_base; kept under the historical names
|
||||
# here for the components that import them from this module.
|
||||
@@ -249,7 +183,6 @@ 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_SLOT, CONF_USE_PSRAM
|
||||
from esphome.components.const import CONF_USE_PSRAM
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CLK_PIN,
|
||||
@@ -16,10 +16,8 @@ from esphome.const import (
|
||||
CONF_VARIANT,
|
||||
)
|
||||
from esphome.cpp_generator import add_define
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@swoboda1337"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
# esp32_ble raises the task watchdog around the remote BT controller bring-up
|
||||
AUTO_LOAD = ["watchdog"]
|
||||
|
||||
@@ -35,6 +33,7 @@ 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
|
||||
@@ -126,22 +125,6 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
# The esp_hosted releases compatible with older ESP-IDF versions crash at
|
||||
# boot with a heap double free in the SDIO RX path (fixed in esp_hosted
|
||||
# 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time.
|
||||
if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0):
|
||||
raise cv.Invalid(
|
||||
f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. "
|
||||
"Remove the framework version from your configuration to use the "
|
||||
"recommended version, or pin a version at or above 5.3."
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def _configure_sdio(config):
|
||||
slot = config[CONF_SLOT]
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
@@ -269,14 +252,18 @@ async def to_code(config):
|
||||
if config[CONF_USE_PSRAM]:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
|
||||
|
||||
# Library versions; this component set requires ESP-IDF 5.3 or newer,
|
||||
# which is enforced at validation time.
|
||||
# Library versions
|
||||
idf_ver = esp32.idf_version()
|
||||
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
|
||||
if idf_ver >= cv.Version(5, 5, 0):
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
|
||||
else:
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11")
|
||||
esp32.add_extra_script(
|
||||
"post",
|
||||
"esp32_hosted.py",
|
||||
|
||||
@@ -113,9 +113,6 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "Standard format",
|
||||
|
||||
@@ -355,7 +355,7 @@ def _validate(config):
|
||||
" clk:\n"
|
||||
" mode: %s\n"
|
||||
" pin: %s\n"
|
||||
"Removal scheduled for 2026.11.0.",
|
||||
"Removal scheduled for 2026.9.0.",
|
||||
config[CONF_CLK_MODE],
|
||||
mode,
|
||||
pin,
|
||||
|
||||
@@ -154,12 +154,8 @@ 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) {
|
||||
#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());
|
||||
api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(),
|
||||
&data.get_raw_data());
|
||||
}
|
||||
#endif
|
||||
return false; // Don't consume the event, allow other listeners to process it
|
||||
|
||||
@@ -184,6 +184,8 @@ static int32_t get_firmware_int(const char *version_string) {
|
||||
return result;
|
||||
}
|
||||
|
||||
float LD2420Component::get_setup_priority() const { return setup_priority::BUS; }
|
||||
|
||||
void LD2420Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"LD2420:\n"
|
||||
|
||||
@@ -105,6 +105,7 @@ class LD2420Component final : public Component, public uart::UARTDevice {
|
||||
void apply_config_action();
|
||||
void factory_reset_action();
|
||||
void revert_config_action();
|
||||
float get_setup_priority() const override;
|
||||
int send_cmd_from_array(CmdFrameT cmd_frame);
|
||||
void report_gate_data();
|
||||
void handle_cmd_error(uint16_t error);
|
||||
|
||||
@@ -182,9 +182,6 @@ def get_download_types(storage_json: StorageJSON = None):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
types = [
|
||||
{
|
||||
"title": "UF2 package (recommended)",
|
||||
|
||||
@@ -444,7 +444,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r
|
||||
lv_indev_set_type(this->drv_, LV_INDEV_TYPE_POINTER);
|
||||
lv_indev_set_disp(this->drv_, parent->get_disp());
|
||||
lv_indev_set_long_press_time(this->drv_, long_press_time);
|
||||
lv_indev_set_long_press_repeat_time(this->drv_, long_press_repeat_time);
|
||||
// long press repeat time TBD
|
||||
lv_indev_set_user_data(this->drv_, this);
|
||||
lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) {
|
||||
auto *l = static_cast<LVTouchListener *>(lv_indev_get_user_data(d));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from esphome.components.const import CONF_LABEL
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_TEXT
|
||||
|
||||
@@ -15,6 +14,8 @@ from ..schemas import TEXT_SCHEMA
|
||||
from ..types import LvText
|
||||
from . import Widget, WidgetType
|
||||
|
||||
CONF_LABEL = "label"
|
||||
|
||||
|
||||
class LabelType(WidgetType):
|
||||
def __init__(self):
|
||||
|
||||
@@ -63,6 +63,7 @@ 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"]
|
||||
@@ -332,6 +333,68 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
# Platforms whose MQTT components subscribe to an object_id-derived command topic.
|
||||
# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus
|
||||
# text, whose MQTT component subscribes a command topic that cannot be overridden.
|
||||
_COMMAND_TOPIC_PLATFORMS = frozenset(
|
||||
{
|
||||
"alarm_control_panel",
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
"datetime",
|
||||
"fan",
|
||||
"light",
|
||||
"lock",
|
||||
"number",
|
||||
"select",
|
||||
"switch",
|
||||
"text",
|
||||
"update",
|
||||
"valve",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Platforms whose MQTT components derive extra sub-topics (position/command,
|
||||
# mode/command, speed/command, ...) from the object_id, each with its own config
|
||||
# key; custom state and command topics cannot exempt them from conflicting.
|
||||
_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"})
|
||||
|
||||
|
||||
def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool:
|
||||
"""Check whether more than one entity actually uses an object_id-derived topic.
|
||||
|
||||
An empty topic_prefix disables default topics entirely, custom state and
|
||||
command topics avoid the default topics, and disabling discovery (globally
|
||||
or per entity) avoids the discovery config topic.
|
||||
"""
|
||||
if config[CONF_TOPIC_PREFIX]:
|
||||
platform = entities[0].platform
|
||||
if platform in _SUB_TOPIC_PLATFORMS:
|
||||
return True
|
||||
if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1:
|
||||
return True
|
||||
if (
|
||||
platform in _COMMAND_TOPIC_PLATFORMS
|
||||
and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1
|
||||
):
|
||||
return True
|
||||
if not config[CONF_DISCOVERY]:
|
||||
return False
|
||||
discovery_entities = sum(
|
||||
entity.config.get(CONF_DISCOVERY, True) for entity in entities
|
||||
)
|
||||
return discovery_entities > 1
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts(
|
||||
"mqtt builds default topics and discovery topics from the entity object_id, "
|
||||
"which is the name converted to ASCII",
|
||||
conflict_filter=_topics_conflict,
|
||||
)
|
||||
|
||||
|
||||
def exp_mqtt_message(config):
|
||||
if config is None:
|
||||
return cg.optional(cg.TemplateArguments(MQTTMessage))
|
||||
|
||||
@@ -473,9 +473,6 @@ def copy_files() -> None:
|
||||
|
||||
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
|
||||
"""Get the download types for the firmware."""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
types = []
|
||||
UF2_PATH = "zephyr/zephyr.uf2"
|
||||
DFU_PATH = "firmware.zip"
|
||||
|
||||
@@ -3,6 +3,7 @@ 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"]
|
||||
@@ -35,6 +36,11 @@ 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,12 +99,8 @@ 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) {
|
||||
#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());
|
||||
api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(),
|
||||
&data.get_raw_data());
|
||||
}
|
||||
#endif
|
||||
return false; // Don't consume the event, allow other listeners to process it
|
||||
|
||||
@@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() {
|
||||
}
|
||||
|
||||
if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) {
|
||||
this->store_.counter = std::clamp<int32_t>(0, this->store_.min_value, this->store_.max_value);
|
||||
this->store_.counter = 0;
|
||||
}
|
||||
int counter = this->store_.counter;
|
||||
if (this->store_.last_read != counter || this->publish_initial_value_) {
|
||||
|
||||
@@ -156,9 +156,6 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "UF2 factory format",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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 (
|
||||
@@ -46,6 +45,7 @@ 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"
|
||||
|
||||
@@ -283,11 +283,8 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1)
|
||||
void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; }
|
||||
|
||||
optional<float> DeltaFilter::new_value(float value) {
|
||||
const bool no_value = std::isnan(value);
|
||||
const bool no_reference = std::isnan(this->last_value_);
|
||||
if (no_value && no_reference)
|
||||
return {};
|
||||
if (no_value || no_reference) {
|
||||
// Always yield the first value.
|
||||
if (std::isnan(this->last_value_)) {
|
||||
this->last_value_ = value;
|
||||
return value;
|
||||
}
|
||||
@@ -296,7 +293,8 @@ optional<float> DeltaFilter::new_value(float value) {
|
||||
float min = fabsf(this->min_a0_ + ref * this->min_a1_);
|
||||
float max = fabsf(this->max_a0_ + ref * this->max_a1_);
|
||||
float delta = fabsf(value - ref);
|
||||
// accept only if within range
|
||||
// if there is no reference, e.g. for the first value, just accept this one,
|
||||
// otherwise accept only if within range.
|
||||
if (delta > min && delta <= max) {
|
||||
this->last_value_ = value;
|
||||
return value;
|
||||
|
||||
@@ -20,18 +20,14 @@ void TemplateText::setup() {
|
||||
|
||||
// Need std::string for pref_->setup() to fill from flash
|
||||
std::string value{this->initial_value_ != nullptr ? this->initial_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);
|
||||
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);
|
||||
if (!value.empty())
|
||||
this->publish_state(value);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ class TemplateTextSaverBase {
|
||||
public:
|
||||
virtual bool save(const std::string &value) { return true; }
|
||||
|
||||
virtual void setup(uint32_t id, std::string &value) {}
|
||||
/// 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) {}
|
||||
|
||||
protected:
|
||||
ESPPreferenceObject pref_;
|
||||
@@ -45,11 +47,16 @@ 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, std::string &value) override {
|
||||
this->pref_ = global_preferences->make_preference<uint8_t[SZ + 1]>(id);
|
||||
|
||||
void setup(uint32_t id, uint32_t old_id, std::string &value) override {
|
||||
char temp[SZ + 1];
|
||||
#ifdef USE_PREFERENCE_KEY_LOOKUP
|
||||
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);
|
||||
bool hasdata = this->pref_.load(&temp);
|
||||
#endif
|
||||
|
||||
if (hasdata) {
|
||||
size_t len = static_cast<uint8_t>(temp[0]);
|
||||
|
||||
@@ -307,11 +307,6 @@ void ZigbeeComponent::setup() {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef CONFIG_ZB_ZCZR
|
||||
ezb_bdb_set_router_rejoin_required(true);
|
||||
#endif
|
||||
|
||||
ezb_aps_secur_enable_distributed_security(false);
|
||||
ezb_nwk_set_min_join_lqi(32);
|
||||
if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) {
|
||||
|
||||
@@ -285,7 +285,7 @@ async def attributes_to_code(
|
||||
async def esp32_to_code(config: ConfigType) -> "MockObj":
|
||||
add_idf_component(
|
||||
name="espressif/esp-zigbee-lib",
|
||||
ref="2.0.4",
|
||||
ref="2.0.3",
|
||||
)
|
||||
|
||||
# add sdkconfigs later so they can overwrite esp32 defaults
|
||||
|
||||
@@ -99,10 +99,6 @@ from esphome.schema_extractors import (
|
||||
schema_extractor_registry,
|
||||
schema_extractor_typed,
|
||||
)
|
||||
|
||||
# Deprecated re-export for external components; remove before 2027.2.0
|
||||
# pylint: disable-next=unused-import
|
||||
from esphome.util import parse_esphome_version # noqa: F401
|
||||
from esphome.voluptuous_schema import _Schema
|
||||
from esphome.yaml_util import SensitiveStr, make_data_base
|
||||
|
||||
|
||||
@@ -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 object_id_hash, uint32_t entity_fields) { \
|
||||
obj->configure_entity_(name, object_id_hash, entity_fields); \
|
||||
void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \
|
||||
obj->configure_entity_(name, entity_key, 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_object_id_hash() == key && obj->get_device_id() == device_id && \
|
||||
if (obj->get_entity_key() == 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_object_id_hash() == key && (include_internal || !obj->is_internal())) \
|
||||
if (obj->get_entity_key() == 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 object_id_hash, uint32_t entity_fields) {
|
||||
void EntityBase::configure_entity_(const char *name, uint32_t entity_key, 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 object_id_hash, ui
|
||||
}
|
||||
}
|
||||
this->flags_.has_own_name = false;
|
||||
// Dynamic name - must calculate hash at runtime
|
||||
this->calc_object_id_();
|
||||
// Dynamic name - must calculate key at runtime
|
||||
this->calc_entity_key_();
|
||||
} else {
|
||||
this->flags_.has_own_name = true;
|
||||
// Static name - use pre-computed hash if provided
|
||||
if (object_id_hash != 0) {
|
||||
this->object_id_hash_ = object_id_hash;
|
||||
// Static name - use pre-computed key if provided
|
||||
if (entity_key != 0) {
|
||||
this->entity_key_ = entity_key;
|
||||
} else {
|
||||
this->calc_object_id_();
|
||||
this->calc_entity_key_();
|
||||
}
|
||||
}
|
||||
// Unpack entity string table indices and flags from entity_fields.
|
||||
@@ -147,9 +147,15 @@ std::string EntityBase::get_icon() const {
|
||||
}
|
||||
#endif // !USE_ESP8266
|
||||
|
||||
// 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());
|
||||
// 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);
|
||||
}
|
||||
|
||||
size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const {
|
||||
@@ -167,16 +173,22 @@ 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 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);
|
||||
// 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
|
||||
}
|
||||
|
||||
#ifdef USE_ENTITY_ICON
|
||||
|
||||
+40
-36
@@ -73,8 +73,17 @@ 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 Object ID of this Entity
|
||||
uint32_t get_object_id_hash() const { return this->object_id_hash_; }
|
||||
// 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 object_id with zero heap allocation
|
||||
/// For static case: returns StringRef to internal storage (buffer unused)
|
||||
@@ -181,40 +190,24 @@ class EntityBase {
|
||||
// Set has_state - for components that need to manually set this
|
||||
void set_has_state(bool state) { this->flags_.has_state = state; }
|
||||
|
||||
/**
|
||||
* @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.7.0")
|
||||
uint32_t get_preference_hash() {
|
||||
/// 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
|
||||
// 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();
|
||||
return this->get_device_id();
|
||||
#else
|
||||
// Without devices, just use object_id_hash as before
|
||||
return this->get_object_id_hash();
|
||||
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.
|
||||
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_(); }
|
||||
|
||||
/// Create a preference object for storing this entity's state/settings.
|
||||
/// @tparam T The type of data to store (must be trivially copyable)
|
||||
/// @param version Optional version hash XORed with preference key (change when struct layout changes)
|
||||
@@ -230,9 +223,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, object_id hash, entity string indices, and flags.
|
||||
/// Combined entity setup from codegen: set name, entity key, 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 object_id_hash, uint32_t entity_fields);
|
||||
void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields);
|
||||
|
||||
#ifdef USE_DEVICES
|
||||
// Codegen-only setter — only accessible from setup() via friend declaration.
|
||||
@@ -240,13 +233,24 @@ class EntityBase {
|
||||
#endif
|
||||
|
||||
/// Non-template helper for make_entity_preference() to avoid code bloat.
|
||||
/// When the preference hash algorithm changes, migration logic goes here.
|
||||
/// 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
|
||||
ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version);
|
||||
|
||||
void calc_object_id_();
|
||||
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(); }
|
||||
|
||||
StringRef name_;
|
||||
uint32_t object_id_hash_{};
|
||||
uint32_t entity_key_{};
|
||||
#ifdef USE_DEVICES
|
||||
Device *device_{};
|
||||
#endif
|
||||
|
||||
+111
-79
@@ -25,25 +25,86 @@ from esphome.core.config import (
|
||||
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
|
||||
from esphome.cpp_types import App
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import (
|
||||
cpp_string_escape,
|
||||
fnv1_hash,
|
||||
fnv1_hash_object_id,
|
||||
sanitize,
|
||||
snake_case,
|
||||
)
|
||||
from esphome.helpers import cpp_string_escape, fnv1_hash_name, 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_OBJECT_ID_HASH = "_entity_object_id_hash"
|
||||
_KEY_ENTITY_KEY = "_entity_key"
|
||||
|
||||
# Bit layout for entity_fields in configure_entity_().
|
||||
# Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h
|
||||
@@ -306,7 +367,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None:
|
||||
standalone ``var->configure_entity_(name, hash, packed)``.
|
||||
"""
|
||||
entity_name = config[_KEY_ENTITY_NAME]
|
||||
object_id_hash = config[_KEY_OBJECT_ID_HASH]
|
||||
entity_key = config[_KEY_ENTITY_KEY]
|
||||
dc_idx = config.get(_KEY_DC_IDX, 0)
|
||||
uom_idx = config.get(_KEY_UOM_IDX, 0)
|
||||
icon_idx = config.get(_KEY_ICON_IDX, 0)
|
||||
@@ -326,57 +387,30 @@ 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, object_id_hash, packed
|
||||
var, entity_name, entity_key, packed
|
||||
)
|
||||
else:
|
||||
expr = var.configure_entity_(entity_name, object_id_hash, packed)
|
||||
expr = var.configure_entity_(entity_name, entity_key, packed)
|
||||
if comment:
|
||||
add(RawStatement(f"{expr}; // {comment}"))
|
||||
else:
|
||||
add(expr)
|
||||
|
||||
|
||||
def get_base_entity_object_id(
|
||||
def get_base_entity_name(
|
||||
name: str, friendly_name: str | None, device_name: str | None = None
|
||||
) -> str:
|
||||
"""Calculate the base object ID for an entity that will be set via set_object_id().
|
||||
"""Return the base name whose hash becomes this entity's key on the device.
|
||||
|
||||
This function calculates what object_id_c_str_ should be set to in C++.
|
||||
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.
|
||||
|
||||
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()
|
||||
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.
|
||||
"""
|
||||
|
||||
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))
|
||||
return name or device_name or friendly_name or CORE.name
|
||||
|
||||
|
||||
def setup_entity(var_or_platform, config=None, platform=None):
|
||||
@@ -435,15 +469,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 object_id hash for configure_entity_()
|
||||
# Pre-compute entity name and entity key for configure_entity_()
|
||||
# which is emitted later by finalize_entity_strings().
|
||||
# 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)
|
||||
# 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
|
||||
entity_name = config[CONF_NAME]
|
||||
object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0
|
||||
entity_key = fnv1_hash_name(entity_name) if entity_name else 0
|
||||
config[_KEY_ENTITY_NAME] = entity_name
|
||||
config[_KEY_OBJECT_ID_HASH] = object_id_hash
|
||||
config[_KEY_ENTITY_KEY] = entity_key
|
||||
# Store flags for packing into configure_entity_()
|
||||
config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT])
|
||||
if CONF_INTERNAL in config:
|
||||
@@ -556,16 +590,13 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
|
||||
# Use the device ID string directly for uniqueness
|
||||
device_id = device_id_obj.id
|
||||
|
||||
# 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
|
||||
)
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
# 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
|
||||
unique_key = (device_id, platform, name_hash)
|
||||
if unique_key in CORE.unique_ids:
|
||||
# Get the existing entity metadata
|
||||
@@ -590,26 +621,14 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
|
||||
if existing_component != "unknown":
|
||||
conflict_msg += f" from component '{existing_component}'"
|
||||
|
||||
# Distinguish names that sanitize to the same object_id from a genuine
|
||||
# 32-bit hash collision between two different object_ids
|
||||
# Different names can only clash here through a genuine hash collision
|
||||
collision_msg = ""
|
||||
if entity_name != existing_name:
|
||||
existing_object_id = get_base_entity_object_id(
|
||||
existing_name, CORE.friendly_name, existing_device or None
|
||||
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"
|
||||
)
|
||||
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
|
||||
@@ -621,6 +640,19 @@ 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,
|
||||
|
||||
+25
-4
@@ -809,6 +809,19 @@ 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>;
|
||||
@@ -1013,12 +1026,20 @@ 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 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) {
|
||||
/// 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) {
|
||||
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,10 +24,9 @@
|
||||
#endif
|
||||
|
||||
// Key-lookup preference backends find stored data by key; their platforms add the
|
||||
// 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;
|
||||
// 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;
|
||||
// migration is not possible there, and key collisions cannot corrupt data.
|
||||
|
||||
namespace esphome {
|
||||
@@ -105,9 +104,10 @@ 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; 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 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.
|
||||
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>;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#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,5 +56,17 @@ 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
|
||||
|
||||
@@ -15,13 +15,6 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() {
|
||||
// Set the wake-requested flag BEFORE esp_schedule so the consumer is
|
||||
// guaranteed to see it on its next gate check.
|
||||
wake_request_set();
|
||||
// Skip the post when a wake was already signalled and not yet consumed by
|
||||
// wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code,
|
||||
// which must not be poked per-byte from the software serial RX ISR (see
|
||||
// esphome#18409). The flag can stay latched while the loop is awake, which
|
||||
// is intentional; posts are only needed to cut a suspend short.
|
||||
if (g_main_loop_woke)
|
||||
return;
|
||||
g_main_loop_woke = true;
|
||||
esp_schedule();
|
||||
}
|
||||
|
||||
@@ -109,8 +109,6 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]:
|
||||
env_cache = _cache().env
|
||||
if version not in env_cache:
|
||||
env_cache[version] = os.environ.copy()
|
||||
# Do not leak PYTHONPATH into child env
|
||||
env_cache[version].pop("PYTHONPATH", None)
|
||||
|
||||
# Use provided IDF framework if available
|
||||
if "IDF_PATH" not in os.environ:
|
||||
|
||||
@@ -155,8 +155,6 @@ def run_command(
|
||||
_LOGGER.debug("%s - running ...", cmd_str)
|
||||
|
||||
run_env = os.environ.copy()
|
||||
# Do not leak PYTHONPATH
|
||||
run_env.pop("PYTHONPATH", None)
|
||||
if env:
|
||||
run_env.update(env)
|
||||
|
||||
|
||||
+10
-5
@@ -91,8 +91,13 @@ 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.
|
||||
If you modify this function, update the C++ version and tests in both places.
|
||||
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.
|
||||
"""
|
||||
return fnv1_hash(sanitize(snake_case(name)))
|
||||
|
||||
@@ -100,9 +105,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).
|
||||
|
||||
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).
|
||||
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.
|
||||
"""
|
||||
return _fnv1_hash(name.encode("utf-8"))
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ dependencies:
|
||||
rules:
|
||||
- if: "target in [esp32, esp32p4]"
|
||||
espressif/esp-zigbee-lib:
|
||||
version: 2.0.4
|
||||
version: 2.0.3
|
||||
rules:
|
||||
- if: "target in [esp32h2, esp32c5, esp32c6]"
|
||||
espressif/lan87xx:
|
||||
|
||||
+38
-25
@@ -269,9 +269,10 @@ 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_meta = get_alias_metadata().get(domain)
|
||||
if alias_meta is not None:
|
||||
manif = _lookup_module(alias_meta.canonical, exception)
|
||||
alias_map = _get_alias_map()
|
||||
if domain in alias_map:
|
||||
canonical = alias_map[domain]
|
||||
manif = _lookup_module(canonical, exception)
|
||||
if manif is not None:
|
||||
_COMPONENT_CACHE[domain] = manif
|
||||
return manif
|
||||
@@ -328,10 +329,8 @@ 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``, 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:
|
||||
# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two
|
||||
# integrations are then wired up automatically:
|
||||
#
|
||||
# 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``)
|
||||
# intercepts ``esphome.components.<legacy>``/``...<legacy>.<sub>``
|
||||
@@ -345,13 +344,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non
|
||||
# dependency checks, schema validation and codegen all see only the
|
||||
# canonical name.
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
|
||||
_ALIAS_MAP_CACHE: dict[str, str] | None = None
|
||||
_ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None
|
||||
|
||||
|
||||
@@ -368,17 +367,31 @@ class AliasMeta:
|
||||
removal_version: str | None
|
||||
|
||||
|
||||
def get_alias_metadata() -> dict[str, AliasMeta]:
|
||||
"""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
|
||||
def _ensure_alias_caches() -> None:
|
||||
"""Populate both alias caches from a single directory scan.
|
||||
|
||||
_ALIAS_META_CACHE = {
|
||||
alias: AliasMeta(canonical=canonical, removal_version=removal_version)
|
||||
for alias, (canonical, removal_version) in COMPONENT_ALIASES.items()
|
||||
}
|
||||
``_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).
|
||||
|
||||
Used by the YAML pre-pass to format a per-alias deprecation warning.
|
||||
"""
|
||||
_ensure_alias_caches()
|
||||
return _ALIAS_META_CACHE
|
||||
|
||||
|
||||
@@ -524,11 +537,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder):
|
||||
# least three parts, so ``parts[2]`` (the domain) always exists.
|
||||
parts = fullname.split(".")
|
||||
domain = parts[2]
|
||||
alias_meta = get_alias_metadata().get(domain)
|
||||
if alias_meta is None:
|
||||
alias_map = _get_alias_map()
|
||||
if domain not in alias_map:
|
||||
return None
|
||||
|
||||
parts[2] = alias_meta.canonical
|
||||
parts[2] = alias_map[domain]
|
||||
canonical_fullname = ".".join(parts)
|
||||
try:
|
||||
canonical_module = importlib.import_module(canonical_fullname)
|
||||
|
||||
@@ -5,7 +5,6 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -235,35 +234,6 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
|
||||
_write_pio_stamp_python(stamp_file, current)
|
||||
|
||||
|
||||
def _ccache_usable() -> bool:
|
||||
"""Return True when the ``ccache`` on PATH actually runs.
|
||||
|
||||
``shutil.which`` proves existence, not runnability: on Windows it also
|
||||
matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose
|
||||
target is gone. Wrapping compiles around such a find fails every compile
|
||||
step with an opaque OS error, so probe once and fall back to compiling
|
||||
without ccache when the probe fails.
|
||||
"""
|
||||
ccache = shutil.which("ccache")
|
||||
if ccache is None:
|
||||
return False
|
||||
try:
|
||||
subprocess.run(
|
||||
[ccache, "--version"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
_LOGGER.warning(
|
||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||
ccache,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ccache_env() -> dict[str, str]:
|
||||
"""Return ccache settings for PlatformIO builds.
|
||||
|
||||
@@ -296,7 +266,7 @@ def _ccache_env() -> dict[str, str]:
|
||||
if "ESPHOME_CCACHE_ENABLE" in os.environ:
|
||||
enabled = get_bool_env("ESPHOME_CCACHE_ENABLE")
|
||||
else:
|
||||
enabled = _ccache_usable()
|
||||
enabled = shutil.which("ccache") is not None
|
||||
env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"}
|
||||
if not enabled:
|
||||
return env
|
||||
|
||||
+8
-48
@@ -71,11 +71,8 @@ def archive_storage_path() -> Path:
|
||||
|
||||
|
||||
def _to_path_if_not_none(value: str | None) -> Path | None:
|
||||
"""Convert a string to Path; None and the legacy "None" both map to None.
|
||||
|
||||
Sidecars written before as_dict skipped unset paths hold str(None).
|
||||
"""
|
||||
return Path(value) if value is not None and value != "None" else None
|
||||
"""Convert a string to Path if it's not None."""
|
||||
return Path(value) if value is not None else None
|
||||
|
||||
|
||||
def _parse_framework_version(framework_version: str) -> Version:
|
||||
@@ -173,10 +170,8 @@ class StorageJSON:
|
||||
"address": self.address,
|
||||
"web_port": self.web_port,
|
||||
"esp_platform": self.target_platform,
|
||||
"build_path": str(self.build_path) if self.build_path else None,
|
||||
"firmware_bin_path": (
|
||||
str(self.firmware_bin_path) if self.firmware_bin_path else None
|
||||
),
|
||||
"build_path": str(self.build_path),
|
||||
"firmware_bin_path": str(self.firmware_bin_path),
|
||||
"loaded_integrations": sorted(self.loaded_integrations),
|
||||
"loaded_platforms": sorted(self.loaded_platforms),
|
||||
"no_mdns": self.no_mdns,
|
||||
@@ -194,18 +189,7 @@ class StorageJSON:
|
||||
write_file_if_changed(path, self.to_json())
|
||||
|
||||
@staticmethod
|
||||
def from_esphome_core(
|
||||
esph: CoreType, old: StorageJSON | None, *, claim_build: bool = True
|
||||
) -> StorageJSON:
|
||||
"""Build a sidecar from post-validation CORE state.
|
||||
|
||||
claim_build=False (the upload/logs fallback, which runs no build)
|
||||
carries the build-artifact fields (esphome_version,
|
||||
firmware_bin_path) from *old* instead of asserting this run built
|
||||
firmware. Validation-derived fields (platform, framework_version,
|
||||
toolchain, build_path) always stamp; storage_should_clean compares
|
||||
them against the next compile.
|
||||
"""
|
||||
def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON:
|
||||
hardware = esph.target_platform.upper()
|
||||
framework_version: str | None = None
|
||||
if esph.is_esp32:
|
||||
@@ -220,21 +204,13 @@ class StorageJSON:
|
||||
name=esph.name,
|
||||
friendly_name=esph.friendly_name,
|
||||
comment=esph.comment,
|
||||
esphome_version=(
|
||||
const.__version__
|
||||
if claim_build
|
||||
else (old.esphome_version if old else None)
|
||||
),
|
||||
esphome_version=const.__version__,
|
||||
src_version=1,
|
||||
address=esph.address,
|
||||
web_port=esph.web_port,
|
||||
target_platform=hardware,
|
||||
build_path=esph.build_path,
|
||||
firmware_bin_path=(
|
||||
esph.firmware_bin
|
||||
if claim_build
|
||||
else (old.firmware_bin_path if old else None)
|
||||
),
|
||||
firmware_bin_path=esph.firmware_bin,
|
||||
loaded_integrations=esph.loaded_integrations,
|
||||
loaded_platforms=esph.loaded_platforms,
|
||||
no_mdns=(
|
||||
@@ -326,27 +302,11 @@ class StorageJSON:
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def load_strict(path: Path) -> StorageJSON | None:
|
||||
"""Like load, but None only means missing; an unreadable file raises."""
|
||||
if not path.is_file():
|
||||
return None
|
||||
return StorageJSON._load_impl(path)
|
||||
|
||||
def can_apply_to_core(self) -> bool:
|
||||
"""True when the sidecar carries everything apply_to_core hands CORE.
|
||||
|
||||
Wizard-written sidecars leave build_path unset (older wizards also
|
||||
the platform fields) and can't drive upload/logs.
|
||||
"""
|
||||
return bool((self.core_platform or self.target_platform) and self.build_path)
|
||||
|
||||
def apply_to_core(self) -> None:
|
||||
"""Populate CORE with the metadata upload/logs read.
|
||||
|
||||
Inverse of :meth:`from_esphome_core`. Keep paired -- a new
|
||||
attribute upload/logs needs has to be captured there too and
|
||||
reflected in :meth:`can_apply_to_core`.
|
||||
attribute upload/logs needs has to be captured there too.
|
||||
Validator-only fields (loaded_integrations/platforms,
|
||||
friendly_name) are skipped; the fast path doesn't run
|
||||
validation and CORE.__init__ defaults them.
|
||||
|
||||
@@ -390,20 +390,6 @@ def is_dev_esphome_version():
|
||||
return "dev" in const.__version__
|
||||
|
||||
|
||||
# Remove before 2027.2.0
|
||||
def parse_esphome_version() -> tuple[int, int, int]:
|
||||
"""Deprecated: use esphome.config_validation.require_esphome_version instead."""
|
||||
from esphome.core import Version
|
||||
|
||||
_LOGGER.warning(
|
||||
"parse_esphome_version() is deprecated. Use "
|
||||
"cv.require_esphome_version to gate on a minimum version. "
|
||||
"Removed in 2027.2.0"
|
||||
)
|
||||
version = Version.parse(const.__version__)
|
||||
return version.major, version.minor, version.patch
|
||||
|
||||
|
||||
# Custom OrderedDict with nicer repr method for debugging
|
||||
class OrderedDict(collections.OrderedDict):
|
||||
def __repr__(self):
|
||||
|
||||
+3
-3
@@ -45,7 +45,7 @@ lib_deps_base =
|
||||
lib_deps =
|
||||
${common.lib_deps_base}
|
||||
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
|
||||
https://github.com/esphome-libs/noise-c.git#chachapoly-stack-scratch ; api
|
||||
esphome/noise-c@0.1.11 ; api
|
||||
improv/Improv@1.2.6 ; improv_serial / esp32_improv
|
||||
kikuchan98/pngle@1.1.0 ; online_image
|
||||
; Using the repository directly, otherwise ESP-IDF can't use the library
|
||||
@@ -244,7 +244,7 @@ lib_deps =
|
||||
${common:idf-component-libs.lib_deps}
|
||||
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
|
||||
droscy/esp_wireguard@0.4.5 ; wireguard
|
||||
https://github.com/esphome-libs/noise-c.git#chachapoly-stack-scratch ; api
|
||||
esphome/noise-c@0.1.11 ; api
|
||||
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
|
||||
DNSServer ; captive_portal
|
||||
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
|
||||
@@ -641,7 +641,7 @@ build_unflags =
|
||||
extends = common
|
||||
platform = platformio/native
|
||||
lib_deps =
|
||||
https://github.com/esphome-libs/noise-c.git#chachapoly-stack-scratch ; used by api
|
||||
esphome/noise-c@0.1.11 ; used by api
|
||||
lvgl/lvgl@9.5.0 ; lvgl
|
||||
build_flags =
|
||||
${common.build_flags}
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.3.1
|
||||
click==8.3.3
|
||||
aioesphomeapi==45.10.2
|
||||
aioesphomeapi==45.10.1
|
||||
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==3.0.2
|
||||
bleak==2.1.1
|
||||
smpclient==7.2.0
|
||||
requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.11.2 # native esp-idf toolchain global cache dir
|
||||
platformdirs==4.11.1 # 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.7
|
||||
pylint==4.0.6
|
||||
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.13 # also change in .github/workflows/ci.yml when updating
|
||||
prek==0.4.12 # also change in .github/workflows/ci.yml when updating
|
||||
|
||||
# Unit tests
|
||||
pytest==9.1.1
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/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,7 +0,0 @@
|
||||
esphome:
|
||||
name: bk-family-gate-n
|
||||
|
||||
bk72xx:
|
||||
board: cb2s
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -1,7 +0,0 @@
|
||||
esphome:
|
||||
name: bk-family-gate-q
|
||||
|
||||
bk72xx:
|
||||
board: wa2
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -1,7 +0,0 @@
|
||||
esphome:
|
||||
name: bk-family-gate-t
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7231t-qfn32-tuya
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -1,7 +0,0 @@
|
||||
esphome:
|
||||
name: bk-family-gate-7252
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7252
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -1,40 +0,0 @@
|
||||
"""The non-5.x family rejection lives in to_code (config validation must stay
|
||||
family-agnostic for the validate-only CI fixtures), so codegen is the only
|
||||
place it can be pinned."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "match"),
|
||||
[
|
||||
("test_bk7231t.yaml", "BK7231T.*BLE 4.2"),
|
||||
("test_bk7252.yaml", "BK7251.*BLE 4.2"),
|
||||
("test_bk7231q.yaml", "BK7231Q.*no BLE"),
|
||||
],
|
||||
)
|
||||
def test_unsupported_family_rejected(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
match: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with pytest.raises(EsphomeError, match=match):
|
||||
generate_main(component_config_path(config_file))
|
||||
# Validation itself must not fail (CI validate fixtures run on a BLE 4.2
|
||||
# board), but it warns before codegen raises.
|
||||
assert "cannot compile" in caplog.text
|
||||
|
||||
|
||||
def test_ble5_family_generates(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("test_bk7231n.yaml"))
|
||||
assert "bk72xx_ble::BK72xxBLE" in main_cpp
|
||||
@@ -2,6 +2,6 @@ esphome:
|
||||
name: slotcount-controller
|
||||
|
||||
bk72xx:
|
||||
board: cb2s
|
||||
board: generic-bk7252
|
||||
|
||||
bk72xx_ble:
|
||||
|
||||
@@ -2,6 +2,6 @@ esphome:
|
||||
name: slotcount-tracker
|
||||
|
||||
bk72xx:
|
||||
board: cb2s
|
||||
board: generic-bk7252
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
|
||||
@@ -57,12 +57,7 @@ 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).
|
||||
|
||||
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.
|
||||
"""
|
||||
"""esp32 pins the ESP-IDF reference rate and exposes active (default on)."""
|
||||
config = ESP32_SCHEMA({})
|
||||
assert to_ble_units(config["interval"]) == 512
|
||||
assert to_ble_units(config["window"]) == 48
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"""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"}})
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Tests for the esp32_hosted ESP-IDF version gate."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_IDF_VERSION
|
||||
from esphome.components.esp32_hosted import _final_validate
|
||||
from esphome.const import PlatformFramework
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"])
|
||||
def test_final_validate_accepts_supported_idf(
|
||||
set_core_config: SetCoreConfigCallable, idf: str
|
||||
) -> None:
|
||||
"""ESP-IDF 5.3 and newer passes validation unchanged."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||
)
|
||||
assert _final_validate({}) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"])
|
||||
def test_final_validate_rejects_old_idf(
|
||||
set_core_config: SetCoreConfigCallable, idf: str
|
||||
) -> None:
|
||||
"""ESP-IDF older than 5.3 is rejected with a clear error."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"):
|
||||
_final_validate({})
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case
|
||||
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aioesphomeapi import DeviceInfo, EntityInfo
|
||||
@@ -25,15 +25,16 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool:
|
||||
return device_info.name.endswith(f"-{mac_suffix}")
|
||||
|
||||
|
||||
def _get_name_for_object_id(
|
||||
def _resolve_entity_name(
|
||||
entity: EntityInfo,
|
||||
device_info: DeviceInfo,
|
||||
device_id_to_name: dict[int, str],
|
||||
) -> str:
|
||||
"""Get the name used for object_id computation.
|
||||
"""Resolve the effective name for an entity.
|
||||
|
||||
This is the algorithm that aioesphomeapi will use to determine which
|
||||
name to use for computing object_id client-side from API data.
|
||||
name to use for computing object_id client-side from API data; the same
|
||||
name is what the device hashes into the entity key.
|
||||
|
||||
Args:
|
||||
entity: The entity to get name for
|
||||
@@ -72,27 +73,27 @@ def compute_entity_object_id(
|
||||
Returns:
|
||||
The computed object_id string
|
||||
"""
|
||||
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
|
||||
return compute_object_id(name_for_id)
|
||||
name = _resolve_entity_name(entity, device_info, device_id_to_name)
|
||||
return compute_object_id(name)
|
||||
|
||||
|
||||
def compute_entity_hash(
|
||||
def compute_entity_key(
|
||||
entity: EntityInfo,
|
||||
device_info: DeviceInfo,
|
||||
device_id_to_name: dict[int, str],
|
||||
) -> int:
|
||||
"""Compute expected object_id hash for an entity.
|
||||
"""Compute expected entity key for an entity.
|
||||
|
||||
Args:
|
||||
entity: The entity to compute hash for
|
||||
entity: The entity to compute the key 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
|
||||
The computed FNV-1 hash of the raw name
|
||||
"""
|
||||
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
|
||||
return fnv1_hash_object_id(name_for_id)
|
||||
name = _resolve_entity_name(entity, device_info, device_id_to_name)
|
||||
return fnv1_hash_name(name)
|
||||
|
||||
|
||||
def verify_entity_object_id(
|
||||
@@ -118,7 +119,7 @@ def verify_entity_object_id(
|
||||
f"expected '{expected_object_id}', got '{entity.object_id}'"
|
||||
)
|
||||
|
||||
expected_hash = compute_entity_hash(entity, device_info, device_id_to_name)
|
||||
expected_hash = compute_entity_key(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}"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
esphome:
|
||||
name: camera-mock-test
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
mock_camera:
|
||||
name: Mock Camera
|
||||
# Larger than MAX_BATCH_PACKET_SIZE (1390) so the image is split across
|
||||
# multiple CameraImageResponse chunks and the client must reassemble.
|
||||
# Must match IMAGE_SIZE in test_camera_mock.py.
|
||||
image_size: 4096
|
||||
@@ -1,28 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.core.entity_helpers import setup_entity
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/tests"]
|
||||
AUTO_LOAD = ["camera"]
|
||||
|
||||
CONF_IMAGE_SIZE = "image_size"
|
||||
|
||||
mock_camera_ns = cg.esphome_ns.namespace("mock_camera")
|
||||
MockCamera = mock_camera_ns.class_("MockCamera", cg.Component, cg.EntityBase)
|
||||
|
||||
CONFIG_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MockCamera),
|
||||
cv.Optional(CONF_IMAGE_SIZE, default=1024): cv.positive_not_null_int,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_CAMERA")
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await setup_entity(var, config, "camera")
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_image_size(config[CONF_IMAGE_SIZE]))
|
||||
@@ -1,30 +0,0 @@
|
||||
#include "mock_camera.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::mock_camera {
|
||||
|
||||
static const char *const TAG = "mock_camera";
|
||||
|
||||
void MockCamera::loop() {
|
||||
uint8_t requesters = this->single_requesters_ | this->stream_requesters_;
|
||||
if (requesters == 0)
|
||||
return;
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_frame_ms_ < FRAME_INTERVAL_MS)
|
||||
return;
|
||||
this->last_frame_ms_ = now;
|
||||
this->single_requesters_ = 0;
|
||||
|
||||
auto image = std::make_shared<MockCameraImage>(this->image_size_, this->frame_counter_, requesters);
|
||||
ESP_LOGV(TAG, "Producing frame %u (%u bytes, requesters 0x%02X)", this->frame_counter_, this->image_size_,
|
||||
requesters);
|
||||
this->frame_counter_++;
|
||||
for (auto *listener : this->listeners_) {
|
||||
listener->on_camera_image(image);
|
||||
}
|
||||
}
|
||||
|
||||
void MockCamera::dump_config() { ESP_LOGCONFIG(TAG, "Mock Camera (%u byte frames)", this->image_size_); }
|
||||
|
||||
} // namespace esphome::mock_camera
|
||||
@@ -1,80 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/camera/camera.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::mock_camera {
|
||||
|
||||
/** Deterministic in-memory camera image.
|
||||
* Byte i of frame N is (N + i) & 0xFF so tests can validate
|
||||
* reassembled data from just the first byte.
|
||||
*/
|
||||
class MockCameraImage : public camera::CameraImage {
|
||||
public:
|
||||
MockCameraImage(size_t size, uint8_t frame_counter, uint8_t requesters)
|
||||
: data_(new uint8_t[size]), size_(size), requesters_(requesters) {
|
||||
for (size_t i = 0; i < size; i++) {
|
||||
this->data_[i] = static_cast<uint8_t>(frame_counter + i);
|
||||
}
|
||||
}
|
||||
uint8_t *get_data_buffer() override { return this->data_.get(); }
|
||||
size_t get_data_length() override { return this->size_; }
|
||||
bool was_requested_by(camera::CameraRequester requester) const override {
|
||||
return (this->requesters_ & (1 << requester)) != 0;
|
||||
}
|
||||
|
||||
protected:
|
||||
std::unique_ptr<uint8_t[]> data_;
|
||||
size_t size_;
|
||||
uint8_t requesters_;
|
||||
};
|
||||
|
||||
class MockCameraImageReader : public camera::CameraImageReader {
|
||||
public:
|
||||
void set_image(std::shared_ptr<camera::CameraImage> image) override {
|
||||
this->image_ = std::move(image);
|
||||
this->offset_ = 0;
|
||||
}
|
||||
size_t available() const override { return this->image_ ? this->image_->get_data_length() - this->offset_ : 0; }
|
||||
uint8_t *peek_data_buffer() override { return this->image_->get_data_buffer() + this->offset_; }
|
||||
void consume_data(size_t consumed) override { this->offset_ += consumed; }
|
||||
void return_image() override {
|
||||
this->image_.reset();
|
||||
this->offset_ = 0;
|
||||
}
|
||||
|
||||
protected:
|
||||
std::shared_ptr<camera::CameraImage> image_;
|
||||
size_t offset_{0};
|
||||
};
|
||||
|
||||
/** Virtual camera producing deterministic frames on request or stream. */
|
||||
class MockCamera : public camera::Camera {
|
||||
public:
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); }
|
||||
camera::CameraImageReader *create_image_reader() override { return new MockCameraImageReader(); }
|
||||
void request_image(camera::CameraRequester requester) override { this->single_requesters_ |= (1 << requester); }
|
||||
void start_stream(camera::CameraRequester requester) override { this->stream_requesters_ |= (1 << requester); }
|
||||
void stop_stream(camera::CameraRequester requester) override { this->stream_requesters_ &= ~(1 << requester); }
|
||||
|
||||
void set_image_size(uint32_t size) { this->image_size_ = size; }
|
||||
|
||||
protected:
|
||||
static constexpr uint32_t FRAME_INTERVAL_MS = 50;
|
||||
|
||||
// Members ordered largest to smallest to minimize padding
|
||||
std::vector<camera::CameraListener *> listeners_;
|
||||
uint32_t image_size_{1024};
|
||||
uint32_t last_frame_ms_{0};
|
||||
uint8_t frame_counter_{0};
|
||||
uint8_t single_requesters_{0};
|
||||
uint8_t stream_requesters_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::mock_camera
|
||||
@@ -71,6 +71,38 @@ 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,10 +156,17 @@ 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 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());
|
||||
// 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());
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
esphome:
|
||||
name: host-pref-key-stability
|
||||
name: host-pref-key-migration
|
||||
|
||||
host:
|
||||
api:
|
||||
@@ -33,11 +33,6 @@ sensor:
|
||||
id: source_sensor_5
|
||||
accuracy_decimals: 1
|
||||
|
||||
- platform: template
|
||||
name: "Source Sensor 6"
|
||||
id: source_sensor_6
|
||||
accuracy_decimals: 1
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor_1
|
||||
name: "Filter Min"
|
||||
@@ -86,13 +81,6 @@ sensor:
|
||||
filters:
|
||||
- delta: 50%
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor_6
|
||||
name: "Filter NaN"
|
||||
id: filter_nan
|
||||
filters:
|
||||
- delta: 0
|
||||
|
||||
script:
|
||||
- id: test_filter_min
|
||||
then:
|
||||
@@ -200,24 +188,6 @@ script:
|
||||
id: source_sensor_5
|
||||
state: 250.0 # Passes (delta=90 > 80)
|
||||
|
||||
- id: test_filter_nan
|
||||
then:
|
||||
- sensor.template.publish:
|
||||
id: source_sensor_6
|
||||
state: 1.0
|
||||
- delay: 20ms
|
||||
- sensor.template.publish:
|
||||
id: source_sensor_6
|
||||
state: !lambda "return NAN;"
|
||||
- delay: 20ms
|
||||
- sensor.template.publish:
|
||||
id: source_sensor_6
|
||||
state: !lambda "return NAN;" # Filtered out
|
||||
- delay: 20ms
|
||||
- sensor.template.publish:
|
||||
id: source_sensor_6
|
||||
state: 2.0
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Test Filter Min"
|
||||
@@ -248,9 +218,3 @@ button:
|
||||
id: btn_filter_percentage
|
||||
on_press:
|
||||
- script.execute: test_filter_percentage
|
||||
|
||||
- platform: template
|
||||
name: "Test Filter NaN"
|
||||
id: btn_filter_nan
|
||||
on_press:
|
||||
- script.execute: test_filter_nan
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Integration test for the camera API flow using a mock camera platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import CameraInfo, CameraState, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# Must match image_size in fixtures/camera_mock.yaml
|
||||
IMAGE_SIZE = 4096
|
||||
STREAM_FRAMES = 3
|
||||
|
||||
|
||||
def _verify_frame(data: bytes) -> int:
|
||||
"""Verify the deterministic frame pattern and return the frame counter."""
|
||||
assert len(data) == IMAGE_SIZE, f"expected {IMAGE_SIZE} bytes, got {len(data)}"
|
||||
counter = data[0]
|
||||
assert data == bytes((counter + i) & 0xFF for i in range(IMAGE_SIZE)), (
|
||||
"frame pattern mismatch"
|
||||
)
|
||||
return counter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_camera_mock(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Single-image and stream requests deliver reassembled deterministic frames."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, _ = await client.list_entities_services()
|
||||
camera = require_entity(entities, "mock_camera", CameraInfo)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
images: list[bytes] = []
|
||||
single_image: asyncio.Future[None] = loop.create_future()
|
||||
stream_done: asyncio.Future[None] = loop.create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if not (isinstance(state, CameraState) and state.key == camera.key):
|
||||
return
|
||||
images.append(bytes(state.data))
|
||||
if not single_image.done():
|
||||
single_image.set_result(None)
|
||||
elif len(images) >= STREAM_FRAMES and not stream_done.done():
|
||||
stream_done.set_result(None)
|
||||
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
# Single image request: one complete frame arrives, reassembled
|
||||
# from multiple chunks (4096 > 1390 byte packets)
|
||||
client.request_single_image()
|
||||
await asyncio.wait_for(single_image, timeout=10)
|
||||
first_counter = _verify_frame(images[0])
|
||||
|
||||
# Stream request: multiple consecutive frames arrive
|
||||
images.clear()
|
||||
client.request_image_stream()
|
||||
await asyncio.wait_for(stream_done, timeout=10)
|
||||
|
||||
# Frames are distinct, ordered, and fresh per the mock's counter.
|
||||
# Not exactly consecutive: the API drops frames by design while the
|
||||
# previous image is still being sent, so allow small gaps.
|
||||
counters = [_verify_frame(img) for img in images[:STREAM_FRAMES]]
|
||||
for prev, cur in zip(counters, counters[1:], strict=False):
|
||||
assert cur != prev, f"duplicate frames: {counters}"
|
||||
assert ((cur - prev) & 0xFF) < 16, f"frames out of order: {counters}"
|
||||
assert counters[0] != first_counter, "stream should produce new frames"
|
||||
@@ -37,6 +37,10 @@ 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++ hash generation (fnv1_hash_object_id in helpers.h)
|
||||
3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id)
|
||||
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)
|
||||
|
||||
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_object_id
|
||||
from esphome.helpers import fnv1_hash_name
|
||||
|
||||
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_object_id(entity_name)
|
||||
hash_from_name = fnv1_hash_name(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_object_id(expected_name)
|
||||
expected_hash = fnv1_hash_name(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_object_id
|
||||
from esphome.helpers import fnv1_hash_name
|
||||
|
||||
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_object_id("My Friendly Device")
|
||||
expected_hash = fnv1_hash_name("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_object_id
|
||||
from esphome.helpers import fnv1_hash_name
|
||||
|
||||
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_object_id() with fallback to CORE.name
|
||||
- Python used get_base_entity_name() 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_object_id("test-device")
|
||||
expected_hash = fnv1_hash_name("test-device")
|
||||
assert entity.key == expected_hash, (
|
||||
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
|
||||
)
|
||||
|
||||
+43
-46
@@ -1,14 +1,14 @@
|
||||
"""Integration test for entity preference key stability.
|
||||
"""Integration test for entity preference key migration.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,23 +33,22 @@ 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-stability"
|
||||
DEVICE_NAME = "host-pref-key-migration"
|
||||
|
||||
# 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")
|
||||
# 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")
|
||||
|
||||
# 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_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
|
||||
TEXT_BETA_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
|
||||
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
|
||||
|
||||
# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes
|
||||
TEXT_MAX_LENGTH = 20
|
||||
@@ -63,18 +62,18 @@ def text_pref_payload(value: str) -> bytes:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preference_key_stability(
|
||||
async def test_preference_key_migration(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> None:
|
||||
"""Test that preferences stored by earlier firmware are restored."""
|
||||
"""Test that preferences stored under the old key survive the upgrade."""
|
||||
port, port_socket = reserved_tcp_port
|
||||
|
||||
assert SWITCH_KEY != SWITCH_BETA_KEY
|
||||
assert NUMBER_KEY != NUMBER_BETA_KEY
|
||||
assert TEXT_KEY != TEXT_BETA_KEY
|
||||
assert SWITCH_OLD_KEY != SWITCH_NEW_KEY
|
||||
assert NUMBER_OLD_KEY != NUMBER_NEW_KEY
|
||||
assert TEXT_OLD_KEY != TEXT_NEW_KEY
|
||||
|
||||
# Write and compile once
|
||||
config_path = await write_yaml_config(yaml_config)
|
||||
@@ -118,51 +117,49 @@ async def test_preference_key_stability(
|
||||
return switch_state, number_state, text_state
|
||||
|
||||
try:
|
||||
# --- 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.
|
||||
# --- Run 1: only OLD keys present, as written by pre-migration firmware.
|
||||
# The restored states prove the data was migrated to the new keys.
|
||||
write_host_prefs(
|
||||
DEVICE_NAME,
|
||||
{
|
||||
SWITCH_KEY: b"\x01", # bool: switch was ON
|
||||
NUMBER_KEY: struct.pack("<f", 42.5),
|
||||
TEXT_KEY: text_pref_payload("hello"),
|
||||
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_state, number_state, text_state = await boot_and_get_initial_states()
|
||||
assert switch_state.state is True, (
|
||||
"Switch state stored under the object_id preference key was lost"
|
||||
"Switch state stored under the old preference key was lost"
|
||||
)
|
||||
assert number_state.state == 42.5, (
|
||||
"Number value stored under the object_id preference key was lost"
|
||||
"Number value stored under the old preference key was lost"
|
||||
)
|
||||
assert text_state.state == "hello", (
|
||||
"Text value stored under the object_id preference key was lost"
|
||||
"Text value stored under the old preference key was lost"
|
||||
)
|
||||
|
||||
# --- 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.
|
||||
# --- 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.
|
||||
write_host_prefs(
|
||||
DEVICE_NAME,
|
||||
{
|
||||
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_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_state, number_state, text_state = await boot_and_get_initial_states()
|
||||
assert switch_state.state is True, (
|
||||
"Beta raw-name-key data overrode the object_id switch state"
|
||||
"Stale old-key data overwrote the current new-key switch state"
|
||||
)
|
||||
assert number_state.state == 13.5, (
|
||||
"Beta raw-name-key data overrode the object_id number value"
|
||||
"Stale old-key data overwrote the current new-key number value"
|
||||
)
|
||||
assert text_state.state == "world", (
|
||||
"Beta raw-name-key data overrode the object_id text value"
|
||||
"Stale old-key data overwrote the current new-key text value"
|
||||
)
|
||||
finally:
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
from aioesphomeapi import ButtonInfo, EntityState, SensorState
|
||||
import pytest
|
||||
@@ -26,7 +25,6 @@ async def test_sensor_filters_delta(
|
||||
"filter_baseline_max": [],
|
||||
"filter_zero_delta": [],
|
||||
"filter_percentage": [],
|
||||
"filter_nan": [],
|
||||
}
|
||||
|
||||
filter_min_done = loop.create_future()
|
||||
@@ -34,23 +32,16 @@ async def test_sensor_filters_delta(
|
||||
filter_baseline_max_done = loop.create_future()
|
||||
filter_zero_delta_done = loop.create_future()
|
||||
filter_percentage_done = loop.create_future()
|
||||
filter_nan_done = loop.create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if not isinstance(state, SensorState):
|
||||
if not isinstance(state, SensorState) or state.missing_state:
|
||||
return
|
||||
|
||||
sensor_name = key_to_sensor.get(state.key)
|
||||
if sensor_name not in sensor_values:
|
||||
return
|
||||
|
||||
if state.missing_state:
|
||||
# Only the NaN test is interested in unavailable states
|
||||
if sensor_name != "filter_nan":
|
||||
return
|
||||
sensor_values[sensor_name].append(math.nan)
|
||||
else:
|
||||
sensor_values[sensor_name].append(state.state)
|
||||
sensor_values[sensor_name].append(state.state)
|
||||
|
||||
# Check completion conditions
|
||||
if (
|
||||
@@ -83,12 +74,6 @@ async def test_sensor_filters_delta(
|
||||
and not filter_percentage_done.done()
|
||||
):
|
||||
filter_percentage_done.set_result(True)
|
||||
elif (
|
||||
sensor_name == "filter_nan"
|
||||
and len(sensor_values[sensor_name]) == 3
|
||||
and not filter_nan_done.done()
|
||||
):
|
||||
filter_nan_done.set_result(True)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
@@ -104,7 +89,6 @@ async def test_sensor_filters_delta(
|
||||
"filter_baseline_max": "Filter Baseline Max",
|
||||
"filter_zero_delta": "Filter Zero Delta",
|
||||
"filter_percentage": "Filter Percentage",
|
||||
"filter_nan": "Filter NaN",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -124,14 +108,13 @@ async def test_sensor_filters_delta(
|
||||
"Test Filter Baseline Max": "filter_baseline_max",
|
||||
"Test Filter Zero Delta": "filter_zero_delta",
|
||||
"Test Filter Percentage": "filter_percentage",
|
||||
"Test Filter NaN": "filter_nan",
|
||||
}
|
||||
buttons = {}
|
||||
for entity in entities:
|
||||
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
|
||||
buttons[button_name_map[entity.name]] = entity.key
|
||||
|
||||
assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}"
|
||||
assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}"
|
||||
|
||||
# Test 1: Min
|
||||
sensor_values["filter_min"].clear()
|
||||
@@ -203,18 +186,3 @@ async def test_sensor_filters_delta(
|
||||
assert sensor_values["filter_percentage"] == pytest.approx(expected), (
|
||||
f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}"
|
||||
)
|
||||
|
||||
# Test 6: NaN passes through once, then is suppressed
|
||||
sensor_values["filter_nan"].clear()
|
||||
client.button_command(buttons["filter_nan"])
|
||||
try:
|
||||
await asyncio.wait_for(filter_nan_done, timeout=2.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}")
|
||||
|
||||
values = sensor_values["filter_nan"]
|
||||
assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}"
|
||||
assert math.isnan(values[1]), (
|
||||
f"Test 6 failed: NaN not passed through, got {values}"
|
||||
)
|
||||
assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}"
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""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,4 +1,4 @@
|
||||
"""Test get_base_entity_object_id function matches C++ behavior."""
|
||||
"""Tests for entity helpers: name selection, entity key hashing, duplicate checks."""
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
from pathlib import Path
|
||||
@@ -25,16 +25,17 @@ from esphome.core.entity_helpers import (
|
||||
_setup_entity_impl,
|
||||
entity_duplicate_validator,
|
||||
finalize_entity_strings,
|
||||
get_base_entity_object_id,
|
||||
get_base_entity_name,
|
||||
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, sanitize, snake_case
|
||||
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
|
||||
|
||||
from .common import load_config_from_fixture
|
||||
|
||||
@@ -57,206 +58,26 @@ def restore_core_state() -> Generator[None, None, None]:
|
||||
CORE.friendly_name = original_friendly_name
|
||||
|
||||
|
||||
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:
|
||||
def test_get_base_entity_name_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
|
||||
# 1. Entity name has highest priority and is used as-is, no transformations
|
||||
assert (
|
||||
get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name")
|
||||
== "entity_name"
|
||||
get_base_entity_name("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_object_id("", "Friendly Name", "Device Name") == "device_name"
|
||||
)
|
||||
assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name"
|
||||
|
||||
# 3. Friendly name is next (when entity and device names are empty)
|
||||
assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name"
|
||||
assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name"
|
||||
|
||||
# 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"
|
||||
# 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"
|
||||
|
||||
|
||||
# Tests for setup_entity function
|
||||
@@ -515,9 +336,10 @@ def test_entity_duplicate_validator() -> None:
|
||||
config1 = {CONF_NAME: "Temperature"}
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
|
||||
assert temperature_key in CORE.unique_ids
|
||||
# Check metadata was stored
|
||||
metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))]
|
||||
metadata = CORE.unique_ids[temperature_key]
|
||||
assert metadata["name"] == "Temperature"
|
||||
assert metadata["platform"] == "sensor"
|
||||
|
||||
@@ -525,8 +347,9 @@ def test_entity_duplicate_validator() -> None:
|
||||
config2 = {CONF_NAME: "Humidity"}
|
||||
validated2 = validator(config2)
|
||||
assert validated2 == config2
|
||||
assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))]
|
||||
humidity_key = ("", "sensor", fnv1_hash_name("Humidity"))
|
||||
assert humidity_key in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[humidity_key]
|
||||
assert metadata2["name"] == "Humidity"
|
||||
|
||||
# Duplicate entity should fail
|
||||
@@ -537,34 +360,6 @@ 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
|
||||
@@ -575,18 +370,19 @@ 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", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))]
|
||||
assert ("device1", "sensor", name_hash) in CORE.unique_ids
|
||||
metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)]
|
||||
assert metadata1["device_id"] == "device1"
|
||||
|
||||
config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2}
|
||||
validated2 = validator(config2)
|
||||
assert validated2 == config2
|
||||
assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))]
|
||||
assert ("device2", "sensor", name_hash) in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)]
|
||||
assert metadata2["device_id"] == "device2"
|
||||
|
||||
# Duplicate on same device should fail
|
||||
@@ -638,6 +434,33 @@ 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
|
||||
@@ -696,7 +519,8 @@ def test_entity_duplicate_validator_internal_entities() -> None:
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
# New format includes device_id (empty string for main device)
|
||||
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
|
||||
assert temperature_key in CORE.unique_ids
|
||||
|
||||
# Internal entity with same name should pass (not added to unique_ids)
|
||||
config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
|
||||
@@ -704,9 +528,7 @@ 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 == ("", "sensor", fnv1_hash("temperature"))
|
||||
)
|
||||
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
|
||||
assert count == 1
|
||||
|
||||
# Another internal entity with same name should also pass
|
||||
@@ -714,9 +536,7 @@ 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 == ("", "sensor", fnv1_hash("temperature"))
|
||||
)
|
||||
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
|
||||
assert count == 1
|
||||
|
||||
# Non-internal entity with same name should fail
|
||||
@@ -744,30 +564,148 @@ def test_empty_or_null_device_id_on_entity() -> None:
|
||||
|
||||
|
||||
def test_entity_duplicate_validator_non_ascii_names() -> None:
|
||||
"""Test that non-ASCII names show helpful error messages."""
|
||||
"""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.
|
||||
"""
|
||||
# Create validator for binary_sensor platform
|
||||
validator = entity_duplicate_validator("binary_sensor")
|
||||
|
||||
# First Russian sensor should pass
|
||||
# Both Russian sensors should pass even though they sanitize identically
|
||||
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(
|
||||
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'\)",
|
||||
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",
|
||||
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
|
||||
@@ -825,7 +763,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_object_id_hash") == 0
|
||||
assert config.get("_entity_key") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -854,7 +792,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_object_id_hash") == 0
|
||||
assert config.get("_entity_key") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -884,7 +822,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_object_id_hash") == 0
|
||||
assert config.get("_entity_key") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -915,7 +853,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_object_id_hash") == 0
|
||||
assert config.get("_entity_key") == 0
|
||||
|
||||
|
||||
def test_register_string_overflow() -> None:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
@@ -1,15 +1,10 @@
|
||||
"""Shared storage-sidecar factory for the lazy-import fixture scripts."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.storage_json import StorageJSON
|
||||
|
||||
|
||||
def make_storage() -> StorageJSON:
|
||||
"""A minimal post-compile esp32 sidecar the upload/logs fast path accepts.
|
||||
|
||||
build_path must be set: the fast path rejects sidecars without one.
|
||||
"""
|
||||
"""A minimal post-compile esp32 sidecar the upload/logs fast path accepts."""
|
||||
return StorageJSON(
|
||||
storage_version=1,
|
||||
name="test",
|
||||
@@ -20,8 +15,8 @@ def make_storage() -> StorageJSON:
|
||||
address="1.2.3.4",
|
||||
web_port=None,
|
||||
target_platform="ESP32S3",
|
||||
build_path=Path("/build/test"),
|
||||
firmware_bin_path=Path("/build/test/firmware.bin"),
|
||||
build_path=None,
|
||||
firmware_bin_path=None,
|
||||
loaded_integrations=set(),
|
||||
loaded_platforms=set(),
|
||||
no_mdns=False,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
import json
|
||||
import os
|
||||
@@ -20,7 +19,6 @@ from esphome.compiled_config import (
|
||||
compiled_config_path,
|
||||
load_compiled_config,
|
||||
save_compiled_config,
|
||||
save_compiled_config_and_sidecar,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_API,
|
||||
@@ -33,16 +31,7 @@ from esphome.const import (
|
||||
KEY_VARIANT,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
ID,
|
||||
EsphomeError,
|
||||
HexInt,
|
||||
Lambda,
|
||||
MACAddress,
|
||||
TimePeriodMilliseconds,
|
||||
)
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds
|
||||
from esphome.util import OrderedDict
|
||||
|
||||
_VALIDATED_CONFIG = {
|
||||
@@ -65,9 +54,8 @@ def _cache_body(config: dict | None = None) -> str:
|
||||
def _write_storage(
|
||||
storage_path: Path,
|
||||
*,
|
||||
esp_platform: str | None = "ESP32",
|
||||
esp_platform: str = "ESP32",
|
||||
core_platform: str | None = "esp32",
|
||||
build_path: str | None = "/build/lite_test",
|
||||
) -> None:
|
||||
"""Write a vanilla StorageJSON sidecar for the cache tests."""
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -81,7 +69,7 @@ def _write_storage(
|
||||
"address": "192.168.1.42",
|
||||
"web_port": None,
|
||||
"esp_platform": esp_platform,
|
||||
"build_path": build_path,
|
||||
"build_path": "/build/lite_test",
|
||||
"firmware_bin_path": "/build/lite_test/firmware.bin",
|
||||
"loaded_integrations": ["api", "logger", "ota", "wifi"],
|
||||
"loaded_platforms": [],
|
||||
@@ -371,262 +359,31 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
def _storage_fixture(tmp_path: Path) -> StorageJSON:
|
||||
"""A loaded StorageJSON instance matching _write_storage's contents."""
|
||||
fixture = tmp_path / "fixture_storage.json"
|
||||
_write_storage(fixture)
|
||||
return StorageJSON.load(fixture)
|
||||
|
||||
|
||||
def _bare_yaml(tmp_path: Path) -> Path:
|
||||
"""A minimal YAML with CORE.config_path pointed at it."""
|
||||
def test_run_esphome_upload_does_not_refresh_cache_without_sidecar(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Without a StorageJSON sidecar (no compile has run), the fallback
|
||||
skips the cache write -- load_compiled_config requires the sidecar,
|
||||
so writing the rendered (secret-resolved) config would be inert and
|
||||
leak secrets to disk for nothing."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
return yaml_path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any:
|
||||
"""Patch the fallback path's collaborators for a run_esphome call.
|
||||
|
||||
Without kwargs, from_esphome_core stays real (yielded mock is None).
|
||||
"""
|
||||
with (
|
||||
patch(
|
||||
"esphome.config.read_config",
|
||||
return_value={"esphome": {"name": "lite_test"}},
|
||||
) as mock_read,
|
||||
),
|
||||
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{command: lambda args, config: 0},
|
||||
{"upload": lambda args, config: 0},
|
||||
),
|
||||
):
|
||||
if not from_core_kwargs:
|
||||
yield mock_read, None
|
||||
return
|
||||
with patch.object(
|
||||
StorageJSON, "from_esphome_core", **from_core_kwargs
|
||||
) as mock_from_core:
|
||||
yield mock_read, mock_from_core
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["upload", "logs"])
|
||||
def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar(
|
||||
tmp_path: Path, command: str
|
||||
) -> None:
|
||||
"""A never-compiled config caches on its first upload/logs run: the
|
||||
fallback writes the StorageJSON sidecar itself (load_compiled_config
|
||||
needs it), so the second run hits the fast path."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
|
||||
with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as (
|
||||
mock_read,
|
||||
mock_from_core,
|
||||
):
|
||||
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
|
||||
mock_from_core.assert_called_once()
|
||||
assert (storage_dir / "lite_test.yaml.validated.json").exists()
|
||||
storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
|
||||
assert storage is not None
|
||||
# No compile happened, so the sidecar must not claim one.
|
||||
assert mock_from_core.call_args.kwargs == {"claim_build": False}
|
||||
|
||||
# The second run loads the cache instead of re-validating.
|
||||
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
# as_dict serialized unset paths as str(None) until 2026.9; files
|
||||
# written by those wizards are still on disk.
|
||||
_WIZARD_SIDECAR_CASES = pytest.mark.parametrize(
|
||||
"wizard_kwargs",
|
||||
[
|
||||
{"esp_platform": None, "core_platform": None, "build_path": None},
|
||||
{"build_path": None},
|
||||
{"build_path": "None"},
|
||||
],
|
||||
ids=["legacy_wizard", "modern_wizard", "none_string_wizard"],
|
||||
)
|
||||
|
||||
|
||||
def _prime_core(tmp_path: Path) -> None:
|
||||
"""Set the post-validation CORE state from_esphome_core reads."""
|
||||
CORE.name = "lite_test"
|
||||
CORE.build_path = tmp_path / "build" / "lite_test"
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp8266",
|
||||
KEY_TARGET_FRAMEWORK: "arduino",
|
||||
}
|
||||
|
||||
|
||||
@_WIZARD_SIDECAR_CASES
|
||||
def test_run_esphome_fallback_completes_wizard_sidecar(
|
||||
tmp_path: Path, wizard_kwargs: dict[str, Any]
|
||||
) -> None:
|
||||
"""A wizard-written sidecar can't drive the fast path (no build_path;
|
||||
older wizards also no platform fields); the fallback rewrites it from
|
||||
CORE so the cache loads on the next run."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
|
||||
|
||||
with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
mock_from_core.assert_called_once()
|
||||
storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
|
||||
assert storage is not None and storage.core_platform == "esp32"
|
||||
# What the wizard recorded about a build (nothing, or a real one)
|
||||
# carries through instead of being stamped with this run's values.
|
||||
assert storage.esphome_version == "2026.1.0"
|
||||
assert load_compiled_config(yaml_path) is not None
|
||||
|
||||
|
||||
def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A failed sidecar write is non-fatal and skips the cache save too:
|
||||
without the sidecar the cache could never be loaded back, so writing
|
||||
it would only leave resolved secrets on disk."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
|
||||
with (
|
||||
_fallback_run(side_effect=RuntimeError("boom")),
|
||||
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
||||
):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
run_esphome(["esphome", "upload", str(yaml_path)])
|
||||
|
||||
mock_save.assert_not_called()
|
||||
assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists()
|
||||
|
||||
|
||||
def test_run_esphome_fallback_write_failure_takes_io_branch(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""StorageJSON.save raises EsphomeError (write_file wraps OSError into
|
||||
it), which must land in the plain I/O warning, not the traceback
|
||||
branch for structural bugs."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
|
||||
with (
|
||||
_fallback_run(return_value=_storage_fixture(tmp_path)),
|
||||
patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")),
|
||||
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
||||
caplog.at_level("WARNING", logger="esphome.compiled_config"),
|
||||
):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
mock_save.assert_not_called()
|
||||
assert "Could not refresh the storage sidecar" in caplog.text
|
||||
assert "Unexpected error" not in caplog.text
|
||||
|
||||
|
||||
def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None:
|
||||
"""A present-but-corrupt sidecar is not overwritten: it may hold a real
|
||||
build's metadata, and replacing it would suppress the next compile's
|
||||
clean of a possibly incoherent build tree. The cache save is skipped."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
sidecar = storage_dir / "lite_test.yaml.json"
|
||||
sidecar.parent.mkdir(parents=True, exist_ok=True)
|
||||
sidecar.write_text("{truncated", encoding="utf-8")
|
||||
|
||||
with _fallback_run(return_value=None) as (_, mock_from_core):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
mock_from_core.assert_not_called()
|
||||
assert sidecar.read_text(encoding="utf-8") == "{truncated"
|
||||
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
|
||||
|
||||
|
||||
def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""If the rebuilt sidecar would still be incomplete, nothing is written:
|
||||
the cache could never be loaded back, so saving it would only rewrite
|
||||
resolved secrets on every run."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
|
||||
incomplete = tmp_path / "incomplete_storage.json"
|
||||
_write_storage(incomplete, build_path=None)
|
||||
|
||||
with _fallback_run(return_value=StorageJSON.load(incomplete)):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
assert not (storage_dir / "lite_test.yaml.json").exists()
|
||||
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
|
||||
|
||||
|
||||
def test_run_esphome_fallback_sidecar_records_platformio_toolchain(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The toolchain fallback runs before the sidecar write, so platforms
|
||||
whose validators leave CORE.toolchain unset record the same
|
||||
"platformio" a compile writes, not null."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
_prime_core(tmp_path)
|
||||
assert CORE.toolchain is None
|
||||
|
||||
with _fallback_run():
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
storage = StorageJSON.load(
|
||||
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
|
||||
)
|
||||
assert storage is not None
|
||||
assert storage.toolchain == "platformio"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("existing_sidecar", [None, "wizard"])
|
||||
def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists(
|
||||
tmp_path: Path, existing_sidecar: str | None
|
||||
) -> None:
|
||||
"""An existing build tree with a missing or wizard-only sidecar keeps
|
||||
it that way: the mismatch is what makes the next compile wipe the
|
||||
unknown tree, so the fallback writes nothing and skips the cache."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
_prime_core(tmp_path)
|
||||
CORE.build_path.mkdir(parents=True)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
if existing_sidecar == "wizard":
|
||||
_write_storage(storage_dir / "lite_test.yaml.json", build_path=None)
|
||||
wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
|
||||
|
||||
with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
mock_from_core.assert_not_called()
|
||||
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
|
||||
if existing_sidecar == "wizard":
|
||||
sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
|
||||
assert sidecar_body == wizard_body
|
||||
else:
|
||||
assert not (storage_dir / "lite_test.yaml.json").exists()
|
||||
|
||||
|
||||
def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None:
|
||||
"""Drive the real from_esphome_core on the fallback path: the
|
||||
post-validation CORE state yields a complete, loadable sidecar."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
_prime_core(tmp_path)
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
save_compiled_config_and_sidecar(CORE.config)
|
||||
|
||||
storage = StorageJSON.load(
|
||||
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
|
||||
)
|
||||
assert storage is not None
|
||||
assert storage.core_platform == "esp8266"
|
||||
assert storage.build_path is not None
|
||||
# No compile happened, so the sidecar must not claim one.
|
||||
assert storage.esphome_version is None
|
||||
assert storage.firmware_bin_path is None
|
||||
assert load_compiled_config(yaml_path) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["upload", "logs"])
|
||||
@@ -652,7 +409,6 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
|
||||
patch(
|
||||
"esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
|
||||
) as mock_save,
|
||||
patch.object(StorageJSON, "from_esphome_core") as mock_from_core,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{command: lambda args, config: 0},
|
||||
@@ -661,8 +417,6 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
|
||||
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
|
||||
|
||||
mock_save.assert_called_once_with(fresh_config)
|
||||
# The compile-written sidecar is complete; the fallback leaves it alone.
|
||||
mock_from_core.assert_not_called()
|
||||
# mtime is now newer than the source YAML, so a follow-up call hits
|
||||
# the fast path instead of repeating read_config.
|
||||
assert cache.stat().st_mtime >= yaml_path.stat().st_mtime
|
||||
@@ -893,15 +647,24 @@ def test_int_keys_coerce_to_strings(primed_storage: Path) -> None:
|
||||
assert config["table"] == {"1": "a", "2": "b"}
|
||||
|
||||
|
||||
@_WIZARD_SIDECAR_CASES
|
||||
def test_load_compiled_config_rejects_wizard_only_sidecar(
|
||||
tmp_path: Path, wizard_kwargs: dict[str, Any]
|
||||
) -> None:
|
||||
"""A wizard-written sidecar (no build_path; older wizards also no
|
||||
platform fields) can't drive upload/logs, so the fast path falls back."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None:
|
||||
"""A wizard-only sidecar (no compile -- no core_platform / target_platform)
|
||||
can't drive upload/logs, so the fast path falls back."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
# StorageJSON with both core_platform and target_platform unset.
|
||||
(storage_dir / "lite_test.yaml.json").write_text(
|
||||
'{"storage_version": 1, "name": "lite_test", "friendly_name": null, '
|
||||
'"comment": null, "esphome_version": null, "src_version": 1, '
|
||||
'"address": null, "web_port": null, "esp_platform": null, '
|
||||
'"build_path": null, "firmware_bin_path": null, '
|
||||
'"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, '
|
||||
'"framework": null, "core_platform": null}'
|
||||
)
|
||||
cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
||||
_set_cache_mtime(cache_path, yaml_path, offset=5)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user