Merge remote-tracking branch 'origin/dev' into jesserockz-2026-584

# Conflicts:
#	esphome/components/api/__init__.py
#	platformio.ini
This commit is contained in:
Jesse Hills
2026-08-18 07:11:48 +12:00
154 changed files with 2953 additions and 1280 deletions
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-n
bk72xx:
board: cb2s
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-q
bk72xx:
board: wa2
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-t
bk72xx:
board: generic-bk7231t-qfn32-tuya
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-7252
bk72xx:
board: generic-bk7252
bk72xx_ble:
@@ -0,0 +1,40 @@
"""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: generic-bk7252
board: cb2s
bk72xx_ble:
@@ -2,6 +2,6 @@ esphome:
name: slotcount-tracker
bk72xx:
board: generic-bk7252
board: cb2s
bk72xx_ble_tracker:
@@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None:
def test_esp32_defaults_are_valid() -> None:
"""esp32 pins the ESP-IDF reference rate and exposes active (default on)."""
"""esp32 pins the ESP-IDF reference rate and exposes active (default on).
Without wifi loaded, the conditional window default falls back to the
historical 30 ms; the wifi-aware resolution is covered by the
esp32_ble_tracker component tests.
"""
config = ESP32_SCHEMA({})
assert to_ble_units(config["interval"]) == 512
assert to_ble_units(config["window"]) == 48
@@ -0,0 +1,122 @@
"""Tests for the esp32_ble_tracker conditional scan window default.
The scan window default depends on wifi coexistence and the IDF version:
IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the
configured window (espressif/esp-idf#18931), so on fixed versions the
historical 30 ms default would only listen 9.4 % of the time and miss most
advertisements. With the coexistence arbiter compiled in on a fixed IDF, the
window instead defaults to the interval, as Espressif recommends; without the
arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
"""
from __future__ import annotations
from collections.abc import Callable
import pytest
from esphome import config_validation as cv
from esphome.components.ble_device_base import to_ble_units
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import KEY_IDF_VERSION
from esphome.components.esp32_ble_tracker import (
CONF_SOFTWARE_COEXISTENCE,
CONFIG_SCHEMA,
)
from esphome.const import CONF_INTERVAL, PlatformFramework
from esphome.core import CORE
from esphome.types import ConfigType
from ..types import SetCoreConfigCallable
@pytest.fixture
def stage_esp32(
set_core_config: SetCoreConfigCallable,
) -> Callable[..., None]:
"""Stage an esp32 build with a given IDF version and wifi presence."""
def stage(idf: str, *, wifi: bool) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
if wifi:
# Makes cv.OnlyWith default software_coexistence to True, exactly
# as a real config with wifi: does.
CORE.loaded_integrations.add("wifi")
return stage
def _scan_params(config: ConfigType) -> ConfigType:
return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS]
@pytest.mark.parametrize(
("idf", "config", "expected_units"),
[
("5.5.5", {}, 512), # first fixed version, default 320 ms interval
("6.0.1", {}, 512), # any newer version behaves the same
# Follows a user-set interval.
("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600),
],
)
def test_wifi_on_fixed_idf_defaults_window_to_interval(
stage_esp32: Callable[..., None],
idf: str,
config: ConfigType,
expected_units: int,
) -> None:
"""With wifi coexistence on a fixed IDF, the window defaults to the interval."""
stage_esp32(idf, wifi=True)
params = _scan_params(config)
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
assert to_ble_units(params[CONF_WINDOW]) == expected_units
@pytest.mark.parametrize(
("idf", "wifi", "config"),
[
# Buggy IDF over-scans anyway; keep the 30 ms default.
("5.5.4", True, {}),
# No wifi (e.g. ethernet) means no radio contention.
("5.5.5", False, {}),
# Coexistence disabled: no arbiter, so a full-duty scan would starve
# wifi outright.
("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}),
],
)
def test_30ms_default_kept(
stage_esp32: Callable[..., None],
idf: str,
wifi: bool,
config: ConfigType,
) -> None:
stage_esp32(idf, wifi=wifi)
assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48
@pytest.mark.parametrize("window", ["60ms", "30ms"])
def test_explicit_window_is_never_touched(
stage_esp32: Callable[..., None], window: str
) -> None:
"""A user-set window wins over the conditional default.
The explicit 30 ms case matters: it is indistinguishable from the
defaulted value by inspection, so the defaulted flag must separate them.
"""
stage_esp32("5.5.5", wifi=True)
params = _scan_params({"scan_parameters": {"window": window}})
assert to_ble_units(params[CONF_WINDOW]) == to_ble_units(
cv.positive_time_period(window)
)
def test_short_interval_without_window_still_rejected(
stage_esp32: Callable[..., None],
) -> None:
"""The provisional 30 ms default validates against the interval as before."""
stage_esp32("5.5.5", wifi=True)
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
_scan_params({"scan_parameters": {"interval": "20ms"}})
@@ -0,0 +1,35 @@
"""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({})
+13 -14
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case
if TYPE_CHECKING:
from aioesphomeapi import DeviceInfo, EntityInfo
@@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool:
return device_info.name.endswith(f"-{mac_suffix}")
def _resolve_entity_name(
def _get_name_for_object_id(
entity: EntityInfo,
device_info: DeviceInfo,
device_id_to_name: dict[int, str],
) -> str:
"""Resolve the effective name for an entity.
"""Get the name used for object_id computation.
This is the algorithm that aioesphomeapi will use to determine which
name to use for computing object_id client-side from API data; the same
name is what the device hashes into the entity key.
name to use for computing object_id client-side from API data.
Args:
entity: The entity to get name for
@@ -73,27 +72,27 @@ def compute_entity_object_id(
Returns:
The computed object_id string
"""
name = _resolve_entity_name(entity, device_info, device_id_to_name)
return compute_object_id(name)
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
return compute_object_id(name_for_id)
def compute_entity_key(
def compute_entity_hash(
entity: EntityInfo,
device_info: DeviceInfo,
device_id_to_name: dict[int, str],
) -> int:
"""Compute expected entity key for an entity.
"""Compute expected object_id hash for an entity.
Args:
entity: The entity to compute the key for
entity: The entity to compute hash for
device_info: Device info from the API
device_id_to_name: Mapping of device_id to device name for sub-devices
Returns:
The computed FNV-1 hash of the raw name
The computed FNV-1 hash
"""
name = _resolve_entity_name(entity, device_info, device_id_to_name)
return fnv1_hash_name(name)
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
return fnv1_hash_object_id(name_for_id)
def verify_entity_object_id(
@@ -119,7 +118,7 @@ def verify_entity_object_id(
f"expected '{expected_object_id}', got '{entity.object_id}'"
)
expected_hash = compute_entity_key(entity, device_info, device_id_to_name)
expected_hash = compute_entity_hash(entity, device_info, device_id_to_name)
assert entity.key == expected_hash, (
f"hash mismatch for entity '{entity.name}': "
f"expected {expected_hash:#x}, got {entity.key:#x}"
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,28 @@
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]))
@@ -0,0 +1,30 @@
#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
@@ -0,0 +1,80 @@
#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,38 +71,6 @@ esphome:
ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty);
}
// Raw name hash: matches Python fnv1_hash_name("My Sensor Name")
uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14);
if (hash_raw == 0x8cec6fb0) {
ESP_LOGI("FNV1_OID", "raw PASSED");
} else {
ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw);
}
// Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température")
uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12);
if (hash_raw_utf8 == 0x531a74aa) {
ESP_LOGI("FNV1_OID", "raw_utf8 PASSED");
} else {
ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8);
}
// Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température")
uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true);
if (hash_old_utf8 == 0x965698f3) {
ESP_LOGI("FNV1_OID", "old_utf8 PASSED");
} else {
ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8);
}
// Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度")
uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true);
if (hash_old_cjk == 0x3276cb9f) {
ESP_LOGI("FNV1_OID", "old_cjk PASSED");
} else {
ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk);
}
host:
api:
logger:
@@ -156,17 +156,10 @@ button:
ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str());
ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str());
ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str());
// Log preference key bases for entities that actually store preferences.
// This is the key base make_entity_preference() uses: entity key XOR device id.
ESP_LOGI("test", "Device A Switch Pref Hash: %u",
id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero());
ESP_LOGI("test", "Device B Switch Pref Hash: %u",
id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero());
ESP_LOGI("test", "Main Switch Pref Hash: %u",
id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero());
ESP_LOGI("test", "Device A Number Pref Hash: %u",
id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero());
ESP_LOGI("test", "Device B Number Pref Hash: %u",
id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero());
ESP_LOGI("test", "Main Number Pref Hash: %u",
id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero());
// Log preference hashes for entities that actually store preferences
ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash());
ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash());
ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash());
ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash());
ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash());
ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash());
@@ -1,5 +1,5 @@
esphome:
name: host-pref-key-migration
name: host-pref-key-stability
host:
api:
@@ -33,6 +33,11 @@ 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"
@@ -81,6 +86,13 @@ 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:
@@ -188,6 +200,24 @@ 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"
@@ -218,3 +248,9 @@ 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
+73
View File
@@ -0,0 +1,73 @@
"""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,10 +37,6 @@ async def test_fnv1_hash_object_id(
"special",
"complex",
"empty",
"raw",
"raw_utf8",
"old_utf8",
"old_cjk",
}
def on_log_line(line: str) -> None:
@@ -2,8 +2,8 @@
This test verifies a three-way match between:
1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char)
2. C++ entity key generation (fnv1_hash of the raw name in helpers.h)
3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py)
2. C++ hash generation (fnv1_hash_object_id in helpers.h)
3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id)
The API response contains C++ computed values, so verifying API == Python
implicitly verifies C++ == Python == API for both object_id and hash.
@@ -25,7 +25,7 @@ from __future__ import annotations
import pytest
from esphome.helpers import fnv1_hash_name
from esphome.helpers import fnv1_hash_object_id
from .entity_utils import compute_object_id, verify_all_entities
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -123,7 +123,7 @@ async def test_object_id_api_verification(
)
# Verify hash can be computed from the name
hash_from_name = fnv1_hash_name(entity_name)
hash_from_name = fnv1_hash_object_id(entity_name)
assert hash_from_name == entity.key, (
f"Entity '{entity_name}': hash mismatch. "
f"Python hash {hash_from_name:#x}, API key {entity.key:#x}"
@@ -164,7 +164,7 @@ async def test_object_id_api_verification(
)
# Verify hash matches
expected_hash = fnv1_hash_name(expected_name)
expected_hash = fnv1_hash_object_id(expected_name)
assert entity.key == expected_hash, (
f"Empty-name entity (device_id={entity.device_id}): hash mismatch. "
f"API key: {entity.key:#x}, expected: {expected_hash:#x}"
@@ -11,7 +11,7 @@ from __future__ import annotations
import pytest
from esphome.helpers import fnv1_hash_name
from esphome.helpers import fnv1_hash_object_id
from .entity_utils import (
compute_object_id,
@@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix(
)
# Hash should match friendly_name
expected_hash = fnv1_hash_name("My Friendly Device")
expected_hash = fnv1_hash_object_id("My Friendly Device")
assert entity.key == expected_hash, (
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
)
@@ -17,7 +17,7 @@ from __future__ import annotations
import pytest
from esphome.helpers import fnv1_hash_name
from esphome.helpers import fnv1_hash_object_id
from .entity_utils import compute_object_id, verify_all_entities
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix(
OLD behavior:
- is_object_id_dynamic_() returned false (mac suffix not enabled)
- Used object_id_c_str_ which was pre-computed in Python
- Python used get_base_entity_name() with fallback to CORE.name
- Python used get_base_entity_object_id() with fallback to CORE.name
Result: object_id = sanitize(snake_case(device_name))
"""
@@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix(
)
# Hash should match device name
expected_hash = fnv1_hash_name("test-device")
expected_hash = fnv1_hash_object_id("test-device")
assert entity.key == expected_hash, (
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
)
@@ -1,14 +1,14 @@
"""Integration test for entity preference key migration.
"""Integration test for entity preference key stability.
Entity keys are now the FNV-1 hash of the raw name instead of the sanitized
object_id (https://github.com/esphome/backlog/issues/85). On key-lookup
preference backends, make_entity_preference() must move data stored under the
old key to the new key, so devices keep their restored state after upgrading.
Entity preferences are stored under keys derived from the sanitized object_id
hash. This test seeds the host preferences file the way existing firmware
wrote it and verifies the state is restored, proving the key scheme has not
drifted; a save and reload round trip cannot catch drift because it writes
and reads with the same code.
This test seeds the host preferences file the way a pre-migration firmware
would have written it and verifies:
1. Data stored under the OLD key is restored (migration happened, no data loss)
2. Data already stored under the NEW key is never overwritten by old data
The second run also seeds the raw-name-hash entries a 2026.8 beta device left
behind (see https://github.com/esphome/esphome/pull/18361) and proves they are
ignored: the object_id entries win and the beta leftovers are inert.
"""
from __future__ import annotations
@@ -33,22 +33,23 @@ from .host_prefs import clear_host_prefs, write_host_prefs
from .state_utils import InitialStateHelper, require_entity
from .types import CompileFunction, ConfigWriter
DEVICE_NAME = "host-pref-key-migration"
DEVICE_NAME = "host-pref-key-stability"
# The pre-migration preference key was the sanitized object_id hash; the new
# key is the raw-name hash. All entities are on the main device (device_id 0)
# and their preferences use no version salt, so the key is just the hash.
SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch")
SWITCH_NEW_KEY = fnv1_hash_name("Test Switch")
NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number")
NUMBER_NEW_KEY = fnv1_hash_name("Test Number")
# All entities are on the main device (device_id 0) and their preferences use
# no version salt, so the key is just the object_id hash.
SWITCH_KEY = fnv1_hash_object_id("Test Switch")
NUMBER_KEY = fnv1_hash_object_id("Test Number")
# Raw-name-hash keys as written by 2026.8 beta firmware; never read by this build
SWITCH_BETA_KEY = fnv1_hash_name("Test Switch")
NUMBER_BETA_KEY = fnv1_hash_name("Test Number")
# template_text salts its key with the length limits and pattern hash; this must
# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20,
# no pattern configured)
TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6)
TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
TEXT_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
TEXT_BETA_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes
TEXT_MAX_LENGTH = 20
@@ -62,18 +63,18 @@ def text_pref_payload(value: str) -> bytes:
@pytest.mark.asyncio
async def test_preference_key_migration(
async def test_preference_key_stability(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Test that preferences stored under the old key survive the upgrade."""
"""Test that preferences stored by earlier firmware are restored."""
port, port_socket = reserved_tcp_port
assert SWITCH_OLD_KEY != SWITCH_NEW_KEY
assert NUMBER_OLD_KEY != NUMBER_NEW_KEY
assert TEXT_OLD_KEY != TEXT_NEW_KEY
assert SWITCH_KEY != SWITCH_BETA_KEY
assert NUMBER_KEY != NUMBER_BETA_KEY
assert TEXT_KEY != TEXT_BETA_KEY
# Write and compile once
config_path = await write_yaml_config(yaml_config)
@@ -117,49 +118,51 @@ async def test_preference_key_migration(
return switch_state, number_state, text_state
try:
# --- Run 1: only OLD keys present, as written by pre-migration firmware.
# The restored states prove the data was migrated to the new keys.
# --- Run 1: entries under the object_id-hash keys, exactly as any
# earlier firmware wrote them. The restored states prove the key
# scheme has not drifted.
write_host_prefs(
DEVICE_NAME,
{
SWITCH_OLD_KEY: b"\x01", # bool: switch was ON
NUMBER_OLD_KEY: struct.pack("<f", 42.5),
TEXT_OLD_KEY: text_pref_payload("hello"),
SWITCH_KEY: b"\x01", # bool: switch was ON
NUMBER_KEY: struct.pack("<f", 42.5),
TEXT_KEY: text_pref_payload("hello"),
},
)
switch_state, number_state, text_state = await boot_and_get_initial_states()
assert switch_state.state is True, (
"Switch state stored under the old preference key was lost"
"Switch state stored under the object_id preference key was lost"
)
assert number_state.state == 42.5, (
"Number value stored under the old preference key was lost"
"Number value stored under the object_id preference key was lost"
)
assert text_state.state == "hello", (
"Text value stored under the old preference key was lost"
"Text value stored under the object_id preference key was lost"
)
# --- Run 2: both keys present with different values. The NEW key holds
# the current data and must win; stale old-key data must never clobber it.
# --- Run 2: raw-name-hash entries from a 2026.8 beta device present
# alongside the object_id entries. The object_id data must win; the
# beta entries are never read.
write_host_prefs(
DEVICE_NAME,
{
SWITCH_OLD_KEY: b"\x00", # stale: OFF
SWITCH_NEW_KEY: b"\x01", # current: ON
NUMBER_OLD_KEY: struct.pack("<f", 42.5), # stale
NUMBER_NEW_KEY: struct.pack("<f", 13.5), # current
TEXT_OLD_KEY: text_pref_payload("hello"), # stale
TEXT_NEW_KEY: text_pref_payload("world"), # current
SWITCH_KEY: b"\x01", # current: ON
SWITCH_BETA_KEY: b"\x00", # beta leftover: OFF
NUMBER_KEY: struct.pack("<f", 13.5), # current
NUMBER_BETA_KEY: struct.pack("<f", 99.5), # beta leftover
TEXT_KEY: text_pref_payload("world"), # current
TEXT_BETA_KEY: text_pref_payload("ignored"), # beta leftover
},
)
switch_state, number_state, text_state = await boot_and_get_initial_states()
assert switch_state.state is True, (
"Stale old-key data overwrote the current new-key switch state"
"Beta raw-name-key data overrode the object_id switch state"
)
assert number_state.state == 13.5, (
"Stale old-key data overwrote the current new-key number value"
"Beta raw-name-key data overrode the object_id number value"
)
assert text_state.state == "world", (
"Stale old-key data overwrote the current new-key text value"
"Beta raw-name-key data overrode the object_id text value"
)
finally:
clear_host_prefs(DEVICE_NAME)
+35 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import math
from aioesphomeapi import ButtonInfo, EntityState, SensorState
import pytest
@@ -25,6 +26,7 @@ async def test_sensor_filters_delta(
"filter_baseline_max": [],
"filter_zero_delta": [],
"filter_percentage": [],
"filter_nan": [],
}
filter_min_done = loop.create_future()
@@ -32,16 +34,23 @@ 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) or state.missing_state:
if not isinstance(state, SensorState):
return
sensor_name = key_to_sensor.get(state.key)
if sensor_name not in sensor_values:
return
sensor_values[sensor_name].append(state.state)
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)
# Check completion conditions
if (
@@ -74,6 +83,12 @@ 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),
@@ -89,6 +104,7 @@ 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",
},
)
@@ -108,13 +124,14 @@ 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) == 5, f"Expected 5 buttons, found {len(buttons)}"
assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}"
# Test 1: Min
sensor_values["filter_min"].clear()
@@ -186,3 +203,18 @@ 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}"
+121
View File
@@ -20,6 +20,7 @@ changed_files = helpers.changed_files
filter_changed = helpers.filter_changed
get_changed_components = helpers.get_changed_components
_get_changed_files_from_command = helpers._get_changed_files_from_command
run_gh_command = helpers.run_gh_command
_get_pr_number_from_github_env = helpers._get_pr_number_from_github_env
_get_changed_files_github_actions = helpers._get_changed_files_github_actions
_filter_changed_ci = helpers._filter_changed_ci
@@ -1872,3 +1873,123 @@ def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) ->
def test_base_python_changed(files: list[str], expected: bool) -> None:
"""Only Python modules directly in esphome/ count as base Python changes."""
assert helpers.base_python_changed(files) is expected
def _gh_error(stderr: str) -> subprocess.CalledProcessError:
return subprocess.CalledProcessError(1, ["gh"], output="", stderr=stderr)
def _gh_success(stdout: str = "ok\n") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(["gh"], 0, stdout=stdout, stderr="")
def test_run_gh_command_success() -> None:
"""A successful command returns without retrying."""
with patch("helpers.subprocess.run", return_value=_gh_success()) as mock_run:
result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"])
assert result.stdout == "ok\n"
mock_run.assert_called_once()
@pytest.mark.parametrize(
"second_error",
[
(
'Post "https://api.github.com/graphql": tls: failed to verify'
" certificate: x509: certificate is not valid for any names,"
" but wanted to match api.github.com"
),
'Post "https://api.github.com/graphql": EOF',
(
"error connecting to api.github.com\n"
"check your internet connection or https://githubstatus.com"
),
],
)
def test_run_gh_command_retries_transient_error(second_error: str) -> None:
"""Transient server errors are retried with 2s/4s backoff."""
with (
patch(
"helpers.subprocess.run",
side_effect=[
_gh_error("HTTP 502: 502 Bad Gateway (https://api.github.com/graphql)"),
_gh_error(second_error),
_gh_success(),
],
) as mock_run,
patch("helpers.time.sleep") as mock_sleep,
):
result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"])
assert result.stdout == "ok\n"
assert mock_run.call_count == 3
assert [call.args[0] for call in mock_sleep.call_args_list] == [2, 4]
def test_run_gh_command_gives_up_after_max_attempts() -> None:
"""A persistent transient error raises after the third attempt."""
with (
patch(
"helpers.subprocess.run",
side_effect=_gh_error("HTTP 503: Service Unavailable"),
) as mock_run,
patch("helpers.time.sleep") as mock_sleep,
pytest.raises(subprocess.CalledProcessError),
):
run_gh_command(["gh", "pr", "diff", "123", "--name-only"])
assert mock_run.call_count == 3
assert mock_sleep.call_count == 2
@pytest.mark.parametrize(
"stderr",
[
"HTTP 404: Not Found (https://api.github.com/repos/x)",
"HTTP 401: Bad credentials",
"HTTP 403: API rate limit exceeded for installation ID 123.",
"diff exceeded the maximum number of changed files (300)",
(
"GraphQL: Could not resolve to a PullRequest with the number of 999999."
" (repository.pullRequest)"
),
],
)
def test_run_gh_command_permanent_error_not_retried(stderr: str) -> None:
"""Permanent failures raise immediately without any retry."""
with (
patch("helpers.subprocess.run", side_effect=_gh_error(stderr)) as mock_run,
patch("helpers.time.sleep") as mock_sleep,
pytest.raises(subprocess.CalledProcessError),
):
run_gh_command(["gh", "pr", "diff", "123", "--name-only"])
mock_run.assert_called_once()
mock_sleep.assert_not_called()
def test_run_gh_command_no_retry_for_non_idempotent_commands() -> None:
"""retry=False fails on the first error even when it looks transient."""
with (
patch(
"helpers.subprocess.run",
side_effect=_gh_error("HTTP 502: 502 Bad Gateway"),
) as mock_run,
patch("helpers.time.sleep") as mock_sleep,
pytest.raises(subprocess.CalledProcessError),
):
run_gh_command(["gh", "pr", "comment", "123", "--body", "x"], retry=False)
mock_run.assert_called_once()
mock_sleep.assert_not_called()
def test_get_changed_files_from_command_gh_failure_keeps_stderr() -> None:
"""Failures from gh surface stderr so callers can detect the 300-file limit."""
stderr = "diff exceeded the maximum number of changed files (300)"
with (
patch("helpers.subprocess.run", side_effect=_gh_error(stderr)),
pytest.raises(Exception, match="maximum number of changed files"),
):
_get_changed_files_from_command(["gh", "pr", "diff", "123", "--name-only"])
@@ -1,239 +0,0 @@
"""Tests for the MQTT object_id conflict filter.
MQTT still builds default topics and discovery topics from the sanitized
object_id, so entity names that only differ in characters lost during
sanitizing conflict there; _topics_conflict() exempts entities that never
use an object_id-derived topic. See https://github.com/esphome/backlog/issues/85
"""
from pathlib import Path
import pytest
from esphome.components.mqtt import (
_COMMAND_TOPIC_PLATFORMS,
_SUB_TOPIC_PLATFORMS,
_topics_conflict,
)
from esphome.config_validation import Invalid
from esphome.const import (
CONF_COMMAND_TOPIC,
CONF_DISCOVERY,
CONF_NAME,
CONF_STATE_TOPIC,
CONF_TOPIC_PREFIX,
)
from esphome.core import CORE
from esphome.core.entity_helpers import (
entity_duplicate_validator,
validate_no_object_id_conflicts,
)
COMPONENTS_DIR = Path(__file__).parents[4] / "esphome" / "components"
REASON = "mqtt builds default topics from the entity object_id"
# MQTT infrastructure sources, not entity components
_NON_ENTITY_MQTT_SOURCES = {"mqtt_client", "mqtt_component"}
# The date, time and datetime MQTT components all belong to the datetime platform
_DATETIME_STEMS = {"date", "time", "datetime"}
def test_command_topic_platforms_in_sync() -> None:
"""Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe.
Drift silently reintroduces shared subscribe topics, so this derives the set
from the C++ components that actually call subscribe(); that also catches
platforms like text that subscribe a command topic without exposing a
command_topic key in their schema.
"""
expected: set[str] = set()
for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"):
if path.stem in _NON_ENTITY_MQTT_SOURCES:
continue
if "this->subscribe" not in path.read_text(encoding="utf-8"):
continue
stem = path.stem.removeprefix("mqtt_")
expected.add("datetime" if stem in _DATETIME_STEMS else stem)
assert expected == _COMMAND_TOPIC_PLATFORMS
def test_sub_topic_platforms_in_sync() -> None:
"""Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics.
Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra
topics such as position/command from the object_id.
"""
expected = {
path.stem.removeprefix("mqtt_")
for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h")
if path.stem != "mqtt_component"
and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8")
}
assert expected == _SUB_TOPIC_PLATFORMS
def test_conflict_filter_exempts_custom_topics() -> None:
"""Test that custom state topics with discovery off avoid the conflict."""
validator = entity_duplicate_validator("sensor")
# Both entities have custom state topics and discovery disabled per entity,
# so no object_id-derived MQTT topic is used
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
assert component_validator(config) is config
# Without the filter the same conflicts are fatal
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
validate_no_object_id_conflicts(REASON)({})
def test_conflict_on_default_command_topic() -> None:
"""Test that commandable platforms conflict through their default command topic.
Custom state topics with discovery off are not enough for platforms that also
subscribe to an object_id-derived command topic.
"""
validator = entity_duplicate_validator("switch")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
# Both switches share the default command topic: rejected
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
component_validator(mqtt_config)
# With custom command topics as well, nothing derives from the object_id
CORE.reset()
validator = entity_duplicate_validator("switch")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_COMMAND_TOPIC: "custom/cmd/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_COMMAND_TOPIC: "custom/cmd/b",
CONF_DISCOVERY: False,
}
)
assert component_validator(mqtt_config) is mqtt_config
def test_conflict_on_sub_topic_platforms() -> None:
"""Test that platforms with extra object_id sub-topics always conflict.
Covers derive topics like position/command from the object_id through their
own config keys, so custom state and command topics cannot exempt them.
"""
validator = entity_duplicate_validator("cover")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_COMMAND_TOPIC: "custom/cmd/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_COMMAND_TOPIC: "custom/cmd/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"})
def test_no_conflict_on_disjoint_default_topics() -> None:
"""Test that entities whose default topics are disjoint do not conflict.
One entity uses only the default command topic and the other only the default
state topic, so they never share a topic.
"""
validator = entity_duplicate_validator("switch")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_COMMAND_TOPIC: "custom/cmd/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
assert component_validator(config) is config
def test_no_conflict_on_empty_topic_prefix() -> None:
"""Test that an empty topic_prefix disables the default topic conflict.
With topic_prefix set to null no default topics exist at runtime, so entities
without custom state topics cannot conflict; only discovery still matters.
"""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Датчик открытия"})
validator({CONF_NAME: "Датчик закрытия"})
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
# No default topics and no discovery: valid
config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""}
assert component_validator(config) is config
# Discovery still uses object_id-derived config topics: rejected
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""})
+247 -185
View File
@@ -1,4 +1,4 @@
"""Tests for entity helpers: name selection, entity key hashing, duplicate checks."""
"""Test get_base_entity_object_id function matches C++ behavior."""
from collections.abc import Callable, Generator
from pathlib import Path
@@ -25,17 +25,16 @@ from esphome.core.entity_helpers import (
_setup_entity_impl,
entity_duplicate_validator,
finalize_entity_strings,
get_base_entity_name,
get_base_entity_object_id,
register_device_class,
register_icon,
register_unit_of_measurement,
setup_device_class,
setup_entity,
setup_unit_of_measurement,
validate_no_object_id_conflicts,
)
from esphome.cpp_generator import MockObj
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
from esphome.helpers import fnv1_hash, sanitize, snake_case
from .common import load_config_from_fixture
@@ -58,26 +57,206 @@ def restore_core_state() -> Generator[None, None, None]:
CORE.friendly_name = original_friendly_name
def test_get_base_entity_name_priority_order() -> None:
def test_with_entity_name() -> None:
"""Test when entity has its own name - should use entity name."""
# Simple name
assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor"
assert (
get_base_entity_object_id("Temperature Sensor", "Device Name")
== "temperature_sensor"
)
# Even with device name, entity name takes precedence
assert (
get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device")
== "temperature_sensor"
)
# Name with special characters
assert (
get_base_entity_object_id("Temp!@#$%^&*()Sensor", None)
== "temp__________sensor"
)
assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123"
# Already snake_case
assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor"
# Mixed case
assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor"
assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor"
def test_empty_name_with_device_name() -> None:
"""Test when entity has empty name and is on a sub-device - should use device name."""
# C++ behavior: when has_own_name is false and device is set, uses device->get_name()
assert (
get_base_entity_object_id("", "Friendly Device", "Sub Device 1")
== "sub_device_1"
)
assert (
get_base_entity_object_id("", "Kitchen Controller", "controller_1")
== "controller_1"
)
assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123"
def test_empty_name_with_friendly_name() -> None:
"""Test when entity has empty name and no device - should use friendly name."""
# C++ behavior: when has_own_name is false, uses App.get_friendly_name()
assert get_base_entity_object_id("", "Friendly Device") == "friendly_device"
assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller"
assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123"
# Special characters in friendly name
assert get_base_entity_object_id("", "Device!@#$%") == "device_____"
def test_empty_name_no_friendly_name() -> None:
"""Test when entity has empty name and no friendly name - should use device name."""
# Test with CORE.name set
CORE.name = "device-name"
assert get_base_entity_object_id("", None) == "device-name"
CORE.name = "Test Device"
assert get_base_entity_object_id("", None) == "test_device"
def test_edge_cases() -> None:
"""Test edge cases."""
# Only spaces
assert get_base_entity_object_id(" ", None) == "___"
# Unicode characters (should be replaced)
assert get_base_entity_object_id("Température", None) == "temp_rature"
assert get_base_entity_object_id("测试", None) == "__"
# Empty string with empty friendly name (empty friendly name is treated as None)
# Falls back to CORE.name
CORE.name = "device"
assert get_base_entity_object_id("", "") == "device"
# Very long name (should work fine)
long_name = "a" * 100 + " " + "b" * 100
expected = "a" * 100 + "_" + "b" * 100
assert get_base_entity_object_id(long_name, None) == expected
@pytest.mark.parametrize(
("name", "expected"),
[
("Temperature Sensor", "temperature_sensor"),
("Living Room Light", "living_room_light"),
("Test-Device_123", "test-device_123"),
("Special!@#Chars", "special___chars"),
("UPPERCASE NAME", "uppercase_name"),
("lowercase name", "lowercase_name"),
("Mixed Case Name", "mixed_case_name"),
(" Spaces ", "___spaces___"),
],
)
def test_matches_cpp_helpers(name: str, expected: str) -> None:
"""Test that the logic matches using snake_case and sanitize directly."""
# For non-empty names, verify our function produces same result as direct snake_case + sanitize
assert get_base_entity_object_id(name, None) == sanitize(snake_case(name))
assert get_base_entity_object_id(name, None) == expected
def test_empty_name_fallback() -> None:
"""Test empty name handling which falls back to friendly_name or CORE.name."""
# Empty name is handled specially - it doesn't just use sanitize(snake_case(""))
# Instead it falls back to friendly_name or CORE.name
assert sanitize(snake_case("")) == "" # Direct conversion gives empty string
# But our function returns a fallback
CORE.name = "device"
assert get_base_entity_object_id("", None) == "device" # Uses device name
def test_name_add_mac_suffix_behavior() -> None:
"""Test behavior related to name_add_mac_suffix.
In C++, an entity's object_id is computed from its name_ via
write_object_id_to() (sanitized snake_case). When an entity has no name,
configure_entity_() sets name_ from the friendly name, with the MAC suffix
appended when name_add_mac_suffix is enabled. Our function always returns
the same result since we're calculating the base for duplicate tracking.
"""
# The function should always return the same result regardless of
# name_add_mac_suffix setting, as we're calculating the base object_id
assert get_base_entity_object_id("", "Test Device") == "test_device"
assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name"
def test_priority_order() -> None:
"""Test the priority order: entity name > device name > friendly name > CORE.name."""
CORE.name = "core-device"
# 1. Entity name has highest priority and is used as-is, no transformations
# 1. Entity name has highest priority
assert (
get_base_entity_name("Entity Name", "Friendly Name", "Device Name")
== "Entity Name"
get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name")
== "entity_name"
)
assert get_base_entity_name("Température", None) == "Température"
# 2. Device name is next priority (when entity name is empty)
assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name"
assert (
get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name"
)
# 3. Friendly name is next (when entity and device names are empty)
assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name"
assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name"
# 4. CORE.name is last resort; an empty friendly name falls through to it
assert get_base_entity_name("", None, None) == "core-device"
assert get_base_entity_name("", "") == "core-device"
# 4. CORE.name is last resort
assert get_base_entity_object_id("", None, None) == "core-device"
@pytest.mark.parametrize(
("name", "friendly_name", "device_name", "expected"),
[
# name, friendly_name, device_name, expected
("Living Room Light", None, None, "living_room_light"),
("", "Kitchen Controller", None, "kitchen_controller"),
(
"",
"ESP32 Device",
"controller_1",
"controller_1",
), # Device name takes precedence
("GPIO2 Button", None, None, "gpio2_button"),
("WiFi Signal", "My Device", None, "wifi_signal"),
("", None, "esp32_node", "esp32_node"),
("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"),
],
)
def test_real_world_examples(
name: str, friendly_name: str | None, device_name: str | None, expected: str
) -> None:
"""Test real-world entity naming scenarios."""
result = get_base_entity_object_id(name, friendly_name, device_name)
assert result == expected
def test_issue_6953_scenarios() -> None:
"""Test specific scenarios from issue #6953."""
# Scenario 1: Multiple empty names on main device with name_add_mac_suffix
# The Python code calculates the base, C++ might append MAC suffix dynamically
CORE.name = "device-name"
CORE.friendly_name = "Friendly Device"
# All empty names should resolve to same base
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
# Scenario 2: Empty names on sub-devices
assert (
get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1"
)
assert (
get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2"
)
# Scenario 3: xyz duplicates
assert get_base_entity_object_id("xyz", None) == "xyz"
assert get_base_entity_object_id("xyz", "Device") == "xyz"
# Tests for setup_entity function
@@ -336,10 +515,9 @@ def test_entity_duplicate_validator() -> None:
config1 = {CONF_NAME: "Temperature"}
validated1 = validator(config1)
assert validated1 == config1
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
assert temperature_key in CORE.unique_ids
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
# Check metadata was stored
metadata = CORE.unique_ids[temperature_key]
metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))]
assert metadata["name"] == "Temperature"
assert metadata["platform"] == "sensor"
@@ -347,9 +525,8 @@ def test_entity_duplicate_validator() -> None:
config2 = {CONF_NAME: "Humidity"}
validated2 = validator(config2)
assert validated2 == config2
humidity_key = ("", "sensor", fnv1_hash_name("Humidity"))
assert humidity_key in CORE.unique_ids
metadata2 = CORE.unique_ids[humidity_key]
assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids
metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))]
assert metadata2["name"] == "Humidity"
# Duplicate entity should fail
@@ -360,6 +537,34 @@ def test_entity_duplicate_validator() -> None:
validator(config3)
def test_entity_duplicate_validator_hash_collision() -> None:
"""Test that two different object_ids with the same FNV-1 hash are rejected."""
# Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4
name_a = "Sensor aooxzi"
name_b = "Sensor baraia"
object_id_a = sanitize(snake_case(name_a))
object_id_b = sanitize(snake_case(name_b))
assert object_id_a != object_id_b
assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b)
validator = entity_duplicate_validator("sensor")
config1 = {CONF_NAME: name_a}
validated1 = validator(config1)
assert validated1 == config1
config2 = {CONF_NAME: name_b}
with pytest.raises(
Invalid,
match=re.compile(
r"Duplicate sensor entity with name 'Sensor baraia' found.*"
r"produce the same entity key hash \(0xe95747e4\)",
re.DOTALL,
),
):
validator(config2)
def test_entity_duplicate_validator_with_devices() -> None:
"""Test entity_duplicate_validator with devices."""
# Create validator for sensor platform
@@ -370,19 +575,18 @@ def test_entity_duplicate_validator_with_devices() -> None:
device2 = ID("device2", type="Device")
# Same name on different devices should pass
name_hash = fnv1_hash_name("Temperature")
config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1}
validated1 = validator(config1)
assert validated1 == config1
assert ("device1", "sensor", name_hash) in CORE.unique_ids
metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)]
assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))]
assert metadata1["device_id"] == "device1"
config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2}
validated2 = validator(config2)
assert validated2 == config2
assert ("device2", "sensor", name_hash) in CORE.unique_ids
metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)]
assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))]
assert metadata2["device_id"] == "device2"
# Duplicate on same device should fail
@@ -434,33 +638,6 @@ def test_entity_different_platforms_yaml_validation(
assert result is not None
def test_object_id_conflict_mqtt_yaml_validation(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that names sanitizing to the same object_id fail when mqtt is configured."""
result = load_config_from_fixture(
yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR
)
assert result is None
captured = capsys.readouterr()
assert (
"mqtt builds default topics and discovery topics from the entity object_id"
in captured.out
)
def test_object_id_conflict_without_mqtt_yaml_validation(
yaml_file: Callable[[str], str],
) -> None:
"""Test that names sanitizing to the same object_id pass without mqtt/prometheus."""
result = load_config_from_fixture(
yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR
)
# This should succeed
assert result is not None
def test_entity_duplicate_validator_error_message() -> None:
"""Test that duplicate entity error messages include helpful metadata."""
# Create validator for sensor platform
@@ -519,8 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None:
validated1 = validator(config1)
assert validated1 == config1
# New format includes device_id (empty string for main device)
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
assert temperature_key in CORE.unique_ids
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
# Internal entity with same name should pass (not added to unique_ids)
config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
@@ -528,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None:
assert validated2 == config2
# Internal entity should not be added to unique_ids
# Count how many times the key appears (should still be 1)
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
count = sum(
1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature"))
)
assert count == 1
# Another internal entity with same name should also pass
@@ -536,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None:
validated3 = validator(config3)
assert validated3 == config3
# Still only one entry in unique_ids (from the non-internal entity)
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
count = sum(
1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature"))
)
assert count == 1
# Non-internal entity with same name should fail
@@ -564,148 +744,30 @@ def test_empty_or_null_device_id_on_entity() -> None:
def test_entity_duplicate_validator_non_ascii_names() -> None:
"""Test that distinct non-ASCII names no longer collide.
These names used to be rejected because both sanitize to only underscores;
the entity key now hashes the raw name so they stay distinct.
"""
"""Test that non-ASCII names show helpful error messages."""
# Create validator for binary_sensor platform
validator = entity_duplicate_validator("binary_sensor")
# Both Russian sensors should pass even though they sanitize identically
# First Russian sensor should pass
config1 = {CONF_NAME: "Датчик открытия основного крана"}
validated1 = validator(config1)
assert validated1 == config1
# Second Russian sensor with different text but same ASCII conversion should fail
config2 = {CONF_NAME: "Датчик закрытия основного крана"}
validated2 = validator(config2)
assert validated2 == config2
# An exact duplicate still fails
config3 = {CONF_NAME: "Датчик открытия основного крана"}
with pytest.raises(
Invalid,
match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found",
):
validator(config3)
def test_entity_duplicate_validator_hash_collision() -> None:
"""Test that two different names with the same FNV-1 hash are rejected."""
# Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b
name_a = "Sensor m2CZ"
name_b = "Sensor qCaa"
assert name_a != name_b
assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b)
validator = entity_duplicate_validator("sensor")
config1 = {CONF_NAME: name_a}
validated1 = validator(config1)
assert validated1 == config1
config2 = {CONF_NAME: name_b}
with pytest.raises(
Invalid,
match=re.compile(
rf"Duplicate sensor entity with name '{name_b}' found.*"
rf"The names '{name_b}' and '{name_a}' produce the.*"
r"same entity key hash \(0x0ee5ff7b\).*"
r"To fix: Rename one of the entities",
r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*"
r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*"
r"Both convert to ASCII ID: '_______________________________'.*"
r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)",
re.DOTALL,
),
):
validator(config2)
def test_object_id_conflicts_rejected_by_component_validator() -> None:
"""Test that object_id conflicts pass entity validation but fail for mqtt/prometheus."""
validator = entity_duplicate_validator("sensor")
# Both names validate fine in general (distinct raw names, distinct keys)
validator({CONF_NAME: "Датчик открытия"})
validator({CONF_NAME: "Датчик закрытия"})
# A component that addresses entities by object_id must reject the config
component_validator = validate_no_object_id_conflicts(
"mqtt builds default topics from the entity object_id"
)
with pytest.raises(
Invalid,
match=re.compile(
r"mqtt builds default topics from the entity object_id.*"
r"sensor entities 'Датчик открытия', 'Датчик закрытия' "
r"share the object_id '_______________'.*"
r"To fix: Add unique ASCII characters",
re.DOTALL,
),
):
component_validator({})
def test_object_id_conflicts_skipped_in_testing_mode() -> None:
"""Test that testing_mode skips the conflict check, as used for grouped testing."""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Датчик открытия"})
validator({CONF_NAME: "Датчик закрытия"})
component_validator = validate_no_object_id_conflicts(
"mqtt builds default topics from the entity object_id"
)
CORE.testing_mode = True
try:
config: dict = {}
assert component_validator(config) is config
finally:
CORE.testing_mode = False
def test_object_id_conflicts_none_recorded() -> None:
"""Test that distinct object_ids produce no conflicts."""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Temperature"})
validator({CONF_NAME: "Humidity"})
component_validator = validate_no_object_id_conflicts(
"mqtt builds default topics from the entity object_id"
)
config: dict = {}
assert component_validator(config) is config
def test_object_id_conflicts_device_scoped() -> None:
"""Test that the object_id conflict check is scoped per device.
Same-named entities on different sub-devices were accepted before entity keys
moved to raw names, so the check keeps that scope; conflicts within one device
are still reported with the device named in the message.
"""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")})
validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")})
component_validator = validate_no_object_id_conflicts(
"prometheus builds metric labels from the entity object_id"
)
config: dict = {}
assert component_validator(config) is config
# Two names sanitizing identically on the same sub-device still conflict
validator(
{CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")}
)
validator(
{CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")}
)
with pytest.raises(
Invalid,
match=re.compile(
r"prometheus builds metric labels.*on device 'device1'", re.DOTALL
),
):
component_validator({})
def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None:
"""Test that identical names don't show the enhanced message."""
# Create validator for sensor platform
@@ -763,7 +825,7 @@ async def test_setup_entity_empty_name_with_device(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_key") == 0
assert config.get("_entity_object_id_hash") == 0
@pytest.mark.asyncio
@@ -792,7 +854,7 @@ async def test_setup_entity_empty_name_with_mac_suffix(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_key") == 0
assert config.get("_entity_object_id_hash") == 0
@pytest.mark.asyncio
@@ -822,7 +884,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_key") == 0
assert config.get("_entity_object_id_hash") == 0
@pytest.mark.asyncio
@@ -853,7 +915,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_key") == 0
assert config.get("_entity_object_id_hash") == 0
def test_register_string_overflow() -> None:
@@ -1,22 +0,0 @@
esphome:
name: test-object-id-conflict
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
mqtt:
broker: test.mosquitto.org
sensor:
# Distinct raw names are fine in general, but both sanitize to the same
# object_id, which MQTT still uses to build default topics - should fail
- platform: template
name: "Датчик открытия"
lambda: return 21.0;
- platform: template
name: "Датчик закрытия"
lambda: return 22.0;
@@ -1,15 +0,0 @@
esphome:
name: test-object-id-ok
esp32:
board: esp32dev
sensor:
# Distinct raw names that sanitize to the same object_id are allowed when no
# component addresses entities by object_id (no mqtt or prometheus configured)
- platform: template
name: "Датчик открытия"
lambda: return 21.0;
- platform: template
name: "Датчик закрытия"
lambda: return 22.0;
@@ -1,10 +1,15 @@
"""Shared storage-sidecar factory for the lazy-import fixture scripts."""
from pathlib import Path
from esphome.storage_json import StorageJSON
def make_storage() -> StorageJSON:
"""A minimal post-compile esp32 sidecar the upload/logs fast path accepts."""
"""A minimal post-compile esp32 sidecar the upload/logs fast path accepts.
build_path must be set: the fast path rejects sidecars without one.
"""
return StorageJSON(
storage_version=1,
name="test",
@@ -15,8 +20,8 @@ def make_storage() -> StorageJSON:
address="1.2.3.4",
web_port=None,
target_platform="ESP32S3",
build_path=None,
firmware_bin_path=None,
build_path=Path("/build/test"),
firmware_bin_path=Path("/build/test/firmware.bin"),
loaded_integrations=set(),
loaded_platforms=set(),
no_mdns=False,
+268 -31
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import contextmanager
from ipaddress import IPv4Address, IPv4Network
import json
import os
@@ -19,6 +20,7 @@ from esphome.compiled_config import (
compiled_config_path,
load_compiled_config,
save_compiled_config,
save_compiled_config_and_sidecar,
)
from esphome.const import (
CONF_API,
@@ -31,7 +33,16 @@ from esphome.const import (
KEY_VARIANT,
Toolchain,
)
from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds
from esphome.core import (
CORE,
ID,
EsphomeError,
HexInt,
Lambda,
MACAddress,
TimePeriodMilliseconds,
)
from esphome.storage_json import StorageJSON
from esphome.util import OrderedDict
_VALIDATED_CONFIG = {
@@ -54,8 +65,9 @@ def _cache_body(config: dict | None = None) -> str:
def _write_storage(
storage_path: Path,
*,
esp_platform: str = "ESP32",
esp_platform: str | None = "ESP32",
core_platform: str | None = "esp32",
build_path: str | None = "/build/lite_test",
) -> None:
"""Write a vanilla StorageJSON sidecar for the cache tests."""
storage_path.parent.mkdir(parents=True, exist_ok=True)
@@ -69,7 +81,7 @@ def _write_storage(
"address": "192.168.1.42",
"web_port": None,
"esp_platform": esp_platform,
"build_path": "/build/lite_test",
"build_path": build_path,
"firmware_bin_path": "/build/lite_test/firmware.bin",
"loaded_integrations": ["api", "logger", "ota", "wifi"],
"loaded_platforms": [],
@@ -359,31 +371,262 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
mock_read.assert_called_once()
def test_run_esphome_upload_does_not_refresh_cache_without_sidecar(
tmp_path: Path,
) -> None:
"""Without a StorageJSON sidecar (no compile has run), the fallback
skips the cache write -- load_compiled_config requires the sidecar,
so writing the rendered (secret-resolved) config would be inert and
leak secrets to disk for nothing."""
def _storage_fixture(tmp_path: Path) -> StorageJSON:
"""A loaded StorageJSON instance matching _write_storage's contents."""
fixture = tmp_path / "fixture_storage.json"
_write_storage(fixture)
return StorageJSON.load(fixture)
def _bare_yaml(tmp_path: Path) -> Path:
"""A minimal YAML with CORE.config_path pointed at it."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
return yaml_path
@contextmanager
def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any:
"""Patch the fallback path's collaborators for a run_esphome call.
Without kwargs, from_esphome_core stays real (yielded mock is None).
"""
with (
patch(
"esphome.config.read_config",
return_value={"esphome": {"name": "lite_test"}},
),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"upload": lambda args, config: 0},
{command: lambda args, config: 0},
),
):
run_esphome(["esphome", "upload", str(yaml_path)])
if not from_core_kwargs:
yield mock_read, None
return
with patch.object(
StorageJSON, "from_esphome_core", **from_core_kwargs
) as mock_from_core:
yield mock_read, mock_from_core
@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar(
tmp_path: Path, command: str
) -> None:
"""A never-compiled config caches on its first upload/logs run: the
fallback writes the StorageJSON sidecar itself (load_compiled_config
needs it), so the second run hits the fast path."""
yaml_path = _bare_yaml(tmp_path)
storage_dir = tmp_path / ".esphome" / "storage"
with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as (
mock_read,
mock_from_core,
):
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
mock_from_core.assert_called_once()
assert (storage_dir / "lite_test.yaml.validated.json").exists()
storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
assert storage is not None
# No compile happened, so the sidecar must not claim one.
assert mock_from_core.call_args.kwargs == {"claim_build": False}
# The second run loads the cache instead of re-validating.
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
mock_read.assert_called_once()
# as_dict serialized unset paths as str(None) until 2026.9; files
# written by those wizards are still on disk.
_WIZARD_SIDECAR_CASES = pytest.mark.parametrize(
"wizard_kwargs",
[
{"esp_platform": None, "core_platform": None, "build_path": None},
{"build_path": None},
{"build_path": "None"},
],
ids=["legacy_wizard", "modern_wizard", "none_string_wizard"],
)
def _prime_core(tmp_path: Path) -> None:
"""Set the post-validation CORE state from_esphome_core reads."""
CORE.name = "lite_test"
CORE.build_path = tmp_path / "build" / "lite_test"
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp8266",
KEY_TARGET_FRAMEWORK: "arduino",
}
@_WIZARD_SIDECAR_CASES
def test_run_esphome_fallback_completes_wizard_sidecar(
tmp_path: Path, wizard_kwargs: dict[str, Any]
) -> None:
"""A wizard-written sidecar can't drive the fast path (no build_path;
older wizards also no platform fields); the fallback rewrites it from
CORE so the cache loads on the next run."""
yaml_path = _bare_yaml(tmp_path)
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
mock_from_core.assert_called_once()
storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
assert storage is not None and storage.core_platform == "esp32"
# What the wizard recorded about a build (nothing, or a real one)
# carries through instead of being stamped with this run's values.
assert storage.esphome_version == "2026.1.0"
assert load_compiled_config(yaml_path) is not None
def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails(
tmp_path: Path,
) -> None:
"""A failed sidecar write is non-fatal and skips the cache save too:
without the sidecar the cache could never be loaded back, so writing
it would only leave resolved secrets on disk."""
yaml_path = _bare_yaml(tmp_path)
with (
_fallback_run(side_effect=RuntimeError("boom")),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
mock_save.assert_not_called()
assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists()
def test_run_esphome_fallback_write_failure_takes_io_branch(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""StorageJSON.save raises EsphomeError (write_file wraps OSError into
it), which must land in the plain I/O warning, not the traceback
branch for structural bugs."""
yaml_path = _bare_yaml(tmp_path)
with (
_fallback_run(return_value=_storage_fixture(tmp_path)),
patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
caplog.at_level("WARNING", logger="esphome.compiled_config"),
):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
mock_save.assert_not_called()
assert "Could not refresh the storage sidecar" in caplog.text
assert "Unexpected error" not in caplog.text
def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None:
"""A present-but-corrupt sidecar is not overwritten: it may hold a real
build's metadata, and replacing it would suppress the next compile's
clean of a possibly incoherent build tree. The cache save is skipped."""
yaml_path = _bare_yaml(tmp_path)
storage_dir = tmp_path / ".esphome" / "storage"
sidecar = storage_dir / "lite_test.yaml.json"
sidecar.parent.mkdir(parents=True, exist_ok=True)
sidecar.write_text("{truncated", encoding="utf-8")
with _fallback_run(return_value=None) as (_, mock_from_core):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
mock_from_core.assert_not_called()
assert sidecar.read_text(encoding="utf-8") == "{truncated"
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete(
tmp_path: Path,
) -> None:
"""If the rebuilt sidecar would still be incomplete, nothing is written:
the cache could never be loaded back, so saving it would only rewrite
resolved secrets on every run."""
yaml_path = _bare_yaml(tmp_path)
storage_dir = tmp_path / ".esphome" / "storage"
incomplete = tmp_path / "incomplete_storage.json"
_write_storage(incomplete, build_path=None)
with _fallback_run(return_value=StorageJSON.load(incomplete)):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
assert not (storage_dir / "lite_test.yaml.json").exists()
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
def test_run_esphome_fallback_sidecar_records_platformio_toolchain(
tmp_path: Path,
) -> None:
"""The toolchain fallback runs before the sidecar write, so platforms
whose validators leave CORE.toolchain unset record the same
"platformio" a compile writes, not null."""
yaml_path = _bare_yaml(tmp_path)
_prime_core(tmp_path)
assert CORE.toolchain is None
with _fallback_run():
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
storage = StorageJSON.load(
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
)
assert storage is not None
assert storage.toolchain == "platformio"
@pytest.mark.parametrize("existing_sidecar", [None, "wizard"])
def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists(
tmp_path: Path, existing_sidecar: str | None
) -> None:
"""An existing build tree with a missing or wizard-only sidecar keeps
it that way: the mismatch is what makes the next compile wipe the
unknown tree, so the fallback writes nothing and skips the cache."""
yaml_path = _bare_yaml(tmp_path)
_prime_core(tmp_path)
CORE.build_path.mkdir(parents=True)
storage_dir = tmp_path / ".esphome" / "storage"
if existing_sidecar == "wizard":
_write_storage(storage_dir / "lite_test.yaml.json", build_path=None)
wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
mock_from_core.assert_not_called()
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
if existing_sidecar == "wizard":
sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
assert sidecar_body == wizard_body
else:
assert not (storage_dir / "lite_test.yaml.json").exists()
def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None:
"""Drive the real from_esphome_core on the fallback path: the
post-validation CORE state yields a complete, loadable sidecar."""
yaml_path = _bare_yaml(tmp_path)
_prime_core(tmp_path)
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
CORE.toolchain = Toolchain.PLATFORMIO
save_compiled_config_and_sidecar(CORE.config)
storage = StorageJSON.load(
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
)
assert storage is not None
assert storage.core_platform == "esp8266"
assert storage.build_path is not None
# No compile happened, so the sidecar must not claim one.
assert storage.esphome_version is None
assert storage.firmware_bin_path is None
assert load_compiled_config(yaml_path) is not None
@pytest.mark.parametrize("command", ["upload", "logs"])
@@ -409,6 +652,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
patch(
"esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
) as mock_save,
patch.object(StorageJSON, "from_esphome_core") as mock_from_core,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{command: lambda args, config: 0},
@@ -417,6 +661,8 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
mock_save.assert_called_once_with(fresh_config)
# The compile-written sidecar is complete; the fallback leaves it alone.
mock_from_core.assert_not_called()
# mtime is now newer than the source YAML, so a follow-up call hits
# the fast path instead of repeating read_config.
assert cache.stat().st_mtime >= yaml_path.stat().st_mtime
@@ -647,24 +893,15 @@ def test_int_keys_coerce_to_strings(primed_storage: Path) -> None:
assert config["table"] == {"1": "a", "2": "b"}
def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None:
"""A wizard-only sidecar (no compile -- no core_platform / target_platform)
can't drive upload/logs, so the fast path falls back."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
@_WIZARD_SIDECAR_CASES
def test_load_compiled_config_rejects_wizard_only_sidecar(
tmp_path: Path, wizard_kwargs: dict[str, Any]
) -> None:
"""A wizard-written sidecar (no build_path; older wizards also no
platform fields) can't drive upload/logs, so the fast path falls back."""
yaml_path = _bare_yaml(tmp_path)
storage_dir = tmp_path / ".esphome" / "storage"
storage_dir.mkdir(parents=True, exist_ok=True)
# StorageJSON with both core_platform and target_platform unset.
(storage_dir / "lite_test.yaml.json").write_text(
'{"storage_version": 1, "name": "lite_test", "friendly_name": null, '
'"comment": null, "esphome_version": null, "src_version": 1, '
'"address": null, "web_port": null, "esp_platform": null, '
'"build_path": null, "firmware_bin_path": null, '
'"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, '
'"framework": null, "core_platform": null}'
)
_write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json")
_set_cache_mtime(cache_path, yaml_path, offset=5)
@@ -2967,6 +2967,23 @@ def test_require_esphome_version_older_prerelease_fails() -> None:
cv.require_esphome_version(2026, 8, 0)("test")
def test_parse_esphome_version_deprecated_shim(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The removed helper still works for external components and warns."""
from esphome import const, util
with (
patch.object(const, "__version__", "2026.9.0-dev"),
caplog.at_level(logging.WARNING),
):
assert cv.parse_esphome_version() == (2026, 9, 0)
assert cv.parse_esphome_version() < (9999, 0, 0)
assert "parse_esphome_version() is deprecated" in caplog.text
# Both historical import paths resolve to the same function
assert cv.parse_esphome_version is util.parse_esphome_version
# ---------------------------------------------------------------------------
# suppress_invalid / validate_source_shorthand / rename_key
# ---------------------------------------------------------------------------
+52
View File
@@ -0,0 +1,52 @@
"""Platform get_download_types contract for never-built configs.
Wizard-written and upload/logs-fallback sidecars record no
firmware_bin_path; the download panel must get an empty list for them,
not entries pointing at files that were never built.
"""
from __future__ import annotations
from importlib import import_module
from pathlib import Path
from typing import Any
import pytest
from esphome.storage_json import StorageJSON
PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52"]
def _download_types(platform: str, storage: StorageJSON) -> list[dict[str, Any]]:
return import_module(f"esphome.components.{platform}").get_download_types(storage)
def _wizard_storage() -> StorageJSON:
return StorageJSON.from_wizard(
name="test_device",
friendly_name="Test Device",
address="test_device.local",
platform="ESP32",
)
@pytest.mark.parametrize("platform", PLATFORMS)
def test_no_firmware_path_yields_no_downloads(platform: str) -> None:
"""No recorded firmware path means nothing was built; no downloads."""
assert _download_types(platform, _wizard_storage()) == []
@pytest.mark.parametrize("platform", PLATFORMS)
def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) -> None:
"""With a firmware path recorded, every platform offers entries in
the documented title/description/file/download shape."""
storage = _wizard_storage()
storage.firmware_bin_path = tmp_path / "firmware.bin"
types = _download_types(platform, storage)
assert types
assert all(
{"title", "description", "file", "download"} <= entry.keys() for entry in types
)
+15
View File
@@ -265,6 +265,21 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None:
"""A PYTHONPATH from the parent environment must not reach idf.py.
It would override the IDF venv's isolation, shadowing its pinned
packages and failing idf.py's dependency check.
"""
toolchain._cache().env.clear()
with patch.dict(
os.environ,
{"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"},
):
env = toolchain._get_idf_env(version="5.5.4")
assert "PYTHONPATH" not in env
def test_get_cmake_output_without_build_dir(setup_core: Path) -> None:
"""A build dir that was never created raises EsphomeError.
+365 -17
View File
@@ -44,13 +44,17 @@ def mock_file() -> io.BytesIO:
@pytest.fixture
def mock_time() -> Generator[None]:
def mock_sleep() -> Generator[Mock]:
"""Mock time.sleep so delays don't slow down tests."""
with patch("time.sleep") as mock:
yield mock
@pytest.fixture
def mock_time(mock_sleep: Mock) -> Generator[None]:
"""Mock time-related functions for consistent testing."""
# Provide enough values for multiple calls (tests may call perform_ota multiple times)
with (
patch("time.sleep"),
patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]),
):
with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]):
yield
@@ -79,6 +83,28 @@ def mock_resolve_ip() -> Generator[Mock]:
yield mock
DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0)
DUAL_STACK_SA4 = ("192.168.1.100", 3232)
@pytest.fixture
def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock:
"""Make resolve_ip_address return an IPv6 and an IPv4 address."""
mock_resolve_ip.return_value = [
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6),
(socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4),
]
return mock_resolve_ip
@pytest.fixture
def firmware_file(tmp_path: Path) -> Path:
"""Create a firmware file on disk for run_ota_impl_ tests."""
firmware = tmp_path / "firmware.bin"
firmware.write_bytes(b"firmware content")
return firmware
@pytest.fixture
def mock_perform_ota() -> Generator[Mock]:
"""Mock perform_ota function for testing."""
@@ -137,9 +163,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None:
with pytest.raises(
espota2.OTAError, match="receiving auth:.*Authentication invalid"
):
) as exc_info:
espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK])
# Device-reported errors must stay plain OTAError, not the retryable kind
assert not isinstance(exc_info.value, espota2.OTANetworkError)
mock_socket.close.assert_called_once()
@@ -147,10 +175,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None:
"""Test receive_exactly handles socket errors."""
mock_socket.recv.side_effect = OSError("Connection reset")
with pytest.raises(espota2.OTAError, match="receiving test response"):
with pytest.raises(espota2.OTANetworkError, match="receiving test response"):
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None:
"""Test receive_exactly handles socket errors after the first byte."""
mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")]
with pytest.raises(espota2.OTANetworkError, match="receiving test:"):
espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK)
def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None:
"""Test receive_exactly raises OTANetworkError when the device closes the connection."""
mock_socket.recv.return_value = b""
with pytest.raises(
espota2.OTANetworkError, match="Device closed connection without responding"
):
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
mock_socket.close.assert_called_once()
@pytest.mark.parametrize(
("error_code", "expected_msg"),
[
@@ -227,15 +275,15 @@ def test_check_error_unexpected_response() -> None:
def test_check_error_empty_data() -> None:
"""Test check_error raises error when device closes connection without responding."""
"""Test check_error raises the retryable OTANetworkError when the device closes the connection."""
with pytest.raises(
espota2.OTAError, match="Device closed connection without responding"
espota2.OTANetworkError, match="Device closed connection without responding"
):
espota2.check_error([], [espota2.RESPONSE_OK])
# Also test with empty bytes
with pytest.raises(
espota2.OTAError, match="Device closed connection without responding"
espota2.OTANetworkError, match="Device closed connection without responding"
):
espota2.check_error(b"", [espota2.RESPONSE_OK])
@@ -530,6 +578,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
def _no_auth_handshake(version: int) -> list[bytes]:
"""Recv responses for a handshake without auth, up to the MD5 check."""
return [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([version]), # Version number
bytes([espota2.RESPONSE_HEADER_OK]), # Features response
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
]
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None:
"""Test OTA raises the retryable OTANetworkError when sending a chunk fails."""
mock_socket.recv.side_effect = [
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
OSError("Connection reset"), # Probe for a pending error byte fails too
]
# Sends before the data phase: magic bytes, features, binary size, MD5;
# fail on the fifth sendall, the first firmware chunk
mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")]
with pytest.raises(espota2.OTANetworkError, match="sending data:"):
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_chunk_send_error_surfaces_device_error(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test a device error byte pending behind a send failure becomes the cause."""
mock_socket.recv.side_effect = [
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed
]
mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")]
with pytest.raises(
espota2.OTAError, match="Writing OTA data to flash memory failed"
) as exc:
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# The device-reported error is not retryable
assert not isinstance(exc.value, espota2.OTANetworkError)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_final_chunk_ack_failure_not_retryable(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test a lost ack for the final chunk is not retried."""
mock_socket.recv.side_effect = [
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
OSError("Connection reset"), # Ack for the only (final) chunk is lost
]
with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc:
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# The device already had the whole image, so it may be committing
assert not isinstance(exc.value, espota2.OTANetworkError)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_intermediate_chunk_ack_failure_retryable(
mock_socket: Mock,
) -> None:
"""Test a lost ack for a non-final chunk stays retryable."""
# Two chunks: the firmware is larger than one upload block
big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1))
mock_socket.recv.side_effect = [
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
OSError("Connection reset"), # Ack for the first of two chunks is lost
]
with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"):
espota2.perform_ota(mock_socket, None, big_file, "test.bin")
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_post_commit_failure_not_retryable(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test a network failure after the device committed is a plain OTAError."""
mock_socket.recv.side_effect = [
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
OSError("Connection reset"), # Connection lost waiting for end result
]
with pytest.raises(espota2.OTAError, match="receiving update end result") as exc:
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# Must not be the retryable kind; the device is already rebooting
assert not isinstance(exc.value, espota2.OTANetworkError)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_md5_mismatch_not_marked_committed(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test an MD5 mismatch keeps its own message and stays non-retryable."""
mock_socket.recv.side_effect = [
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update
]
with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc:
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# The device aborted without committing, so the message must not claim
# the update may have been installed, and the error must not be retried
assert not isinstance(exc.value, espota2.OTANetworkError)
assert "committed" not in str(exc.value)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_end_ack_send_failure_is_success(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test a send failure on the final acknowledgement does not fail the OTA."""
mock_socket.recv.side_effect = [
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed
]
# Sends: magic bytes, features, binary size, MD5, one firmware chunk;
# fail on the sixth sendall, the end acknowledgement
mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")]
# Must not raise; the device treats a missing acknowledgement as non-fatal
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
assert mock_socket.sendall.call_count == 6
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
def test_run_ota_impl_successful(
mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock
@@ -564,21 +750,183 @@ def test_run_ota_impl_successful(
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None:
"""Test run_ota_impl_ when connection fails."""
def test_run_ota_impl_connection_failed(
mock_socket: Mock, firmware_file: Path, mock_sleep: Mock
) -> None:
"""Test run_ota_impl_ retries when connection fails and eventually gives up."""
mock_socket.connect.side_effect = OSError("Connection refused")
# Create a real firmware file
firmware_file = tmp_path / "firmware.bin"
firmware_file.write_bytes(b"firmware content")
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
assert result_code == 1
assert result_host is None
mock_socket.close.assert_called_once()
# A single address gets the whole attempt budget, with a delay before
# each revisit
assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS
mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY)
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
def test_run_ota_impl_connect_retry_succeeds(
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
) -> None:
"""Test run_ota_impl_ succeeds when a retry connects after a failed attempt."""
mock_socket.connect.side_effect = [OSError("Connection timed out"), None]
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
assert result_code == 0
assert result_host == "192.168.1.100"
assert mock_socket.connect.call_count == 2
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
mock_perform_ota.assert_called_once()
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
def test_run_ota_impl_network_error_retry_succeeds(
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
) -> None:
"""Test run_ota_impl_ retries after a network error during the upload."""
mock_perform_ota.side_effect = [
espota2.OTANetworkError("receiving features: Device closed connection"),
None,
]
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
assert result_code == 0
assert result_host == "192.168.1.100"
assert mock_perform_ota.call_count == 2
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
def test_run_ota_impl_network_error_exhausts_attempts(
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
) -> None:
"""Test run_ota_impl_ gives up after all attempts hit network errors."""
mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe")
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
assert result_code == 1
assert result_host is None
assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
def test_run_ota_impl_multiple_addresses_cycle(
mock_socket: Mock, firmware_file: Path, mock_sleep: Mock
) -> None:
"""Test run_ota_impl_ visits every address and cycles for the retries."""
mock_socket.connect.side_effect = OSError("No route to host")
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
assert result_code == 1
assert result_host is None
# Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare
# attempts cycle back through them; the budget is shared, not per address
assert mock_socket.connect.call_args_list == [
call(DUAL_STACK_SA6),
call(DUAL_STACK_SA4),
call(DUAL_STACK_SA6),
call(DUAL_STACK_SA4),
]
# No connect ever reached the device, so the delay only applies before
# the revisits
assert mock_sleep.call_count == 2
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
def test_run_ota_impl_second_address_succeeds_without_delay(
mock_socket: Mock,
firmware_file: Path,
mock_perform_ota: Mock,
mock_sleep: Mock,
) -> None:
"""Test run_ota_impl_ falls through to the next address with no pause."""
mock_socket.connect.side_effect = [OSError("No route to host"), None]
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
assert result_code == 0
assert result_host == "192.168.1.100"
mock_sleep.assert_not_called()
mock_perform_ota.assert_called_once()
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
def test_run_ota_impl_pauses_after_reaching_device(
mock_socket: Mock,
firmware_file: Path,
mock_perform_ota: Mock,
mock_sleep: Mock,
) -> None:
"""Test run_ota_impl_ pauses before the next address once the device was reached."""
mock_perform_ota.side_effect = [
espota2.OTANetworkError("sending data: connection reset"),
None,
]
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
assert result_code == 0
assert result_host == "192.168.1.100"
# The first attempt reached the device, so the next one waits first even
# though it targets a fresh address
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
def test_run_ota_impl_device_error_not_retried(
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
) -> None:
"""Test run_ota_impl_ fails immediately on a device-reported error."""
mock_perform_ota.side_effect = espota2.OTAError(
"Authentication invalid. Is the password correct?"
)
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
assert result_code == 1
assert result_host is None
mock_perform_ota.assert_called_once()
mock_sleep.assert_not_called()
def test_run_ota_impl_no_addresses(
firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock
) -> None:
"""Test run_ota_impl_ fails cleanly when resolution yields no addresses."""
mock_resolve_ip.return_value = []
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
assert result_code == 1
assert result_host is None
mock_sleep.assert_not_called()
def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None:
@@ -188,6 +188,24 @@ def test_run_command_passes_env(mock_subprocess_run: Mock) -> None:
assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42"
def test_run_command_pops_inherited_pythonpath(mock_subprocess_run: Mock) -> None:
"""A PYTHONPATH from the parent environment must not leak into subprocesses."""
mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="")
with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}):
run_command(["cmd"])
assert "PYTHONPATH" not in mock_subprocess_run.call_args[1]["env"]
def test_run_command_env_pythonpath_preferred_over_pop(
mock_subprocess_run: Mock,
) -> None:
"""A PYTHONPATH set explicitly via ``env`` is passed through."""
mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="")
with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}):
run_command(["cmd"], env={"PYTHONPATH": "/idf/tools"})
assert mock_subprocess_run.call_args[1]["env"]["PYTHONPATH"] == "/idf/tools"
def test_run_command_passes_cwd(mock_subprocess_run: Mock, tmp_path: Path) -> None:
mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="")
run_command(["cmd"], cwd=str(tmp_path))
+28
View File
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.component_aliases import COMPONENT_ALIASES
from esphome.loader import (
AliasMeta,
ComponentManifest,
@@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None:
assert meta["rp2040"].removal_version == "2027.7.0"
def test_alias_registry_matches_component_tree() -> None:
"""The checked-in registry must match a live scan of the component tree."""
_, meta_map = _build_alias_map()
expected = {
alias: (meta.canonical, meta.removal_version)
for alias, meta in meta_map.items()
}
assert expected == COMPONENT_ALIASES, (
"esphome/component_aliases.py is out of date; "
"run script/build_alias_registry.py"
)
def test_alias_map_built_from_registry() -> None:
"""The runtime alias map comes from the generated registry, not a scan."""
with (
patch(
"esphome.component_aliases.COMPONENT_ALIASES",
{"legacy": ("modern", "2099.1.0")},
),
patch("esphome.loader._ALIAS_META_CACHE", None),
):
assert get_alias_metadata() == {
"legacy": AliasMeta(canonical="modern", removal_version="2099.1.0")
}
def test_get_component_resolves_alias() -> None:
"""``get_component('rp2040')`` should return the rp2 manifest — every
caller of the loader (dep checker, schema validator, codegen) hits
+47 -1
View File
@@ -9,6 +9,7 @@ import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import threading
from types import SimpleNamespace
@@ -431,6 +432,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None:
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
env = toolchain._ccache_env()
@@ -457,6 +459,44 @@ def test_ccache_env_disabled_without_binary(setup_core: Path) -> None:
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
@pytest.mark.parametrize(
"probe_error",
[
pytest.param(OSError("not runnable"), id="oserror"),
pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"),
pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"),
],
)
def test_ccache_env_disabled_when_probe_fails(
setup_core: Path, probe_error: Exception
) -> None:
"""A ccache that resolves on PATH but fails to run stays disabled."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run", side_effect=probe_error),
):
env = toolchain._ccache_env()
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None:
"""An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
patch.object(toolchain.subprocess, "run") as mock_probe,
):
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
mock_probe.assert_not_called()
def test_ccache_env_opt_out(setup_core: Path) -> None:
"""ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present."""
CORE.build_path = setup_core / "build" / "test"
@@ -496,6 +536,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir(
with (
patch.dict(os.environ, user_env, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
env = toolchain._ccache_env()
@@ -514,6 +555,7 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only(
with (
patch.dict(os.environ, {}, clear=False),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
mock_run_external_process.return_value = 0
@@ -533,6 +575,7 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None:
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
pytest.raises(ValueError, match="CORE.build_path must be set"),
):
toolchain._ccache_env()
@@ -544,7 +587,10 @@ def test_run_platformio_cli_merges_caller_env(
"""A caller-supplied env is the base and gains the ccache settings."""
CORE.build_path = str(setup_core / "build" / "test")
with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"):
with (
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli(
"test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"}
@@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on
firmware upgrades, or break entity state routing to API clients.
Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85):
1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1).
Existing devices have preferences stored under keys derived from it; slot-based
backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it.
2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes).
Sent to API clients and used as the preference key base on key-lookup backends.
1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1).
The entity key sent to API clients and the base of every stored preference key.
2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta
firmware stored preferences under keys derived from it; a future key migration
must reconstruct those keys to recover that data.
DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm,
the change breaks backward compatibility and will cause data loss.
@@ -124,8 +124,9 @@ def test_entity_object_id_hash_stability(
"""Verify fnv1_hash_object_id produces stable hashes for entity names.
CRITICAL: These expected values MUST NOT CHANGE. Existing devices have
preferences stored under keys derived from this legacy hash; changing it
breaks the old-to-new key migration and loses stored preferences.
preferences stored under keys derived from this hash, and it is the entity
key sent to API clients; changing it loses stored preferences and breaks
entity state routing.
"""
actual = fnv1_hash_object_id(entity_name)
assert actual == expected_object_id_hash, (
@@ -144,9 +145,8 @@ def compute_legacy_preference_key(
) -> int:
"""Compute the legacy preference key: (object_id_hash ^ device_id) ^ version.
This is the key existing devices have data stored under. Slot-based backends
(ESP8266, RP2040) still use it directly; key-lookup backends compute it as the
migration source in EntityBase::make_entity_preference_() (entity_base.cpp).
This is the key EntityBase::make_entity_preference_() (entity_base.cpp)
stores every entity preference under.
"""
object_id_hash = fnv1_hash_object_id(entity_name)
preference_hash = object_id_hash ^ device_id
@@ -179,8 +179,8 @@ def test_legacy_preference_key_computation(
) -> None:
"""Verify legacy preference key computation matches expected values.
This test ensures the formula doesn't change, which would break both slot-based
preference storage and the migration source keys on key-lookup backends.
This test ensures the formula doesn't change, which would lose stored
preferences on every platform.
"""
actual_key = compute_legacy_preference_key(entity_name, version, device_id)
@@ -215,12 +215,12 @@ def test_legacy_preference_key_computation(
],
)
def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None:
"""Verify fnv1_hash_name produces stable entity keys.
"""Verify fnv1_hash_name produces stable raw-name hashes.
CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to
API clients and is the new preference key base; changing the algorithm
would break state routing and lose stored preferences.
Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h.
CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored
preferences under keys derived from this hash; a future key migration must
reconstruct those keys, and changing the algorithm would strand that data.
Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores.
"""
actual = fnv1_hash_name(entity_name)
assert actual == expected_key, (
+99
View File
@@ -915,3 +915,102 @@ def test_storage_json_load_area(tmp_path: Path) -> None:
legacy = storage_json.StorageJSON.load(legacy_path)
assert legacy is not None
assert legacy.area is None
def test_from_esphome_core_without_claiming_a_build(setup_core: Path) -> None:
"""claim_build=False carries the build artifact fields from the old
sidecar while validation-derived fields still stamp from CORE."""
mock_core = MagicMock()
mock_core.name = "my_device"
mock_core.friendly_name = "My Device"
mock_core.comment = None
mock_core.address = "my_device.local"
mock_core.web_port = None
mock_core.target_platform = "esp8266"
mock_core.is_esp32 = False
mock_core.is_nrf52 = False
mock_core.build_path = "/build/my_device"
mock_core.loaded_integrations = set()
mock_core.loaded_platforms = set()
mock_core.config = {}
mock_core.target_framework = "arduino"
mock_core.toolchain = Toolchain.PLATFORMIO
mock_core.area = None
old = storage_json.StorageJSON.from_wizard(
name="my_device",
friendly_name="My Device",
address="my_device.local",
platform="ESP8266",
)
old.esphome_version = "2025.1.0"
old.firmware_bin_path = Path("/old/firmware.bin")
result = storage_json.StorageJSON.from_esphome_core(
mock_core, old, claim_build=False
)
# Build artifact fields carry from the old sidecar, not this run.
assert result.esphome_version == "2025.1.0"
assert result.firmware_bin_path == Path("/old/firmware.bin")
# Validation-derived fields stamp from CORE.
assert result.build_path == "/build/my_device"
assert result.toolchain == "platformio"
assert result.core_platform == "esp8266"
# With no old sidecar, no build is claimed at all.
bare = storage_json.StorageJSON.from_esphome_core(
mock_core, None, claim_build=False
)
assert bare.esphome_version is None
assert bare.firmware_bin_path is None
def test_load_strict_distinguishes_missing_from_unreadable(tmp_path: Path) -> None:
"""load_strict returns None only for a missing file; corrupt raises."""
assert storage_json.StorageJSON.load_strict(tmp_path / "missing.json") is None
corrupt = tmp_path / "corrupt.json"
corrupt.write_text("{truncated")
with pytest.raises(ValueError):
storage_json.StorageJSON.load_strict(corrupt)
def test_as_dict_serializes_unset_paths_as_null(setup_core: Path) -> None:
"""Unset build/firmware paths serialize as JSON null, not str(None)."""
storage = storage_json.StorageJSON.from_wizard(
name="wiz",
friendly_name="Wiz",
address="wiz.local",
platform="ESP32",
)
result = storage.as_dict()
assert result["build_path"] is None
assert result["firmware_bin_path"] is None
def test_load_treats_legacy_none_string_paths_as_unset(tmp_path: Path) -> None:
"""Sidecars written before as_dict emitted null hold str(None); those
must load as unset, not as Path("None")."""
file_path = tmp_path / "legacy_none.json"
file_path.write_text(
json.dumps(
{
"storage_version": 1,
"name": "wiz",
"friendly_name": "Wiz",
"esp_platform": "ESP32",
"core_platform": "esp32",
"build_path": "None",
"firmware_bin_path": "None",
}
)
)
result = storage_json.StorageJSON.load(file_path)
assert result is not None
assert result.build_path is None
assert result.firmware_bin_path is None