mirror of
https://github.com/esphome/esphome.git
synced 2026-09-16 01:28:39 +00:00
Merge remote-tracking branch 'upstream/dev' into multi-interface-poc
This commit is contained in:
@@ -35,6 +35,10 @@ runs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
|
||||
shell: bash
|
||||
|
||||
@@ -32,6 +32,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install apt dependencies
|
||||
run: |
|
||||
|
||||
@@ -60,6 +60,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -175,6 +179,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Install device-builder + esphome from PR
|
||||
# Install device-builder with its esphome + test extras
|
||||
# first so its pinned versions of pytest/etc. land, then
|
||||
@@ -365,6 +373,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
|
||||
@@ -50,6 +50,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install Home Assistant
|
||||
run: |
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.12
|
||||
rev: v0.15.14
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
+14
-1
@@ -2449,7 +2449,10 @@ def run_esphome(argv):
|
||||
# Skipped when -s overrides are passed, since the cache was written
|
||||
# against the previous substitution set.
|
||||
config: ConfigType | None = None
|
||||
if args.command in ("upload", "logs") and not command_line_substitutions:
|
||||
cache_eligible = (
|
||||
args.command in ("upload", "logs") and not command_line_substitutions
|
||||
)
|
||||
if cache_eligible:
|
||||
from esphome.compiled_config import load_compiled_config
|
||||
|
||||
config = load_compiled_config(conf_path)
|
||||
@@ -2464,6 +2467,16 @@ def run_esphome(argv):
|
||||
command_line_substitutions,
|
||||
skip_external_update=skip_external,
|
||||
)
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
# sidecar is absent (no compile has run): the cache would
|
||||
# never be loaded back, so writing secrets to disk is wasted.
|
||||
if cache_eligible and config is not None:
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.storage_json import ext_storage_path
|
||||
|
||||
if ext_storage_path(conf_path.name).exists():
|
||||
save_compiled_config(config)
|
||||
if config is None:
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "api_connection.h"
|
||||
#ifdef USE_API
|
||||
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
|
||||
#ifdef USE_API_NOISE
|
||||
#include "api_frame_helper_noise.h"
|
||||
#endif
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
#endif
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
#include "api_server.h"
|
||||
#include "list_entities.h"
|
||||
#include "subscribe_state.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/component.h"
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
@@ -36,6 +37,9 @@ class ComponentIterator;
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
|
||||
class APIServer;
|
||||
|
||||
// Keepalive timeout in milliseconds
|
||||
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
|
||||
// Maximum number of entities to process in a single batch during initial state/info sending
|
||||
@@ -411,44 +415,10 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Non-template buffer management for send_message
|
||||
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
|
||||
|
||||
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
|
||||
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
|
||||
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
|
||||
const void *msg, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
if (conn->flags_.log_only_mode) {
|
||||
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
|
||||
DumpBuffer dump_buf;
|
||||
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
const uint8_t footer_size = conn->helper_->frame_footer_size();
|
||||
|
||||
// First message uses max padding (already in buffer), subsequent use exact header size
|
||||
size_t to_add;
|
||||
if (conn->flags_.batch_first_message) {
|
||||
conn->flags_.batch_first_message = false;
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_padding();
|
||||
to_add = calculated_size;
|
||||
} else {
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
|
||||
to_add = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
}
|
||||
|
||||
// Check if it fits (using actual header size, not max padding)
|
||||
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
if (total_calculated_size > remaining_size)
|
||||
return 0;
|
||||
|
||||
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
|
||||
shared_buf.resize(shared_buf.size() + to_add);
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
|
||||
return total_calculated_size;
|
||||
}
|
||||
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
|
||||
// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||
static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
|
||||
const void *msg, APIConnection *conn, uint32_t remaining_size);
|
||||
|
||||
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
|
||||
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
|
||||
@@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Read by process_batch_multi_ to pass into MessageInfo.
|
||||
uint8_t batch_header_size_{0};
|
||||
|
||||
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
||||
// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||
uint32_t get_batch_delay_ms_() const;
|
||||
// Message will use 8 more bytes than the minimum size, and typical
|
||||
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
|
||||
// If its IPv6 the header is 40 bytes, and if its IPv4
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
|
||||
// Inline APIConnection methods that need APIServer complete. Include this
|
||||
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
|
||||
|
||||
#include "api_connection.h"
|
||||
#include "api_server.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
|
||||
MessageEncodeFn encode_fn, const void *msg,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
if (conn->flags_.log_only_mode) {
|
||||
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
|
||||
DumpBuffer dump_buf;
|
||||
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
const uint8_t footer_size = conn->helper_->frame_footer_size();
|
||||
|
||||
// First message uses max padding (already in buffer), subsequent use exact header size
|
||||
size_t to_add;
|
||||
if (conn->flags_.batch_first_message) {
|
||||
conn->flags_.batch_first_message = false;
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_padding();
|
||||
to_add = calculated_size;
|
||||
} else {
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
|
||||
to_add = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
}
|
||||
|
||||
// Check if it fits (using actual header size, not max padding)
|
||||
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
if (total_calculated_size > remaining_size)
|
||||
return 0;
|
||||
|
||||
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
|
||||
shared_buf.resize(shared_buf.size() + to_add);
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
|
||||
return total_calculated_size;
|
||||
}
|
||||
|
||||
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif
|
||||
@@ -30,11 +30,6 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c
|
||||
|
||||
APIServer::APIServer() { global_api_server = this; }
|
||||
|
||||
// Custom deleter defined here so `delete` sees the complete APIConnection type.
|
||||
// This prevents libc++ from emitting an "incomplete type" error when other
|
||||
// translation units only have the forward declaration of APIConnection.
|
||||
void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; }
|
||||
|
||||
void APIServer::socket_failed_(const LogString *msg) {
|
||||
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
|
||||
this->destroy_socket_();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
#include "api_buffer.h"
|
||||
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
|
||||
#include "api_connection.h"
|
||||
#include "api_noise_context.h"
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
@@ -12,8 +14,6 @@
|
||||
#include "esphome/core/controller.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
#include "list_entities.h"
|
||||
#include "subscribe_state.h"
|
||||
#ifdef USE_LOGGER
|
||||
#include "esphome/components/logger/logger.h"
|
||||
#endif
|
||||
@@ -191,15 +191,9 @@ class APIServer final : public Component,
|
||||
bool is_connected_with_state_subscription() const;
|
||||
|
||||
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
|
||||
// to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the
|
||||
// to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the
|
||||
// APIConnection but cannot reset/move the slot and break the count invariant.
|
||||
// Custom deleter is defined out-of-line in api_server.cpp so libc++ does not
|
||||
// eagerly instantiate `delete static_cast<APIConnection *>(p)` here, where
|
||||
// only the forward declaration of APIConnection is visible (incomplete type).
|
||||
struct APIConnectionDeleter {
|
||||
void operator()(APIConnection *p) const;
|
||||
};
|
||||
using APIConnectionPtr = std::unique_ptr<APIConnection, APIConnectionDeleter>;
|
||||
using APIConnectionPtr = std::unique_ptr<APIConnection>;
|
||||
class ActiveClientsView {
|
||||
const APIConnectionPtr *begin_;
|
||||
const APIConnectionPtr *end_;
|
||||
|
||||
@@ -335,7 +335,7 @@ async def to_code(config):
|
||||
|
||||
add_idf_component(
|
||||
name="esphome/esp-audio-libs",
|
||||
ref="3.0.0",
|
||||
ref="3.1.0",
|
||||
)
|
||||
|
||||
data = _get_data()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "bluetooth_proxy.h"
|
||||
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/macros.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
@@ -113,6 +113,7 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
|
||||
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
|
||||
ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs"
|
||||
ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}"
|
||||
ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32"
|
||||
|
||||
LOG_LEVELS_IDF = [
|
||||
"NONE",
|
||||
@@ -1743,6 +1744,31 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL - 1)
|
||||
async def _finalize_arduino_aware_flags():
|
||||
"""Build flags that depend on whether arduino-esp32 is linked in.
|
||||
|
||||
Scheduler runs lower priority values later, so ``FINAL - 1`` fires
|
||||
after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) --
|
||||
by then ``KEY_COMPONENTS`` is fully populated.
|
||||
|
||||
- Skip our esp_panic_handler wrap when Arduino is linked; Arduino
|
||||
wraps the same symbol and the linker errors on the duplicate.
|
||||
- Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component
|
||||
case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The
|
||||
framework=arduino branch already adds it inline in to_code.
|
||||
"""
|
||||
arduino_linked = (
|
||||
CORE.using_arduino
|
||||
or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS]
|
||||
)
|
||||
if not arduino_linked:
|
||||
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
|
||||
cg.add_define("USE_ESP32_CRASH_HANDLER")
|
||||
elif not CORE.using_arduino:
|
||||
cg.add_build_flag("-DUSE_ARDUINO")
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
@@ -1802,11 +1828,8 @@ async def to_code(config):
|
||||
cg.add_build_flag("-DUSE_ESP32")
|
||||
cg.add_define("USE_NATIVE_64BIT_TIME")
|
||||
cg.add_build_flag("-Wl,-z,noexecstack")
|
||||
# Arduino already wraps esp_panic_handler for its own backtrace handler,
|
||||
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts.
|
||||
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
|
||||
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
|
||||
cg.add_define("USE_ESP32_CRASH_HANDLER")
|
||||
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
|
||||
CORE.add_job(_finalize_arduino_aware_flags)
|
||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
||||
variant = config[CONF_VARIANT]
|
||||
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
|
||||
@@ -1987,7 +2010,7 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True)
|
||||
|
||||
# Setup watchdog
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
|
||||
@@ -2122,7 +2145,6 @@ async def to_code(config):
|
||||
for key, flag in ASSERTION_LEVELS.items():
|
||||
add_idf_sdkconfig_option(flag, assertion_level == key)
|
||||
|
||||
add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False)
|
||||
compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION]
|
||||
for key, flag in COMPILER_OPTIMIZATIONS.items():
|
||||
add_idf_sdkconfig_option(flag, compiler_optimization == key)
|
||||
@@ -2568,7 +2590,7 @@ def _write_idf_component_yml():
|
||||
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
add_idf_component(
|
||||
name="espressif/arduino-esp32",
|
||||
name=ARDUINO_ESP32_COMPONENT_NAME,
|
||||
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
|
||||
)
|
||||
|
||||
|
||||
@@ -86,10 +86,22 @@ class EffectRef:
|
||||
component_path: list[str | int] # path_context when the action was validated
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectCycleRef:
|
||||
"""A pending light.effect.next/previous action to validate.
|
||||
|
||||
Records that the referenced light needs at least one effect configured.
|
||||
"""
|
||||
|
||||
light_id: ID
|
||||
component_path: list[str | int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LightData:
|
||||
gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr
|
||||
effect_refs: list[EffectRef] = field(default_factory=list)
|
||||
effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> LightData:
|
||||
@@ -160,13 +172,15 @@ def _final_validate(config: ConfigType) -> ConfigType:
|
||||
this never runs — but the ID validator will catch the missing light ID separately.
|
||||
"""
|
||||
data = _get_data()
|
||||
if not data.effect_refs:
|
||||
if not data.effect_refs and not data.effect_cycle_refs:
|
||||
return config
|
||||
|
||||
# Drain the list so we only validate once even though
|
||||
# Drain the lists so we only validate once even though
|
||||
# FINAL_VALIDATE_SCHEMA runs for each light platform instance.
|
||||
refs = data.effect_refs
|
||||
data.effect_refs = []
|
||||
cycle_refs = data.effect_cycle_refs
|
||||
data.effect_cycle_refs = []
|
||||
|
||||
fconf = fv.full_config.get()
|
||||
|
||||
@@ -188,6 +202,21 @@ def _final_validate(config: ConfigType) -> ConfigType:
|
||||
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
|
||||
)
|
||||
|
||||
for ref in cycle_refs:
|
||||
try:
|
||||
light_path = fconf.get_path_for_id(ref.light_id)[:-1]
|
||||
light_config = fconf.get_config_for_path(light_path)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if not light_config.get(CONF_EFFECTS):
|
||||
raise cv.FinalExternalInvalid(
|
||||
f"Light '{ref.light_id}' has no effects configured, but a "
|
||||
f"'light.effect.next' or 'light.effect.previous' action "
|
||||
f"references it. Add at least one effect to the light.",
|
||||
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,47 @@ template<bool HasTransitionLength, typename... Ts> class DimRelativeAction : pub
|
||||
transition_length_{};
|
||||
};
|
||||
|
||||
// Cycle through the light's configured effects. `Forward` selects direction
|
||||
// at compile time so the chosen branch is the only one that gets instantiated
|
||||
// per action site. `include_none` is runtime so a single set of templates
|
||||
// covers both the "wrap through None" and "skip None" variants.
|
||||
template<bool Forward, typename... Ts> class LightEffectCycleAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {}
|
||||
|
||||
void set_include_none(bool include_none) { this->include_none_ = include_none; }
|
||||
|
||||
void play(const Ts &...) override {
|
||||
size_t count = this->parent_->get_effect_count();
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
uint32_t current = this->parent_->get_current_effect_index();
|
||||
uint32_t next;
|
||||
if (this->include_none_) {
|
||||
uint32_t total = static_cast<uint32_t>(count) + 1;
|
||||
if constexpr (Forward) {
|
||||
next = (current + 1) % total;
|
||||
} else {
|
||||
next = (current + total - 1) % total;
|
||||
}
|
||||
} else {
|
||||
if constexpr (Forward) {
|
||||
next = (current % static_cast<uint32_t>(count)) + 1;
|
||||
} else {
|
||||
next = (current <= 1) ? static_cast<uint32_t>(count) : current - 1;
|
||||
}
|
||||
}
|
||||
auto call = this->parent_->turn_on();
|
||||
call.set_effect(next);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
protected:
|
||||
LightState *parent_;
|
||||
bool include_none_{false};
|
||||
};
|
||||
|
||||
template<typename... Ts> class LightIsOnCondition : public Condition<Ts...> {
|
||||
public:
|
||||
explicit LightIsOnCondition(LightState *state) : state_(state) {}
|
||||
|
||||
@@ -26,8 +26,8 @@ from esphome.const import (
|
||||
CONF_WARM_WHITE,
|
||||
CONF_WHITE,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, Lambda
|
||||
from esphome.cpp_generator import LambdaExpression
|
||||
from esphome.core import CORE, ID, EsphomeError, Lambda
|
||||
from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .types import (
|
||||
@@ -39,12 +39,15 @@ from .types import (
|
||||
DimRelativeAction,
|
||||
LightCall,
|
||||
LightControlAction,
|
||||
LightEffectCycleAction,
|
||||
LightIsOffCondition,
|
||||
LightIsOnCondition,
|
||||
LightState,
|
||||
ToggleAction,
|
||||
)
|
||||
|
||||
CONF_INCLUDE_NONE = "include_none"
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.toggle",
|
||||
@@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args):
|
||||
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
|
||||
|
||||
|
||||
def _record_effect_cycle_ref(config: ConfigType) -> ConfigType:
|
||||
"""Record a cycle-action reference for later validation against the target light."""
|
||||
from . import EffectCycleRef, _get_data
|
||||
|
||||
_get_data().effect_cycle_refs.append(
|
||||
EffectCycleRef(
|
||||
light_id=config[CONF_ID],
|
||||
component_path=path_context.get(),
|
||||
)
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(LightState),
|
||||
cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref)
|
||||
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.effect.next",
|
||||
LightEffectCycleAction,
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_effect_next_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
return await _light_effect_cycle_to_code(config, action_id, template_arg, True)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.effect.previous",
|
||||
LightEffectCycleAction,
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_effect_previous_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
return await _light_effect_cycle_to_code(config, action_id, template_arg, False)
|
||||
|
||||
|
||||
async def _light_effect_cycle_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
forward: bool,
|
||||
) -> MockObj:
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
cycle_template_arg = cg.TemplateArguments(forward, *template_arg)
|
||||
var = cg.new_Pvariable(action_id, cycle_template_arg, paren)
|
||||
cg.add(var.set_include_none(config[CONF_INCLUDE_NONE]))
|
||||
return var
|
||||
|
||||
|
||||
CONF_RELATIVE_BRIGHTNESS = "relative_brightness"
|
||||
LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ LIMIT_MODES = {
|
||||
# Actions
|
||||
ToggleAction = light_ns.class_("ToggleAction", automation.Action)
|
||||
LightControlAction = light_ns.class_("LightControlAction", automation.Action)
|
||||
LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action)
|
||||
DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action)
|
||||
AddressableSet = light_ns.class_("AddressableSet", automation.Action)
|
||||
LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition)
|
||||
|
||||
@@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current
|
||||
static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms
|
||||
|
||||
uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
|
||||
this->wait_busy_();
|
||||
this->enable();
|
||||
this->wait_busy_();
|
||||
this->transfer_byte(RADIO_READ_BUFFER);
|
||||
this->transfer_byte(offset);
|
||||
uint8_t status = this->transfer_byte(0x00);
|
||||
@@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
|
||||
}
|
||||
|
||||
void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
|
||||
this->wait_busy_();
|
||||
this->enable();
|
||||
this->wait_busy_();
|
||||
this->transfer_byte(RADIO_WRITE_BUFFER);
|
||||
this->transfer_byte(offset);
|
||||
for (const uint8_t &byte : packet) {
|
||||
@@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
|
||||
}
|
||||
|
||||
uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
|
||||
this->wait_busy_();
|
||||
this->enable();
|
||||
this->wait_busy_();
|
||||
this->transfer_byte(opcode);
|
||||
uint8_t status = this->transfer_byte(0x00);
|
||||
for (int32_t i = 0; i < size; i++) {
|
||||
@@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
|
||||
}
|
||||
|
||||
void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
|
||||
this->wait_busy_();
|
||||
this->enable();
|
||||
this->wait_busy_();
|
||||
this->transfer_byte(opcode);
|
||||
for (int32_t i = 0; i < size; i++) {
|
||||
this->transfer_byte(data[i]);
|
||||
@@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
|
||||
}
|
||||
|
||||
void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
|
||||
this->wait_busy_();
|
||||
this->enable();
|
||||
this->wait_busy_();
|
||||
this->write_byte(RADIO_READ_REGISTER);
|
||||
this->write_byte((reg >> 8) & 0xFF);
|
||||
this->write_byte((reg >> 0) & 0xFF);
|
||||
@@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
|
||||
}
|
||||
|
||||
void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) {
|
||||
this->wait_busy_();
|
||||
this->enable();
|
||||
this->wait_busy_();
|
||||
this->write_byte(RADIO_WRITE_REGISTER);
|
||||
this->write_byte((reg >> 8) & 0xFF);
|
||||
this->write_byte((reg >> 0) & 0xFF);
|
||||
|
||||
@@ -206,15 +206,17 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff
|
||||
if (this->status_pin_reported_ != -1) {
|
||||
this->init_state_ = TuyaInitState::INIT_DATAPOINT;
|
||||
this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY);
|
||||
bool is_pin_equals =
|
||||
this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_;
|
||||
// Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send
|
||||
if (!is_pin_equals) {
|
||||
ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.",
|
||||
if (this->status_pin_ != nullptr) {
|
||||
if (this->status_pin_->get_pin() != this->status_pin_reported_) {
|
||||
ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.",
|
||||
this->status_pin_reported_);
|
||||
}
|
||||
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
|
||||
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
|
||||
} else {
|
||||
ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.",
|
||||
this->status_pin_reported_);
|
||||
}
|
||||
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
|
||||
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
|
||||
} else {
|
||||
this->init_state_ = TuyaInitState::INIT_WIFI;
|
||||
ESP_LOGV(TAG, "Configured WIFI_STATE periodic send");
|
||||
|
||||
@@ -5,7 +5,7 @@ import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from esphome.const import (
|
||||
CONF_COMMENT,
|
||||
@@ -569,6 +569,12 @@ class EsphomeCore:
|
||||
self.build_path: Path | None = None
|
||||
# The validated configuration, this is None until the config has been validated
|
||||
self.config: ConfigType | None = None
|
||||
# YAML frontmatter loaded from user YAML files. Frontmatter is a leading
|
||||
# YAML document separated by `---` from the actual configuration. It is
|
||||
# ignored by config validation and code generation, but kept here so it
|
||||
# can be inspected by callers (tooling, future features). Keyed by the
|
||||
# resolved Path of the source file.
|
||||
self.frontmatter: dict[Path, Any] = {}
|
||||
# The pending tasks in the task queue (mostly for C++ generation)
|
||||
# This is a priority queue (with heapq)
|
||||
# Each item is a tuple of form: (-priority, unique number, task)
|
||||
@@ -634,6 +640,7 @@ class EsphomeCore:
|
||||
self.config_path = None
|
||||
self.build_path = None
|
||||
self.config = None
|
||||
self.frontmatter = {}
|
||||
self.event_loop = _FakeEventLoop()
|
||||
self.task_counter = 0
|
||||
self.variables = {}
|
||||
|
||||
@@ -93,7 +93,7 @@ class URLSource(Source):
|
||||
|
||||
|
||||
class GitSource(Source):
|
||||
def __init__(self, url: str, ref: str):
|
||||
def __init__(self, url: str, ref: str | None):
|
||||
self.url = url
|
||||
self.ref = ref
|
||||
|
||||
@@ -109,7 +109,7 @@ class GitSource(Source):
|
||||
return path
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.url}#{self.ref}"
|
||||
return f"{self.url}#{self.ref}" if self.ref else self.url
|
||||
|
||||
|
||||
class InvalidIDFComponent(Exception):
|
||||
@@ -352,7 +352,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
|
||||
IDFComponent: The resolved component with name, version, and URL
|
||||
|
||||
Raises:
|
||||
ValueError: If a repository URL is missing a reference (#)
|
||||
RuntimeError: If no artifact can be found for the library
|
||||
"""
|
||||
name = None
|
||||
@@ -362,10 +361,11 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
|
||||
# Repository is provided directly
|
||||
if library.repository:
|
||||
# Parse repository URL: path becomes the component name, fragment
|
||||
# becomes the git ref stored on GitSource.
|
||||
# (if any) becomes the git ref stored on GitSource. A missing
|
||||
# fragment is fine -- clone_or_update leaves the depth-1 clone on
|
||||
# the remote's default branch, matching PIO's lib_deps behavior
|
||||
# and external_components handling.
|
||||
split_result = urlsplit(library.repository)
|
||||
if not split_result.fragment.strip():
|
||||
raise ValueError(f"Missing ref in URL {library.repository}")
|
||||
|
||||
# Sanitize name
|
||||
name = str(split_result.path).strip("/")
|
||||
@@ -377,7 +377,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
|
||||
version = "*"
|
||||
repository = urlunsplit(split_result._replace(fragment=""))
|
||||
|
||||
source = GitSource(str(repository), split_result.fragment)
|
||||
ref = split_result.fragment.strip() or None
|
||||
source = GitSource(str(repository), ref)
|
||||
|
||||
# Version is provided - resolve using PlatformIO registry
|
||||
elif library.version:
|
||||
@@ -655,6 +656,26 @@ def _process_dependencies(component: IDFComponent):
|
||||
if not dependencies:
|
||||
return
|
||||
|
||||
# PIO's library.json accepts both the list-of-dicts form and the
|
||||
# shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize
|
||||
# the dict form so the loop below sees a uniform list. Iterating a
|
||||
# dict gives string keys, which would silently fail the
|
||||
# ``"name" in dependency`` substring check and skip every entry.
|
||||
if isinstance(dependencies, dict):
|
||||
normalized = []
|
||||
for raw_name, spec in dependencies.items():
|
||||
if "/" in raw_name:
|
||||
owner, pkgname = raw_name.split("/", 1)
|
||||
else:
|
||||
owner, pkgname = None, raw_name
|
||||
entry = {"name": pkgname, "owner": owner}
|
||||
if isinstance(spec, dict):
|
||||
entry.update(spec)
|
||||
else:
|
||||
entry["version"] = spec
|
||||
normalized.append(entry)
|
||||
dependencies = normalized
|
||||
|
||||
_LOGGER.info("Processing %s@%s component dependencies...", name, version)
|
||||
for dependency in dependencies:
|
||||
# Validate dependency structure
|
||||
|
||||
+113
-6
@@ -7,6 +7,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -17,7 +18,7 @@ import requests
|
||||
|
||||
from esphome.config_validation import Version
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import ProgressBar, get_str_env, rmtree
|
||||
from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed
|
||||
|
||||
PathType = str | os.PathLike
|
||||
|
||||
@@ -549,11 +550,11 @@ def _tar_extract_all(
|
||||
if not (mode & stat.S_IXUSR):
|
||||
mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
mode |= stat.S_IRUSR | stat.S_IWUSR
|
||||
elif member.isdir() or member.issym():
|
||||
# Ignore mode for directories & symlinks
|
||||
mode = None
|
||||
else:
|
||||
# Block special files
|
||||
elif not (member.isdir() or member.issym()):
|
||||
# Block special files. Directories and symlinks keep
|
||||
# their masked-original mode — passing None here would
|
||||
# crash tarfile.extract on Python <3.12 (its chmod
|
||||
# path calls os.chmod unconditionally).
|
||||
continue
|
||||
|
||||
member.mode = mode
|
||||
@@ -783,6 +784,102 @@ def download_from_mirrors(
|
||||
return None
|
||||
|
||||
|
||||
def _write_idf_version_txt(framework_path: Path, version: str) -> None:
|
||||
"""Write <framework_path>/version.txt if missing.
|
||||
|
||||
IDF's build.cmake picks the version it embeds in the firmware (and
|
||||
stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt``
|
||||
if present, else ``git describe`` against IDF_PATH, else the
|
||||
``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``.
|
||||
On a clean esphome-libs tarball ``.git`` is fully stripped, so
|
||||
git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet
|
||||
wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom
|
||||
framework.source pointed at a real git URL where build artifacts
|
||||
mark the tree dirty) makes git_describe return ``<hash>-dirty``,
|
||||
which is what then gets baked into the bootloader. Dropping
|
||||
version.txt forces the right answer regardless.
|
||||
"""
|
||||
version_txt = framework_path / "version.txt"
|
||||
if version_txt.exists():
|
||||
return
|
||||
try:
|
||||
version_txt.write_text(f"v{version}\n", encoding="utf-8")
|
||||
except OSError as e:
|
||||
_LOGGER.warning(
|
||||
"Could not write %s (%s); bootloader version string may be incorrect.",
|
||||
version_txt,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release
|
||||
# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no
|
||||
# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a
|
||||
# matching binary or grabs the x86_64 one, which can't execute on
|
||||
# aarch64. cmake is already populated across the same release range; we
|
||||
# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1
|
||||
# tools.json where the fix landed natively.
|
||||
_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = {
|
||||
"1.12.1": {
|
||||
"rename_dist": "ninja-linux-arm64-v1.12.1.zip",
|
||||
"sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1",
|
||||
"size": 121787,
|
||||
"url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None:
|
||||
"""Inject ninja linux-arm64 entries into the framework's tools.json on aarch64.
|
||||
|
||||
Idempotent: a tools.json that already has the entry, or a host that
|
||||
isn't aarch64, is a no-op. Applied unconditionally on every install
|
||||
check so a build dir extracted before the backport got fixed up
|
||||
without forcing a clean.
|
||||
"""
|
||||
if platform.machine() != "aarch64":
|
||||
return
|
||||
|
||||
tools_json = framework_path / "tools" / "tools.json"
|
||||
if not tools_json.is_file():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(tools_json, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
_LOGGER.warning(
|
||||
"Could not parse %s for linux-arm64 backport (%s); "
|
||||
"skipping. A clean reinstall of the framework directory "
|
||||
"may be needed.",
|
||||
tools_json,
|
||||
e,
|
||||
)
|
||||
return
|
||||
|
||||
changed = False
|
||||
for tool in data.get("tools", []):
|
||||
if tool.get("name") != "ninja":
|
||||
continue
|
||||
for ver in tool.get("versions", []):
|
||||
entry = _NINJA_ARM64_BACKPORT.get(ver.get("name"))
|
||||
if entry is None or ver.get("linux-arm64"):
|
||||
continue
|
||||
ver["linux-arm64"] = entry
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
# write_file_if_changed stages a tempfile in the destination dir
|
||||
# and atomically replaces — safe against mid-write interruption
|
||||
# and concurrent invocations.
|
||||
write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n")
|
||||
_LOGGER.info(
|
||||
"Patched %s to add ninja linux-arm64 download "
|
||||
"(espressif/esp-idf#18272 backport).",
|
||||
tools_json,
|
||||
)
|
||||
|
||||
|
||||
def _check_esphome_idf_framework_install(
|
||||
version: str,
|
||||
targets: list[str],
|
||||
@@ -864,6 +961,16 @@ def _check_esphome_idf_framework_install(
|
||||
archive_extract_all(tmp.file, framework_path, progress_header="Extracting")
|
||||
extracted_marker.touch()
|
||||
|
||||
# Idempotent post-extract patch: written every invocation so a build
|
||||
# dir extracted before this fix gets the file too, without forcing a
|
||||
# clean. Skips when version.txt already exists.
|
||||
_write_idf_version_txt(framework_path, version)
|
||||
|
||||
# Apply the ninja linux-arm64 backport on every invocation, not just on
|
||||
# fresh extracts — idempotent and cheap, and lets a build dir carrying
|
||||
# a pre-patch tools.json get fixed up without forcing a clean.
|
||||
_patch_tools_json_for_linux_arm64(framework_path)
|
||||
|
||||
# 3. Check if the framework tools are the same and correctly installed
|
||||
if not install:
|
||||
install = True
|
||||
|
||||
@@ -66,6 +66,12 @@ FILTER_IDF_LINES: list[str] = [
|
||||
# Drop the blank line rich emits after the note so the build log
|
||||
# doesn't end with an orphan gap before ESPHome's own status lines.
|
||||
r"\s*$",
|
||||
# ESP-IDF shells out to ``git rev-parse`` to embed a commit hash;
|
||||
# esphome-libs strips ``.git`` from the tarball so those probes fail
|
||||
# noisily without affecting the build.
|
||||
r"-- git rev-parse returned ",
|
||||
r"fatal: not a git repository",
|
||||
r"Stopping at filesystem boundary",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ dependencies:
|
||||
bblanchon/arduinojson:
|
||||
version: "7.4.2"
|
||||
esphome/esp-audio-libs:
|
||||
version: 3.0.0
|
||||
version: 3.1.0
|
||||
esphome/esp-micro-speech-features:
|
||||
version: 1.2.3
|
||||
esphome/micro-decoder:
|
||||
|
||||
@@ -14,6 +14,7 @@ from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import write_file_if_changed
|
||||
@@ -98,6 +99,7 @@ class StorageJSON:
|
||||
no_mdns: bool,
|
||||
framework: str | None = None,
|
||||
core_platform: str | None = None,
|
||||
toolchain: str | None = None,
|
||||
) -> None:
|
||||
# Version of the storage JSON schema
|
||||
assert storage_version is None or isinstance(storage_version, int)
|
||||
@@ -134,6 +136,8 @@ class StorageJSON:
|
||||
self.framework = framework
|
||||
# The core platform of this firmware. Like "esp32", "rp2040", "host" etc.
|
||||
self.core_platform = core_platform
|
||||
# The toolchain used for the build ("platformio" / "esp-idf")
|
||||
self.toolchain = toolchain
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
@@ -153,6 +157,7 @@ class StorageJSON:
|
||||
"no_mdns": self.no_mdns,
|
||||
"framework": self.framework,
|
||||
"core_platform": self.core_platform,
|
||||
"toolchain": self.toolchain,
|
||||
}
|
||||
|
||||
def to_json(self):
|
||||
@@ -189,6 +194,7 @@ class StorageJSON:
|
||||
),
|
||||
framework=esph.target_framework,
|
||||
core_platform=esph.target_platform,
|
||||
toolchain=esph.toolchain.value if esph.toolchain is not None else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -236,6 +242,7 @@ class StorageJSON:
|
||||
no_mdns = storage.get("no_mdns", False)
|
||||
framework = storage.get("framework")
|
||||
core_platform = storage.get("core_platform")
|
||||
toolchain = storage.get("toolchain")
|
||||
return StorageJSON(
|
||||
storage_version,
|
||||
name,
|
||||
@@ -253,6 +260,7 @@ class StorageJSON:
|
||||
no_mdns,
|
||||
framework,
|
||||
core_platform,
|
||||
toolchain,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -273,6 +281,18 @@ class StorageJSON:
|
||||
"""
|
||||
CORE.name = self.name
|
||||
CORE.build_path = self.build_path
|
||||
# Restore toolchain so upload/logs picks the right firmware_bin path.
|
||||
# An unknown value (corrupt sidecar, or written by a newer ESPHome)
|
||||
# just leaves CORE.toolchain None — the fallback then picks PlatformIO.
|
||||
if self.toolchain and CORE.toolchain is None:
|
||||
try:
|
||||
CORE.toolchain = Toolchain(self.toolchain)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Ignoring unknown toolchain %r from %s",
|
||||
self.toolchain,
|
||||
storage_path(),
|
||||
)
|
||||
target_platform = self.core_platform or self.target_platform.lower()
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: target_platform,
|
||||
|
||||
+27
-2
@@ -768,10 +768,35 @@ def _load_yaml_internal_with_type(
|
||||
content: TextIOWrapper,
|
||||
yaml_loader: Callable[[Path], dict[str, Any]],
|
||||
) -> Any:
|
||||
"""Load a YAML file."""
|
||||
"""Load a YAML file.
|
||||
|
||||
Supports an optional leading YAML frontmatter document: when the file
|
||||
contains two YAML documents separated by ``---``, the first document is
|
||||
treated as metadata and stored in :attr:`CORE.frontmatter` keyed by the
|
||||
resolved file path, while the second document is returned as the actual
|
||||
configuration. Frontmatter is ignored by config validation and code
|
||||
generation.
|
||||
"""
|
||||
loader = loader_type(content, fname, yaml_loader)
|
||||
try:
|
||||
return loader.get_single_data() or OrderedDict()
|
||||
documents: list[Any] = []
|
||||
while loader.check_data():
|
||||
documents.append(loader.get_data())
|
||||
if len(documents) > 2:
|
||||
raise EsphomeError(
|
||||
f"YAML file '{fname}' contains {len(documents)} documents but "
|
||||
f"at most two are supported (an optional frontmatter document "
|
||||
f"followed by the configuration)."
|
||||
)
|
||||
if len(documents) == 2:
|
||||
frontmatter = documents[0]
|
||||
config = documents[1]
|
||||
if frontmatter is not None:
|
||||
CORE.frontmatter[Path(fname).resolve()] = frontmatter
|
||||
return config if config is not None else OrderedDict()
|
||||
if len(documents) == 1:
|
||||
return documents[0] or OrderedDict()
|
||||
return OrderedDict()
|
||||
except yaml.YAMLError as exc:
|
||||
raise EsphomeError(exc) from exc
|
||||
finally:
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ esptool==5.2.0
|
||||
click==8.3.3
|
||||
esphome-dashboard==20260425.0
|
||||
aioesphomeapi==45.0.4
|
||||
zeroconf==0.149.12
|
||||
zeroconf==0.149.16
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
ruamel.yaml.clib==0.2.15 # dashboard_import
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pylint==4.0.5
|
||||
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.15.13 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.15.14 # also change in .pre-commit-config.yaml when updating
|
||||
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
|
||||
pre-commit
|
||||
|
||||
|
||||
@@ -9,13 +9,17 @@ import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.light import (
|
||||
EffectCycleRef,
|
||||
EffectRef,
|
||||
_final_validate,
|
||||
_get_data,
|
||||
available_effects_str,
|
||||
find_effect_index,
|
||||
)
|
||||
from esphome.components.light.automation import _record_effect_ref
|
||||
from esphome.components.light.automation import (
|
||||
_record_effect_cycle_ref,
|
||||
_record_effect_ref,
|
||||
)
|
||||
from esphome.config import Config, path_context
|
||||
from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME
|
||||
from esphome.core import ID, Lambda
|
||||
@@ -215,6 +219,111 @@ def test_final_validate_drains_refs() -> None:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
# --- _final_validate: EffectCycleRef ---
|
||||
|
||||
|
||||
def _setup_cycle_final_validate(
|
||||
cycle_refs: list[EffectCycleRef],
|
||||
light_configs: list[ConfigType],
|
||||
declare_ids: list[tuple[ID, list[str | int]]],
|
||||
) -> Token:
|
||||
"""Set up CORE.data and fv.full_config for EffectCycleRef final_validate tests."""
|
||||
data = _get_data()
|
||||
data.effect_cycle_refs = cycle_refs
|
||||
|
||||
full_conf = Config()
|
||||
full_conf["light"] = light_configs
|
||||
for id_, path in declare_ids:
|
||||
full_conf.declare_ids.append((id_, path))
|
||||
|
||||
return fv.full_config.set(full_conf)
|
||||
|
||||
|
||||
def test_final_validate_cycle_accepts_light_with_effects() -> None:
|
||||
"""Cycle ref against a light with effects should not raise."""
|
||||
light_id = ID("led1", is_declaration=True)
|
||||
token = _setup_cycle_final_validate(
|
||||
cycle_refs=[
|
||||
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
|
||||
],
|
||||
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
|
||||
declare_ids=[(light_id, ["light", 0, CONF_ID])],
|
||||
)
|
||||
try:
|
||||
_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_final_validate_cycle_rejects_light_without_effects_key() -> None:
|
||||
"""Cycle ref against a light with no CONF_EFFECTS key should raise."""
|
||||
light_id = ID("led1", is_declaration=True)
|
||||
token = _setup_cycle_final_validate(
|
||||
cycle_refs=[
|
||||
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
|
||||
],
|
||||
light_configs=[{CONF_ID: light_id}],
|
||||
declare_ids=[(light_id, ["light", 0, CONF_ID])],
|
||||
)
|
||||
try:
|
||||
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
|
||||
_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_final_validate_cycle_rejects_light_with_empty_effects() -> None:
|
||||
"""Cycle ref against a light with empty effects list should raise."""
|
||||
light_id = ID("led1", is_declaration=True)
|
||||
token = _setup_cycle_final_validate(
|
||||
cycle_refs=[
|
||||
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
|
||||
],
|
||||
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: []}],
|
||||
declare_ids=[(light_id, ["light", 0, CONF_ID])],
|
||||
)
|
||||
try:
|
||||
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
|
||||
_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_final_validate_cycle_unknown_light_id_skipped() -> None:
|
||||
"""Cycle refs to unknown light IDs should be silently skipped."""
|
||||
data = _get_data()
|
||||
data.effect_cycle_refs = [
|
||||
EffectCycleRef(
|
||||
light_id=ID("nonexistent", is_declaration=True),
|
||||
component_path=["esphome"],
|
||||
)
|
||||
]
|
||||
|
||||
full_conf = Config()
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_final_validate_drains_cycle_refs() -> None:
|
||||
"""Cycle refs should be drained after validation to avoid redundant runs."""
|
||||
light_id = ID("led1", is_declaration=True)
|
||||
token = _setup_cycle_final_validate(
|
||||
cycle_refs=[
|
||||
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
|
||||
],
|
||||
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
|
||||
declare_ids=[(light_id, ["light", 0, CONF_ID])],
|
||||
)
|
||||
try:
|
||||
_final_validate({})
|
||||
assert _get_data().effect_cycle_refs == []
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
# --- _record_effect_ref ---
|
||||
|
||||
|
||||
@@ -278,3 +387,19 @@ def test_record_effect_ref_skips_no_effect_key() -> None:
|
||||
config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)}
|
||||
_record_effect_ref(config)
|
||||
assert _get_data().effect_refs == []
|
||||
|
||||
|
||||
# --- _record_effect_cycle_ref ---
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_path_ctx")
|
||||
def test_record_effect_cycle_ref() -> None:
|
||||
"""Cycle-action config should be recorded with light_id and path."""
|
||||
light_id = ID("led1", is_declaration=True)
|
||||
config: ConfigType = {CONF_ID: light_id}
|
||||
result = _record_effect_cycle_ref(config)
|
||||
assert result is config
|
||||
data = _get_data()
|
||||
assert len(data.effect_cycle_refs) == 1
|
||||
assert data.effect_cycle_refs[0].light_id is light_id
|
||||
assert data.effect_cycle_refs[0].component_path == ["esphome"]
|
||||
|
||||
@@ -103,6 +103,16 @@ esphome:
|
||||
- light.turn_on:
|
||||
id: test_monochromatic_light
|
||||
effect: !lambda 'return iteration > 1 ? "Strobe" : "none";'
|
||||
# Cycle through configured effects (skip "None")
|
||||
- light.effect.next: test_monochromatic_light
|
||||
- light.effect.previous: test_monochromatic_light
|
||||
# Cycle through effects including "None"
|
||||
- light.effect.next:
|
||||
id: test_monochromatic_light
|
||||
include_none: true
|
||||
- light.effect.previous:
|
||||
id: test_monochromatic_light
|
||||
include_none: true
|
||||
- light.dim_relative:
|
||||
id: test_monochromatic_light
|
||||
relative_brightness: 5%
|
||||
|
||||
@@ -253,6 +253,106 @@ 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) YAML would be inert and
|
||||
leak secrets to disk for nothing."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.__main__.read_config",
|
||||
return_value={"esphome": {"name": "lite_test"}},
|
||||
),
|
||||
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{"upload": lambda args, config: 0},
|
||||
),
|
||||
):
|
||||
run_esphome(["esphome", "upload", str(yaml_path)])
|
||||
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["upload", "logs"])
|
||||
def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
|
||||
tmp_path: Path, command: str
|
||||
) -> None:
|
||||
"""A stale-cache fallback rewrites the cache so the next call hits
|
||||
the fast path. Without this, every upload/logs after a YAML edit
|
||||
pays for read_config() until the next compile rewrites the cache."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(storage_dir / "lite_test.yaml.json")
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
|
||||
_set_cache_mtime(cache, yaml_path, offset=-60) # stale
|
||||
|
||||
fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}}
|
||||
|
||||
with (
|
||||
patch("esphome.__main__.read_config", return_value=fresh_config),
|
||||
patch(
|
||||
"esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
|
||||
) as mock_save,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{command: lambda args, config: 0},
|
||||
),
|
||||
):
|
||||
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
|
||||
|
||||
mock_save.assert_called_once_with(fresh_config)
|
||||
# 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
|
||||
|
||||
|
||||
def test_run_esphome_upload_with_substitution_does_not_refresh_cache(
|
||||
fresh_cache_files: Path,
|
||||
) -> None:
|
||||
"""`-s` substitutions skip the cache on both read and write -- saving
|
||||
here would clobber the cache with a substitution-specific config."""
|
||||
with (
|
||||
patch("esphome.__main__.read_config", return_value={"esphome": {}}),
|
||||
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{"upload": lambda args, config: 0},
|
||||
),
|
||||
):
|
||||
run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)])
|
||||
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
def test_run_esphome_compile_does_not_refresh_cache_via_fallback(
|
||||
fresh_cache_files: Path,
|
||||
) -> None:
|
||||
"""Compile writes the cache through update_storage_json, not via the
|
||||
upload/logs fallback path -- the fallback save would skip the
|
||||
storage_should_clean check."""
|
||||
with (
|
||||
patch("esphome.__main__.read_config", return_value={"esphome": {}}),
|
||||
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{"compile": lambda args, config: 0},
|
||||
),
|
||||
):
|
||||
run_esphome(["esphome", "compile", str(fresh_cache_files)])
|
||||
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
def test_run_esphome_upload_with_substitution_skips_cache(
|
||||
fresh_cache_files: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -436,11 +436,21 @@ def test_convert_library_with_branch_ref():
|
||||
assert result.source.ref == "some-branch"
|
||||
|
||||
|
||||
def test_convert_library_missing_ref():
|
||||
def test_convert_library_missing_ref_uses_default_branch():
|
||||
"""A bare URL with no #ref clones the remote's default branch.
|
||||
|
||||
Matches PIO's lib_deps behavior and external_components handling --
|
||||
git.clone_or_update with ref=None leaves the depth-1 clone on
|
||||
whatever branch the remote HEAD points at.
|
||||
"""
|
||||
lib = Library("name", None, "https://github.com/foo/bar.git")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_convert_library_to_component(lib)
|
||||
result = _convert_library_to_component(lib)
|
||||
|
||||
assert result.name == "foo/bar"
|
||||
assert result.version == "*"
|
||||
assert isinstance(result.source, GitSource)
|
||||
assert result.source.ref is None
|
||||
|
||||
|
||||
def test_convert_library_registry(monkeypatch):
|
||||
@@ -495,3 +505,113 @@ def test_process_dependencies_skips_invalid(tmp_component):
|
||||
_process_dependencies(tmp_component)
|
||||
|
||||
assert tmp_component.dependencies == []
|
||||
|
||||
|
||||
def test_process_dependencies_dict_form(tmp_component, monkeypatch):
|
||||
"""PIO library.json shorthand ``{"owner/Name": "version"}`` is honored.
|
||||
|
||||
Iterating a dict gives string keys, which would silently fail the
|
||||
``"name" in dependency`` substring check. Normalize to list-of-dicts
|
||||
first so the dict form (used by e.g. tesla-ble for its nanopb dep)
|
||||
is treated the same as the verbose list form.
|
||||
"""
|
||||
captured: list[Library] = []
|
||||
|
||||
def fake_generate(library):
|
||||
captured.append(library)
|
||||
return IDFComponent(
|
||||
library.name, library.version, source=URLSource("http://dummy.com")
|
||||
)
|
||||
|
||||
tmp_component.data = {
|
||||
"dependencies": {
|
||||
"nanopb/Nanopb": "^0.4.91",
|
||||
"BareName": "1.2.3",
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component, "_generate_idf_component", fake_generate
|
||||
)
|
||||
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
|
||||
|
||||
_process_dependencies(tmp_component)
|
||||
|
||||
assert len(tmp_component.dependencies) == 2
|
||||
names = sorted(lib.name for lib in captured)
|
||||
versions = sorted(lib.version for lib in captured)
|
||||
assert names == ["BareName", "nanopb/Nanopb"]
|
||||
assert versions == ["1.2.3", "^0.4.91"]
|
||||
|
||||
|
||||
def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch):
|
||||
"""A dict-value that's a URL gets routed to ``repository`` like the list form."""
|
||||
captured: list[Library] = []
|
||||
|
||||
def fake_generate(library):
|
||||
captured.append(library)
|
||||
return IDFComponent(library.name, "*", source=URLSource("http://dummy.com"))
|
||||
|
||||
tmp_component.data = {
|
||||
"dependencies": {
|
||||
"foo/Bar": "https://github.com/foo/bar.git#main",
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component, "_generate_idf_component", fake_generate
|
||||
)
|
||||
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
|
||||
|
||||
_process_dependencies(tmp_component)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].name == "foo/Bar"
|
||||
assert captured[0].version is None
|
||||
assert captured[0].repository == "https://github.com/foo/bar.git#main"
|
||||
|
||||
|
||||
def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch):
|
||||
"""A dict-value that's itself a dict is merged into the entry.
|
||||
|
||||
PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}``
|
||||
for entries that need fields beyond just a version (platforms,
|
||||
frameworks, etc.). The extra fields flow into _check_library_data
|
||||
via the entry merge.
|
||||
"""
|
||||
captured: list[Library] = []
|
||||
checked: list[dict] = []
|
||||
|
||||
def fake_generate(library):
|
||||
captured.append(library)
|
||||
return IDFComponent(
|
||||
library.name, library.version, source=URLSource("http://dummy.com")
|
||||
)
|
||||
|
||||
tmp_component.data = {
|
||||
"dependencies": {
|
||||
"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"},
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component, "_generate_idf_component", fake_generate
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component,
|
||||
"_check_library_data",
|
||||
checked.append,
|
||||
)
|
||||
|
||||
_process_dependencies(tmp_component)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].name == "nanopb/Nanopb"
|
||||
assert captured[0].version == "^0.4.91"
|
||||
# Extra spec fields reach _check_library_data so platform/framework
|
||||
# gating still applies.
|
||||
assert checked == [
|
||||
{
|
||||
"name": "Nanopb",
|
||||
"owner": "nanopb",
|
||||
"version": "^0.4.91",
|
||||
"platforms": "espidf",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
import pytest
|
||||
|
||||
from esphome import storage_json
|
||||
from esphome.const import CONF_DISABLED, CONF_MDNS
|
||||
from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@@ -308,6 +308,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
|
||||
mock_core.loaded_platforms = {"sensor"}
|
||||
mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}}
|
||||
mock_core.target_framework = "esp-idf"
|
||||
mock_core.toolchain = Toolchain.ESP_IDF
|
||||
|
||||
with patch("esphome.components.esp32.get_esp32_variant") as mock_variant:
|
||||
mock_variant.return_value = "ESP32-C3"
|
||||
@@ -327,6 +328,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
|
||||
assert result.no_mdns is True
|
||||
assert result.framework == "esp-idf"
|
||||
assert result.core_platform == "esp32"
|
||||
assert result.toolchain == "esp-idf"
|
||||
|
||||
|
||||
def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
|
||||
@@ -345,10 +347,12 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
|
||||
mock_core.loaded_platforms = set()
|
||||
mock_core.config = {} # No MDNS config means enabled
|
||||
mock_core.target_framework = "arduino"
|
||||
mock_core.toolchain = None
|
||||
|
||||
result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None)
|
||||
|
||||
assert result.no_mdns is False
|
||||
assert result.toolchain is None
|
||||
|
||||
|
||||
def test_storage_json_load_valid_file(tmp_path: Path) -> None:
|
||||
@@ -470,6 +474,73 @@ def test_storage_json_equality() -> None:
|
||||
assert storage1 != "not a storage object"
|
||||
|
||||
|
||||
def _make_storage_with_toolchain(
|
||||
toolchain: str | None,
|
||||
) -> storage_json.StorageJSON:
|
||||
return storage_json.StorageJSON(
|
||||
storage_version=1,
|
||||
name="dev",
|
||||
friendly_name=None,
|
||||
comment=None,
|
||||
esphome_version="2024.1.0",
|
||||
src_version=1,
|
||||
address="dev.local",
|
||||
web_port=None,
|
||||
target_platform="ESP32",
|
||||
build_path=Path("/build"),
|
||||
firmware_bin_path=Path("/build/firmware.bin"),
|
||||
loaded_integrations=set(),
|
||||
loaded_platforms=set(),
|
||||
no_mdns=False,
|
||||
framework="esp-idf",
|
||||
core_platform="esp32",
|
||||
toolchain=toolchain,
|
||||
)
|
||||
|
||||
|
||||
def test_storage_json_toolchain_round_trip(setup_core: Path) -> None:
|
||||
"""Sidecar toolchain survives save -> load -> apply_to_core."""
|
||||
storage = _make_storage_with_toolchain("esp-idf")
|
||||
path = setup_core / "storage.json"
|
||||
path.write_text(storage.to_json())
|
||||
|
||||
# Serialization key is stable -- device-builder relies on it.
|
||||
assert json.loads(path.read_text())["toolchain"] == "esp-idf"
|
||||
|
||||
loaded = storage_json.StorageJSON.load(path)
|
||||
assert loaded is not None
|
||||
assert loaded.toolchain == "esp-idf"
|
||||
|
||||
CORE.toolchain = None
|
||||
with patch("esphome.components.esp32.get_esp32_variant"):
|
||||
loaded.apply_to_core()
|
||||
assert CORE.toolchain == Toolchain.ESP_IDF
|
||||
|
||||
|
||||
def test_storage_json_apply_to_core_preserves_cli_toolchain(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A CLI-set CORE.toolchain wins over the sidecar value."""
|
||||
loaded = _make_storage_with_toolchain("esp-idf")
|
||||
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
with patch("esphome.components.esp32.get_esp32_variant"):
|
||||
loaded.apply_to_core()
|
||||
assert CORE.toolchain == Toolchain.PLATFORMIO
|
||||
|
||||
|
||||
def test_storage_json_apply_to_core_ignores_unknown_toolchain(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Unknown enum values (corrupt sidecar / newer ESPHome) fall through to None."""
|
||||
loaded = _make_storage_with_toolchain("gcc")
|
||||
|
||||
CORE.toolchain = None
|
||||
with patch("esphome.components.esp32.get_esp32_variant"):
|
||||
loaded.apply_to_core()
|
||||
assert CORE.toolchain is None
|
||||
|
||||
|
||||
def test_esphome_storage_json_as_dict() -> None:
|
||||
"""Test EsphomeStorageJSON.as_dict returns correct dictionary."""
|
||||
storage = storage_json.EsphomeStorageJSON(
|
||||
|
||||
@@ -34,6 +34,14 @@ def clear_secrets_cache() -> None:
|
||||
yaml_util._SECRET_CACHE.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_core_frontmatter() -> None:
|
||||
"""Reset CORE.frontmatter between tests."""
|
||||
core.CORE.frontmatter = {}
|
||||
yield
|
||||
core.CORE.frontmatter = {}
|
||||
|
||||
|
||||
def test_include_with_vars(fixture_path: Path) -> None:
|
||||
yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
|
||||
|
||||
@@ -1182,3 +1190,153 @@ def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None:
|
||||
with track_yaml_loads() as loaded:
|
||||
yaml_util.load_yaml(link)
|
||||
assert target.resolve() in loaded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML frontmatter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None:
|
||||
"""A leading `---`-separated YAML document is stored as frontmatter and
|
||||
stripped from the returned config."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text(
|
||||
"author: Jesse\nlabels: [office, climate]\n---\nesphome:\n name: my_node\n"
|
||||
)
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
|
||||
# Config does not contain frontmatter keys
|
||||
assert "author" not in config
|
||||
assert "labels" not in config
|
||||
assert config["esphome"]["name"] == "my_node"
|
||||
|
||||
# Frontmatter is stored on CORE keyed by resolved path
|
||||
frontmatter = core.CORE.frontmatter[yaml_file.resolve()]
|
||||
assert frontmatter["author"] == "Jesse"
|
||||
assert frontmatter["labels"] == ["office", "climate"]
|
||||
|
||||
|
||||
def test_frontmatter_absent_when_single_document(tmp_path: Path) -> None:
|
||||
"""A YAML file with a single document does not populate CORE.frontmatter."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("esphome:\n name: my_node\n")
|
||||
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
assert yaml_file.resolve() not in core.CORE.frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_absent_when_leading_doc_separator(tmp_path: Path) -> None:
|
||||
"""A leading `---` with no content above it is just a document start marker,
|
||||
not frontmatter, and must not populate CORE.frontmatter."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("---\nesphome:\n name: my_node\n")
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
assert config["esphome"]["name"] == "my_node"
|
||||
assert yaml_file.resolve() not in core.CORE.frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None:
|
||||
"""Frontmatter keys are not validated — any structure is accepted."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text(
|
||||
"any_key: any_value\n"
|
||||
"nested:\n"
|
||||
" count: 42\n"
|
||||
" items:\n"
|
||||
" - a\n"
|
||||
" - b\n"
|
||||
"---\n"
|
||||
"esphome:\n"
|
||||
" name: t\n"
|
||||
)
|
||||
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
frontmatter = core.CORE.frontmatter[yaml_file.resolve()]
|
||||
assert frontmatter["any_key"] == "any_value"
|
||||
assert frontmatter["nested"]["count"] == 42
|
||||
assert frontmatter["nested"]["items"] == ["a", "b"]
|
||||
|
||||
|
||||
def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None:
|
||||
"""Frontmatter preserves deeply nested dict/list structures intact."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text(
|
||||
"device:\n"
|
||||
" metadata:\n"
|
||||
" location:\n"
|
||||
" building: HQ\n"
|
||||
" floor: 3\n"
|
||||
" room:\n"
|
||||
" number: 302\n"
|
||||
" occupants:\n"
|
||||
" - name: Jesse\n"
|
||||
" role:\n"
|
||||
" title: maintainer\n"
|
||||
" since: 2021\n"
|
||||
" - name: Alice\n"
|
||||
" role:\n"
|
||||
" title: contributor\n"
|
||||
" since: 2024\n"
|
||||
"---\n"
|
||||
"esphome:\n"
|
||||
" name: t\n"
|
||||
)
|
||||
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
fm = core.CORE.frontmatter[yaml_file.resolve()]
|
||||
room = fm["device"]["metadata"]["location"]["room"]
|
||||
assert room["number"] == 302
|
||||
assert room["occupants"][0]["name"] == "Jesse"
|
||||
assert room["occupants"][0]["role"]["title"] == "maintainer"
|
||||
assert room["occupants"][0]["role"]["since"] == 2021
|
||||
assert room["occupants"][1]["role"]["title"] == "contributor"
|
||||
|
||||
|
||||
def test_frontmatter_more_than_two_documents_raises(tmp_path: Path) -> None:
|
||||
"""Three or more YAML documents is unsupported and must raise."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("a: 1\n---\nb: 2\n---\nc: 3\n")
|
||||
|
||||
with pytest.raises(EsphomeError, match="at most two are supported"):
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
|
||||
|
||||
def test_frontmatter_empty_frontmatter_doc_not_stored(tmp_path: Path) -> None:
|
||||
"""An empty (null) frontmatter document is treated as no frontmatter."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("---\n---\nesphome:\n name: t\n")
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
assert config["esphome"]["name"] == "t"
|
||||
assert yaml_file.resolve() not in core.CORE.frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_empty_config_doc(tmp_path: Path) -> None:
|
||||
"""An empty config document after a frontmatter document yields an empty config."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("only: frontmatter\n---\n")
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
assert config == {}
|
||||
assert core.CORE.frontmatter[yaml_file.resolve()]["only"] == "frontmatter"
|
||||
|
||||
|
||||
def test_frontmatter_included_file_stored(tmp_path: Path) -> None:
|
||||
"""Frontmatter on an !include'd file is also captured on CORE, keyed by
|
||||
that file's resolved path."""
|
||||
inc = tmp_path / "child.yaml"
|
||||
inc.write_text("child_meta: hello\n---\nchild_key: value\n")
|
||||
main = tmp_path / "main.yaml"
|
||||
main.write_text("esphome:\n name: t\nchild: !include child.yaml\n")
|
||||
|
||||
config = yaml_util.load_yaml(main)
|
||||
# !include is deferred; force resolution so the child file actually loads
|
||||
force_load_include_files(config)
|
||||
assert config["child"].load()["child_key"] == "value"
|
||||
# Main file has no frontmatter
|
||||
assert main.resolve() not in core.CORE.frontmatter
|
||||
# Included file's frontmatter is captured
|
||||
assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello"
|
||||
|
||||
Reference in New Issue
Block a user