mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 14:46:20 +00:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13704a148e | ||
|
|
ebb0923362 | ||
|
|
e1c279718f | ||
|
|
cf764740cf | ||
|
|
58d549ed4c | ||
|
|
6b22d8068d | ||
|
|
3f01f9895f | ||
|
|
801a1817b5 | ||
|
|
32c76ae828 | ||
|
|
c664f5fc95 | ||
|
|
2bc4681fd6 | ||
|
|
646501b0ef | ||
|
|
de3e657d8b | ||
|
|
1add726892 | ||
|
|
5a000cf5e4 | ||
|
|
6ed676fe32 | ||
|
|
7cceddb8a3 | ||
|
|
039b897e7b | ||
|
|
b178f74e5d | ||
|
|
e5224e22ae | ||
|
|
be66e8b99c | ||
|
|
4b91c8aff4 | ||
|
|
617e2ec1e0 | ||
|
|
1afac0312d | ||
|
|
b05465145f | ||
|
|
990fc402fd | ||
|
|
dd51624fbb | ||
|
|
45a056e337 | ||
|
|
37782f7206 | ||
|
|
945c2458b3 | ||
|
|
7420d23867 | ||
|
|
191686c5b3 | ||
|
|
137351fa8d | ||
|
|
db5173697a | ||
|
|
c7940382a9 | ||
|
|
87045ab9c0 | ||
|
|
f1c4086778 | ||
|
|
f337d0acff | ||
|
|
9cc05b30d4 | ||
|
|
e0b68c4d6d | ||
|
|
8e624b4117 | ||
|
|
787a909aa4 | ||
|
|
905485b673 | ||
|
|
99677390e0 | ||
|
|
f2121130f9 | ||
|
|
1a01c34ec4 | ||
|
|
3c46cc9c35 | ||
|
|
e192ec8fee | ||
|
|
8a1aa5753d |
@@ -70,6 +70,7 @@ async function isStackedPr(github, context) {
|
||||
async function detectMergeBranch(github, context) {
|
||||
const labels = new Set();
|
||||
const baseRef = context.payload.pull_request.base.ref;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
|
||||
if (baseRef === 'release') {
|
||||
labels.add('merging-to-release');
|
||||
@@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) {
|
||||
} else if (await isStackedPr(github, context)) {
|
||||
// GitHub manages the merge order for a stack, so these are not blocked.
|
||||
labels.add('stacked-pr');
|
||||
} else if (baseRef !== 'dev') {
|
||||
} else if (baseRef !== defaultBranch) {
|
||||
// A chain built by hand: it must not merge until its base branch does.
|
||||
labels.add('chained-pr');
|
||||
}
|
||||
|
||||
@@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
|
||||
|
||||
// Builds a fresh context for detectMergeBranch tests instead of mutating the
|
||||
// shared CONTEXT fixture above (which other describe blocks rely on).
|
||||
function makeMergeContext(baseRef, { stack } = {}) {
|
||||
function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) {
|
||||
const pull_request = { number: 1, base: { ref: baseRef } };
|
||||
if (stack !== undefined) {
|
||||
pull_request.stack = stack;
|
||||
}
|
||||
return {
|
||||
repo: { owner: 'esphome', repo: 'esphome' },
|
||||
payload: { pull_request }
|
||||
payload: { pull_request, repository: { default_branch: defaultBranch } }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +136,21 @@ describe('detectMergeBranch', () => {
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
assert.equal(state.calls, 1);
|
||||
});
|
||||
|
||||
it('base ref matches default branch adds no labels', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('other', { defaultBranch: 'other' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), []);
|
||||
});
|
||||
|
||||
it('base ref dev when the default branch is main adds chained-pr', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('dev', { defaultBranch: 'main' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -445,8 +445,12 @@ jobs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: >-
|
||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
github.repository == 'esphome/esphome' && (
|
||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
)
|
||||
# CodSpeed benchmarks require a CodSpeed account linked to the repository to run
|
||||
# (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself.
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.8.0b4
|
||||
PROJECT_NUMBER = 2026.9.0-dev
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+14
-14
@@ -2732,7 +2732,8 @@ def run_esphome(argv):
|
||||
conf_path.name,
|
||||
)
|
||||
|
||||
if config is None:
|
||||
cache_missed = config is None
|
||||
if cache_missed:
|
||||
from esphome.config import read_config
|
||||
|
||||
config = read_config(
|
||||
@@ -2741,26 +2742,25 @@ def run_esphome(argv):
|
||||
# Snapshot only needed by `esphome config --no-defaults`.
|
||||
snapshot_user_config=getattr(args, "no_defaults", False),
|
||||
)
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
# sidecar is absent (no compile has run): the cache would
|
||||
# never be loaded back, so writing secrets to disk is wasted.
|
||||
if cache_eligible and config is not None:
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.storage_json import ext_storage_path
|
||||
|
||||
if ext_storage_path(conf_path.name).exists():
|
||||
save_compiled_config(config)
|
||||
if config is None:
|
||||
return 2
|
||||
if config is None:
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
# Fallback for platforms whose validators didn't set the toolchain
|
||||
# (only the esp32 component reads esp32.framework.toolchain). All
|
||||
# other platforms only support PlatformIO today.
|
||||
# other platforms only support PlatformIO today. Must run before the
|
||||
# cache refresh below so its sidecar records the same toolchain a
|
||||
# compile would.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config.
|
||||
if cache_eligible and cache_missed:
|
||||
from esphome.compiled_config import save_compiled_config_and_sidecar
|
||||
|
||||
save_compiled_config_and_sidecar(config)
|
||||
|
||||
if args.command not in POST_CONFIG_ACTIONS:
|
||||
safe_print(f"Unknown command {args.command}")
|
||||
return 1
|
||||
|
||||
@@ -18,9 +18,9 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from esphome.const import __version__ as ESPHOME_VERSION
|
||||
from esphome.core import CORE, Lambda
|
||||
from esphome.core import CORE, EsphomeError, Lambda
|
||||
from esphome.helpers import write_file
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path, storage_path
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -65,7 +65,71 @@ def save_compiled_config(config: ConfigType) -> None:
|
||||
# non-basic dict key), so every upload/logs pays the slow path.
|
||||
_LOGGER.warning("Cannot cache the validated config: %s", err)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
||||
# Likely persistent (permissions, full disk): every upload/logs
|
||||
# pays the slow path until it clears, so surface it.
|
||||
_LOGGER.warning("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def save_compiled_config_and_sidecar(config: ConfigType) -> None:
|
||||
"""Refresh the cache from the upload/logs fallback (CORE.config must be set).
|
||||
|
||||
The cache is only written when a complete sidecar is on disk:
|
||||
load_compiled_config can't use it otherwise, and it holds resolved
|
||||
secrets.
|
||||
"""
|
||||
if _refresh_sidecar():
|
||||
save_compiled_config(config)
|
||||
|
||||
|
||||
def _refresh_sidecar() -> bool:
|
||||
"""Ensure a complete sidecar is on disk; True when one is.
|
||||
|
||||
Writes one (without claiming a build) when missing or wizard-only.
|
||||
Failures are non-fatal; the next upload/logs pays the slow path again.
|
||||
"""
|
||||
try:
|
||||
path = storage_path()
|
||||
try:
|
||||
old = StorageJSON.load_strict(path)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Present but unreadable: it may hold a real build's metadata,
|
||||
# and a fresh rewrite would also stop the next compile from
|
||||
# cleaning a possibly incoherent build tree.
|
||||
_LOGGER.warning(
|
||||
"Not caching: storage sidecar %s is unreadable (%s)", path, err
|
||||
)
|
||||
return False
|
||||
if old is not None and old.can_apply_to_core():
|
||||
# Compile-written; nothing to refresh.
|
||||
return True
|
||||
if CORE.build_path is not None and CORE.build_path.exists():
|
||||
# An unvalidated build tree: its absent or mismatched sidecar
|
||||
# is what makes the next compile wipe it, so don't vouch for
|
||||
# a build this run never saw.
|
||||
_LOGGER.warning(
|
||||
"Not caching: build tree %s has no matching sidecar; "
|
||||
"'esphome compile' will settle it",
|
||||
CORE.build_path,
|
||||
)
|
||||
return False
|
||||
new = StorageJSON.from_esphome_core(CORE, old, claim_build=False)
|
||||
if not new.can_apply_to_core():
|
||||
_LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete")
|
||||
return False
|
||||
new.save(path)
|
||||
return True
|
||||
except (OSError, EsphomeError) as err:
|
||||
# write_file wraps OSError into EsphomeError. Persistent
|
||||
# (unwritable storage dir), so surface that every upload/logs
|
||||
# pays the slow path.
|
||||
_LOGGER.warning("Could not refresh the storage sidecar: %s", err)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# A structural bug; keep the traceback so it isn't mistaken
|
||||
# for the I/O failure above.
|
||||
_LOGGER.warning(
|
||||
"Unexpected error refreshing the storage sidecar", exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
@@ -98,11 +162,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
if storage is None:
|
||||
return None
|
||||
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
|
||||
# sidecars leave both of these unset and can't drive upload/logs.
|
||||
if not storage.core_platform and not storage.target_platform:
|
||||
if storage is None or not storage.can_apply_to_core():
|
||||
_LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete")
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
|
||||
@@ -160,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
|
||||
#else
|
||||
#error "No frame helper defined"
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
if (camera::Camera::instance() != nullptr) {
|
||||
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIConnection::start() {
|
||||
@@ -1140,6 +1135,7 @@ void APIConnection::try_send_camera_image_() {
|
||||
if (!this->image_reader_)
|
||||
return;
|
||||
|
||||
const auto *cam = camera::Camera::instance();
|
||||
// Send as many chunks as possible without blocking
|
||||
while (this->image_reader_->available()) {
|
||||
if (!this->helper_->can_write_without_blocking())
|
||||
@@ -1149,11 +1145,11 @@ void APIConnection::try_send_camera_image_() {
|
||||
bool done = this->image_reader_->available() == to_send;
|
||||
|
||||
CameraImageResponse msg;
|
||||
msg.key = camera::Camera::instance()->get_object_id_hash();
|
||||
msg.key = cam->get_object_id_hash();
|
||||
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
|
||||
msg.done = done;
|
||||
#ifdef USE_DEVICES
|
||||
msg.device_id = camera::Camera::instance()->get_device_id();
|
||||
msg.device_id = cam->get_device_id();
|
||||
#endif
|
||||
|
||||
if (!this->send_message(msg)) {
|
||||
@@ -1169,15 +1165,19 @@ void APIConnection::try_send_camera_image_() {
|
||||
void APIConnection::set_camera_state(std::shared_ptr<camera::CameraImage> image) {
|
||||
if (!this->flags_.state_subscription)
|
||||
return;
|
||||
if (!this->image_reader_)
|
||||
if (this->image_reader_ && this->image_reader_->available())
|
||||
return;
|
||||
if (this->image_reader_->available())
|
||||
if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE))
|
||||
return;
|
||||
if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) {
|
||||
this->image_reader_->set_image(std::move(image));
|
||||
// Try to send immediately to reduce latency
|
||||
this->try_send_camera_image_();
|
||||
if (!this->image_reader_) {
|
||||
// Created on the first image this connection will send, so connections
|
||||
// that never receive one never pay for a reader. Only a registered
|
||||
// camera's listener can reach this, so instance() is non-null here.
|
||||
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
|
||||
}
|
||||
this->image_reader_->set_image(std::move(image));
|
||||
// Try to send immediately to reduce latency
|
||||
this->try_send_camera_image_();
|
||||
}
|
||||
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
|
||||
auto *camera = static_cast<camera::Camera *>(entity);
|
||||
|
||||
@@ -591,18 +591,21 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
||||
*/
|
||||
APIError APINoiseFrameHelper::init_handshake_() {
|
||||
int err;
|
||||
memset(&nid_, 0, sizeof(nid_));
|
||||
// const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
|
||||
// err = noise_protocol_name_to_id(&nid_, proto, strlen(proto));
|
||||
nid_.pattern_id = NOISE_PATTERN_NN;
|
||||
nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY;
|
||||
nid_.dh_id = NOISE_DH_CURVE25519;
|
||||
nid_.prefix_id = NOISE_PREFIX_STANDARD;
|
||||
nid_.hybrid_id = NOISE_DH_NONE;
|
||||
nid_.hash_id = NOISE_HASH_SHA256;
|
||||
nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0;
|
||||
// Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack:
|
||||
// noise_handshakestate_new_by_id copies it, so a member would waste
|
||||
// 104 bytes per connection, and a static const would sit in RAM on
|
||||
// ESP8266 (.rodata is DRAM there).
|
||||
const NoiseProtocolId nid = {
|
||||
.prefix_id = NOISE_PREFIX_STANDARD,
|
||||
.pattern_id = NOISE_PATTERN_NN,
|
||||
.modifier_ids = {NOISE_MODIFIER_PSK0},
|
||||
.dh_id = NOISE_DH_CURVE25519,
|
||||
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
|
||||
.hash_id = NOISE_HASH_SHA256,
|
||||
.hybrid_id = NOISE_DH_NONE,
|
||||
};
|
||||
|
||||
err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER);
|
||||
err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER);
|
||||
APIError aerr =
|
||||
handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||
if (aerr != APIError::OK)
|
||||
|
||||
@@ -63,9 +63,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
// Buffer for noise handshake prologue (released after handshake)
|
||||
APIBuffer prologue_;
|
||||
|
||||
// NoiseProtocolId (size depends on implementation)
|
||||
NoiseProtocolId nid_;
|
||||
|
||||
// Group small types together
|
||||
// Fixed-size header buffer for noise protocol:
|
||||
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
|
||||
|
||||
@@ -103,7 +103,8 @@ struct CameraImageSpec {
|
||||
/** Abstract camera base class. Collaborates with API.
|
||||
* 1) API server starts and registers as a listener (add_listener)
|
||||
* to receive new images from the camera.
|
||||
* 2) New API client connects and creates a new image reader (create_image_reader).
|
||||
* 2) API connection creates an image reader (create_image_reader) when it receives
|
||||
* the first image it will send.
|
||||
* 3) API connection receives protobuf CameraImageRequest and calls request_image.
|
||||
* 3.a) API connection receives protobuf CameraImageRequest and calls start_stream.
|
||||
* 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners.
|
||||
|
||||
@@ -22,6 +22,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr"
|
||||
CONF_GYROSCOPE_RANGE = "gyroscope_range"
|
||||
CONF_IAQ = "iaq"
|
||||
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
|
||||
CONF_LABEL = "label"
|
||||
CONF_LIBRETINY = "libretiny"
|
||||
CONF_LOOP = "loop"
|
||||
CONF_NOX_INDEX = "nox_index"
|
||||
@@ -35,6 +36,7 @@ CONF_REQUEST_HEADERS = "request_headers"
|
||||
CONF_ROWS = "rows"
|
||||
CONF_SCAN_PARAMETERS = "scan_parameters"
|
||||
CONF_SHA256 = "sha256"
|
||||
CONF_SLOT = "slot"
|
||||
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
|
||||
CONF_STOP_BITS = "stop_bits"
|
||||
CONF_TARGET_COUNT = "target_count"
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from esphome import automation, core
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LABEL
|
||||
from esphome.components.number import Number
|
||||
from esphome.components.select import Select
|
||||
from esphome.components.switch import Switch
|
||||
@@ -30,7 +31,6 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base")
|
||||
|
||||
CONF_ROTARY = "rotary"
|
||||
CONF_JOYSTICK = "joystick"
|
||||
CONF_LABEL = "label"
|
||||
CONF_MENU = "menu"
|
||||
CONF_BACK = "back"
|
||||
CONF_SELECT = "select"
|
||||
|
||||
@@ -570,6 +570,9 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "Factory format (Previously Modern)",
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
from esphome import pins
|
||||
from esphome.components import esp32
|
||||
from esphome.components.const import CONF_USE_PSRAM
|
||||
from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CLK_PIN,
|
||||
@@ -16,8 +16,10 @@ from esphome.const import (
|
||||
CONF_VARIANT,
|
||||
)
|
||||
from esphome.cpp_generator import add_define
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@swoboda1337"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
# esp32_ble raises the task watchdog around the remote BT controller bring-up
|
||||
AUTO_LOAD = ["watchdog"]
|
||||
|
||||
@@ -33,7 +35,6 @@ CONF_DATA_READY_PIN = "data_ready_pin"
|
||||
CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high"
|
||||
CONF_HANDSHAKE_PIN = "handshake_pin"
|
||||
CONF_SDIO_FREQUENCY = "sdio_frequency"
|
||||
CONF_SLOT = "slot"
|
||||
CONF_SPI_MODE = "spi_mode"
|
||||
|
||||
# Shared fields for both transport modes
|
||||
@@ -125,6 +126,22 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
# The esp_hosted releases compatible with older ESP-IDF versions crash at
|
||||
# boot with a heap double free in the SDIO RX path (fixed in esp_hosted
|
||||
# 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time.
|
||||
if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0):
|
||||
raise cv.Invalid(
|
||||
f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. "
|
||||
"Remove the framework version from your configuration to use the "
|
||||
"recommended version, or pin a version at or above 5.3."
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def _configure_sdio(config):
|
||||
slot = config[CONF_SLOT]
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
@@ -252,18 +269,14 @@ async def to_code(config):
|
||||
if config[CONF_USE_PSRAM]:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
|
||||
|
||||
# Library versions
|
||||
# Library versions; this component set requires ESP-IDF 5.3 or newer,
|
||||
# which is enforced at validation time.
|
||||
idf_ver = esp32.idf_version()
|
||||
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
|
||||
if idf_ver >= cv.Version(5, 5, 0):
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
|
||||
else:
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11")
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
|
||||
esp32.add_extra_script(
|
||||
"post",
|
||||
"esp32_hosted.py",
|
||||
|
||||
@@ -113,6 +113,9 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "Standard format",
|
||||
|
||||
@@ -355,7 +355,7 @@ def _validate(config):
|
||||
" clk:\n"
|
||||
" mode: %s\n"
|
||||
" pin: %s\n"
|
||||
"Removal scheduled for 2026.9.0.",
|
||||
"Removal scheduled for 2026.11.0.",
|
||||
config[CONF_CLK_MODE],
|
||||
mode,
|
||||
pin,
|
||||
|
||||
@@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) {
|
||||
return result;
|
||||
}
|
||||
|
||||
float LD2420Component::get_setup_priority() const { return setup_priority::BUS; }
|
||||
|
||||
void LD2420Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"LD2420:\n"
|
||||
|
||||
@@ -105,7 +105,6 @@ class LD2420Component final : public Component, public uart::UARTDevice {
|
||||
void apply_config_action();
|
||||
void factory_reset_action();
|
||||
void revert_config_action();
|
||||
float get_setup_priority() const override;
|
||||
int send_cmd_from_array(CmdFrameT cmd_frame);
|
||||
void report_gate_data();
|
||||
void handle_cmd_error(uint16_t error);
|
||||
|
||||
@@ -182,6 +182,9 @@ def get_download_types(storage_json: StorageJSON = None):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
types = [
|
||||
{
|
||||
"title": "UF2 package (recommended)",
|
||||
|
||||
@@ -175,7 +175,7 @@ template<size_t DATA_SIZE, bool AUTO_START = false> class LvAnimation : public C
|
||||
this->update_callback_(data);
|
||||
}
|
||||
|
||||
float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0; }
|
||||
float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0f; }
|
||||
void set_duration(uint32_t duration) { this->duration_ = duration; }
|
||||
void set_start_delay(uint32_t start_delay) { this->start_delay_ = start_delay; }
|
||||
void add_timing(LvAnimationTiming *timing) { this->timings_.push_back(timing); }
|
||||
|
||||
@@ -444,7 +444,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r
|
||||
lv_indev_set_type(this->drv_, LV_INDEV_TYPE_POINTER);
|
||||
lv_indev_set_disp(this->drv_, parent->get_disp());
|
||||
lv_indev_set_long_press_time(this->drv_, long_press_time);
|
||||
// long press repeat time TBD
|
||||
lv_indev_set_long_press_repeat_time(this->drv_, long_press_repeat_time);
|
||||
lv_indev_set_user_data(this->drv_, this);
|
||||
lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) {
|
||||
auto *l = static_cast<LVTouchListener *>(lv_indev_get_user_data(d));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from esphome.components.const import CONF_LABEL
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_TEXT
|
||||
|
||||
@@ -14,8 +15,6 @@ from ..schemas import TEXT_SCHEMA
|
||||
from ..types import LvText
|
||||
from . import Widget, WidgetType
|
||||
|
||||
CONF_LABEL = "label"
|
||||
|
||||
|
||||
class LabelType(WidgetType):
|
||||
def __init__(self):
|
||||
|
||||
@@ -473,6 +473,9 @@ def copy_files() -> None:
|
||||
|
||||
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
|
||||
"""Get the download types for the firmware."""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
types = []
|
||||
UF2_PATH = "zephyr/zephyr.uf2"
|
||||
DFU_PATH = "firmware.zip"
|
||||
|
||||
@@ -156,6 +156,9 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "UF2 factory format",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import runtime_image
|
||||
from esphome.components.const import CONF_SLOT
|
||||
from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -45,7 +46,6 @@ MAX_IMAGE_DIMENSION = 32767
|
||||
MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60)
|
||||
MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60)
|
||||
|
||||
CONF_SLOT = "slot"
|
||||
CONF_CURRENT_IMAGE = "current_image"
|
||||
CONF_TRANSITION_IMAGE = "transition_image"
|
||||
CONF_ON_IMAGE_DISPLAY = "on_image_display"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.8.0b4"
|
||||
__version__ = "2026.9.0-dev"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
+48
-8
@@ -71,8 +71,11 @@ def archive_storage_path() -> Path:
|
||||
|
||||
|
||||
def _to_path_if_not_none(value: str | None) -> Path | None:
|
||||
"""Convert a string to Path if it's not None."""
|
||||
return Path(value) if value is not None else None
|
||||
"""Convert a string to Path; None and the legacy "None" both map to None.
|
||||
|
||||
Sidecars written before as_dict skipped unset paths hold str(None).
|
||||
"""
|
||||
return Path(value) if value is not None and value != "None" else None
|
||||
|
||||
|
||||
def _parse_framework_version(framework_version: str) -> Version:
|
||||
@@ -170,8 +173,10 @@ class StorageJSON:
|
||||
"address": self.address,
|
||||
"web_port": self.web_port,
|
||||
"esp_platform": self.target_platform,
|
||||
"build_path": str(self.build_path),
|
||||
"firmware_bin_path": str(self.firmware_bin_path),
|
||||
"build_path": str(self.build_path) if self.build_path else None,
|
||||
"firmware_bin_path": (
|
||||
str(self.firmware_bin_path) if self.firmware_bin_path else None
|
||||
),
|
||||
"loaded_integrations": sorted(self.loaded_integrations),
|
||||
"loaded_platforms": sorted(self.loaded_platforms),
|
||||
"no_mdns": self.no_mdns,
|
||||
@@ -189,7 +194,18 @@ class StorageJSON:
|
||||
write_file_if_changed(path, self.to_json())
|
||||
|
||||
@staticmethod
|
||||
def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON:
|
||||
def from_esphome_core(
|
||||
esph: CoreType, old: StorageJSON | None, *, claim_build: bool = True
|
||||
) -> StorageJSON:
|
||||
"""Build a sidecar from post-validation CORE state.
|
||||
|
||||
claim_build=False (the upload/logs fallback, which runs no build)
|
||||
carries the build-artifact fields (esphome_version,
|
||||
firmware_bin_path) from *old* instead of asserting this run built
|
||||
firmware. Validation-derived fields (platform, framework_version,
|
||||
toolchain, build_path) always stamp; storage_should_clean compares
|
||||
them against the next compile.
|
||||
"""
|
||||
hardware = esph.target_platform.upper()
|
||||
framework_version: str | None = None
|
||||
if esph.is_esp32:
|
||||
@@ -204,13 +220,21 @@ class StorageJSON:
|
||||
name=esph.name,
|
||||
friendly_name=esph.friendly_name,
|
||||
comment=esph.comment,
|
||||
esphome_version=const.__version__,
|
||||
esphome_version=(
|
||||
const.__version__
|
||||
if claim_build
|
||||
else (old.esphome_version if old else None)
|
||||
),
|
||||
src_version=1,
|
||||
address=esph.address,
|
||||
web_port=esph.web_port,
|
||||
target_platform=hardware,
|
||||
build_path=esph.build_path,
|
||||
firmware_bin_path=esph.firmware_bin,
|
||||
firmware_bin_path=(
|
||||
esph.firmware_bin
|
||||
if claim_build
|
||||
else (old.firmware_bin_path if old else None)
|
||||
),
|
||||
loaded_integrations=esph.loaded_integrations,
|
||||
loaded_platforms=esph.loaded_platforms,
|
||||
no_mdns=(
|
||||
@@ -302,11 +326,27 @@ class StorageJSON:
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def load_strict(path: Path) -> StorageJSON | None:
|
||||
"""Like load, but None only means missing; an unreadable file raises."""
|
||||
if not path.is_file():
|
||||
return None
|
||||
return StorageJSON._load_impl(path)
|
||||
|
||||
def can_apply_to_core(self) -> bool:
|
||||
"""True when the sidecar carries everything apply_to_core hands CORE.
|
||||
|
||||
Wizard-written sidecars leave build_path unset (older wizards also
|
||||
the platform fields) and can't drive upload/logs.
|
||||
"""
|
||||
return bool((self.core_platform or self.target_platform) and self.build_path)
|
||||
|
||||
def apply_to_core(self) -> None:
|
||||
"""Populate CORE with the metadata upload/logs read.
|
||||
|
||||
Inverse of :meth:`from_esphome_core`. Keep paired -- a new
|
||||
attribute upload/logs needs has to be captured there too.
|
||||
attribute upload/logs needs has to be captured there too and
|
||||
reflected in :meth:`can_apply_to_core`.
|
||||
Validator-only fields (loaded_integrations/platforms,
|
||||
friendly_name) are skipped; the fast path doesn't run
|
||||
validation and CORE.__init__ defaults them.
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.3.1
|
||||
click==8.3.3
|
||||
aioesphomeapi==45.10.1
|
||||
aioesphomeapi==45.10.3
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
zeroconf==0.150.0
|
||||
puremagic==2.2.0
|
||||
@@ -23,11 +23,11 @@ pillow==12.3.0
|
||||
resvg-py==0.3.4
|
||||
freetype-py==2.5.1
|
||||
jinja2==3.1.6
|
||||
bleak==2.1.1
|
||||
bleak==3.0.2
|
||||
smpclient==7.2.0
|
||||
requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.11.1 # native esp-idf toolchain global cache dir
|
||||
platformdirs==4.11.2 # native esp-idf toolchain global cache dir
|
||||
filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
|
||||
# esp-idf >= 5.0 requires this
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
pylint==4.0.6
|
||||
pylint==4.0.7
|
||||
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.16.2 # also change in .pre-commit-config.yaml when updating
|
||||
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
|
||||
prek==0.4.12 # also change in .github/workflows/ci.yml when updating
|
||||
prek==0.4.13 # also change in .github/workflows/ci.yml when updating
|
||||
|
||||
# Unit tests
|
||||
pytest==9.1.1
|
||||
|
||||
@@ -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({})
|
||||
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
esphome:
|
||||
name: host-pref-key-stability
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
id: test_switch_restore
|
||||
name: Test Switch
|
||||
optimistic: true
|
||||
restore_mode: RESTORE_DEFAULT_OFF
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
id: test_number_restore
|
||||
name: Test Number
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
initial_value: 1.0
|
||||
min_value: 0
|
||||
max_value: 100
|
||||
step: 0.5
|
||||
|
||||
text:
|
||||
- platform: template
|
||||
id: test_text_restore
|
||||
name: Test Text
|
||||
mode: text
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
initial_value: fallback
|
||||
min_length: 0
|
||||
max_length: 20
|
||||
@@ -25,15 +25,25 @@ def clear_host_prefs(device_name: str) -> None:
|
||||
host_prefs_path(device_name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path:
|
||||
"""Write preference entries, replacing the file's contents.
|
||||
|
||||
Returns the path that was written.
|
||||
"""
|
||||
payload = b""
|
||||
for key, data in entries.items():
|
||||
if len(data) > 255:
|
||||
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
|
||||
payload += struct.pack("<IB", key, len(data)) + data
|
||||
path = host_prefs_path(device_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
|
||||
|
||||
def write_host_pref(device_name: str, key: int, data: bytes) -> Path:
|
||||
"""Write a single preference entry, replacing the file's contents.
|
||||
|
||||
Returns the path that was written.
|
||||
"""
|
||||
if len(data) > 255:
|
||||
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
|
||||
path = host_prefs_path(device_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = struct.pack("<IB", key, len(data)) + data
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
return write_host_prefs(device_name, {key: data})
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Integration test for entity preference key stability.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from aioesphomeapi import (
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
SwitchInfo,
|
||||
SwitchState,
|
||||
TextInfo,
|
||||
TextState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id
|
||||
|
||||
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
|
||||
from .host_prefs import clear_host_prefs, write_host_prefs
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "host-pref-key-stability"
|
||||
|
||||
# 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_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
|
||||
|
||||
|
||||
def text_pref_payload(value: str) -> bytes:
|
||||
"""Build the length-prefixed buffer TextSaver stores for a value."""
|
||||
data = value.encode("utf-8")
|
||||
assert len(data) <= TEXT_MAX_LENGTH
|
||||
return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 by earlier firmware are restored."""
|
||||
port, port_socket = reserved_tcp_port
|
||||
|
||||
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)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
|
||||
# Release the reserved port so the binary can bind to it
|
||||
port_socket.close()
|
||||
|
||||
async def boot_and_get_initial_states() -> tuple[
|
||||
SwitchState, NumberState, TextState
|
||||
]:
|
||||
"""Boot the binary and return the restored entity states."""
|
||||
async with (
|
||||
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
|
||||
wait_and_connect_api_client(port=port) as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info.name == DEVICE_NAME
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
switch_entity = require_entity(
|
||||
entities, "test_switch", SwitchInfo, "Test Switch"
|
||||
)
|
||||
number_entity = require_entity(
|
||||
entities, "test_number", NumberInfo, "Test Number"
|
||||
)
|
||||
text_entity = require_entity(entities, "test_text", TextInfo, "Test Text")
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda s: None)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
switch_state = initial_state_helper.initial_states[switch_entity.key]
|
||||
number_state = initial_state_helper.initial_states[number_entity.key]
|
||||
text_state = initial_state_helper.initial_states[text_entity.key]
|
||||
assert isinstance(switch_state, SwitchState)
|
||||
assert isinstance(number_state, NumberState)
|
||||
assert isinstance(text_state, TextState)
|
||||
return switch_state, number_state, text_state
|
||||
|
||||
try:
|
||||
# --- Run 1: entries under the object_id-hash keys, exactly as any
|
||||
# earlier firmware wrote them. The restored states prove the key
|
||||
# scheme has not drifted.
|
||||
write_host_prefs(
|
||||
DEVICE_NAME,
|
||||
{
|
||||
SWITCH_KEY: b"\x01", # bool: switch was ON
|
||||
NUMBER_KEY: struct.pack("<f", 42.5),
|
||||
TEXT_KEY: text_pref_payload("hello"),
|
||||
},
|
||||
)
|
||||
switch_state, number_state, text_state = await boot_and_get_initial_states()
|
||||
assert switch_state.state is True, (
|
||||
"Switch state stored under the object_id preference key was lost"
|
||||
)
|
||||
assert number_state.state == 42.5, (
|
||||
"Number value stored under the object_id preference key was lost"
|
||||
)
|
||||
assert text_state.state == "hello", (
|
||||
"Text value stored under the object_id preference key was lost"
|
||||
)
|
||||
|
||||
# --- Run 2: raw-name-hash entries from a 2026.8 beta device present
|
||||
# alongside the object_id entries. The object_id data must win; the
|
||||
# beta entries are never read.
|
||||
write_host_prefs(
|
||||
DEVICE_NAME,
|
||||
{
|
||||
SWITCH_KEY: b"\x01", # current: ON
|
||||
SWITCH_BETA_KEY: b"\x00", # beta leftover: OFF
|
||||
NUMBER_KEY: struct.pack("<f", 13.5), # current
|
||||
NUMBER_BETA_KEY: struct.pack("<f", 99.5), # beta leftover
|
||||
TEXT_KEY: text_pref_payload("world"), # current
|
||||
TEXT_BETA_KEY: text_pref_payload("ignored"), # beta leftover
|
||||
},
|
||||
)
|
||||
switch_state, number_state, text_state = await boot_and_get_initial_states()
|
||||
assert switch_state.state is True, (
|
||||
"Beta raw-name-key data overrode the object_id switch state"
|
||||
)
|
||||
assert number_state.state == 13.5, (
|
||||
"Beta raw-name-key data overrode the object_id number value"
|
||||
)
|
||||
assert text_state.state == "world", (
|
||||
"Beta raw-name-key data overrode the object_id text value"
|
||||
)
|
||||
finally:
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user