diff --git a/esphome/codegen.py b/esphome/codegen.py index 2aa6a70abd..5debb52b4e 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -78,6 +78,7 @@ from esphome.cpp_types import ( # noqa: F401 StringRef, arduino_json_ns, bool_, + char, const_char_ptr, double, esphome_ns, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 2e891a9663..3568318dad 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -5,6 +5,7 @@ from typing import Any from esphome import automation from esphome.automation import Condition import esphome.codegen as cg +from esphome.components.const import CONF_DESCRIPTION from esphome.components.logger import request_log_listener # ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external @@ -41,10 +42,12 @@ from esphome.const import ( CONF_TAG, CONF_THEN, CONF_TRIGGER_ID, + CONF_TYPE, CONF_VARIABLES, ) from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.helpers import fnv1_hash from esphome.types import ConfigFragmentType, ConfigType # Compat alias: downstream consumers (e.g. device-builder) referenced the @@ -125,6 +128,7 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = { } CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" +CONF_EXAMPLE = "example" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" CONF_LISTEN_BACKLOG = "listen_backlog" @@ -228,14 +232,30 @@ def _validate_supports_response(value: Any) -> str: return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) +# ESP8266 copies every string of an action into a stack buffer sized by codegen; keep it small +ESP8266_ACTION_STRINGS_MAX_TOTAL = 384 + +VARIABLE_SCHEMA = cv.Schema( + { + cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), + cv.Optional(CONF_DESCRIPTION): cv.string_strict, + cv.Optional(CONF_EXAMPLE): cv.string_strict, + } +) + +# Accepts the plain `name: type` shorthand or the full mapping form +validate_variable = cv.maybe_simple_value(VARIABLE_SCHEMA, key=CONF_TYPE) + + ACTIONS_SCHEMA = automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UserServiceTrigger), cv.Exclusive(CONF_SERVICE, group_of_exclusion=CONF_ACTION): cv.valid_name, cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.valid_name, + cv.Optional(CONF_DESCRIPTION): cv.string_strict, cv.Optional(CONF_VARIABLES, default={}): cv.Schema( { - cv.validate_id_name: cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), + cv.validate_id_name: validate_variable, } ), # No default - auto-detected by _auto_detect_supports_response @@ -352,6 +372,85 @@ CONFIG_SCHEMA = cv.All( ) +def _has_action_metadata(actions: list[ConfigType]) -> bool: + # Empty strings count as unset, matching _action_strings + return any( + conf.get(CONF_DESCRIPTION) + or any( + var_.get(CONF_DESCRIPTION) or var_.get(CONF_EXAMPLE) + for var_ in conf[CONF_VARIABLES].values() + ) + for conf in actions + ) + + +def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]: + """Strings of one action in the table order UserServiceStatic (user_services.h) expects.""" + # An empty description or example is treated as unset + strings: list[str | None] = [conf[CONF_ACTION]] + if has_metadata: + strings.append(conf.get(CONF_DESCRIPTION) or None) + for name, var_ in conf[CONF_VARIABLES].items(): + strings.append(name) + if has_metadata: + strings += [ + var_.get(CONF_DESCRIPTION) or None, + var_.get(CONF_EXAMPLE) or None, + ] + return strings + + +def _action_strings_size(strings: list[str | None]) -> int: + """Bytes needed to copy every string out of flash, each with its terminator.""" + return sum( + len(string.encode("utf-8")) + 1 for string in strings if string is not None + ) + + +def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType: + if not CORE.is_esp8266: + return config + actions = config.get(CONF_ACTIONS, []) + has_metadata = _has_action_metadata(actions) + for conf in actions: + size = _action_strings_size(_action_strings(conf, has_metadata)) + if size > ESP8266_ACTION_STRINGS_MAX_TOTAL: + raise cv.Invalid( + f"Action '{conf[CONF_ACTION]}' has {size} bytes of name, variable name, " + f"description and example text; ESP8266 allows at most " + f"{ESP8266_ACTION_STRINGS_MAX_TOTAL} bytes per action" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings + + +def _add_action_strings( + index: int, strings: list[str | None], interned: dict[str, MockObj] +) -> MockObj: + """Emit the PROGMEM string table for one action. + + Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical + strings are shared between actions through `interned`. + """ + entries: list[MockObj] = [] + for string in strings: + if string is None: + entries.append(cg.nullptr) + continue + if (var := interned.get(string)) is None: + var = interned[string] = cg.progmem_array( + ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char), + string, + ) + entries.append(var) + return cg.progmem_array( + ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr), + entries, + ) + + @coroutine_with_priority(CoroPriority.WEB) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) @@ -371,8 +470,10 @@ async def to_code(config: ConfigType) -> None: cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) + actions = config.get(CONF_ACTIONS, []) + has_user_actions = bool(actions) or config[CONF_CUSTOM_SERVICES] # Set USE_API_USER_DEFINED_ACTIONS if any services are enabled - if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: + if has_user_actions: cg.add_define("USE_API_USER_DEFINED_ACTIONS") # Set USE_API_CUSTOM_SERVICES if external components need dynamic service registration @@ -385,10 +486,17 @@ async def to_code(config: ConfigType) -> None: if config[CONF_HOMEASSISTANT_STATES]: cg.add_define("USE_API_HOMEASSISTANT_STATES") - if actions := config.get(CONF_ACTIONS, []): + scratch_size = 0 + if actions: + # Metadata is compiled in for every action once any action declares it, because the + # string table layout is fixed by the define rather than per action + has_metadata = _has_action_metadata(actions) + if has_metadata: + cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA") + interned_strings: dict[str, MockObj] = {} # Collect all triggers first, then register all at once with initializer_list triggers: list[cg.MockObj] = [] - for conf in actions: + for index, conf in enumerate(actions): func_args: list[tuple[MockObj, str]] = [] service_template_args: list[MockObj] = [] # User service argument types @@ -421,22 +529,23 @@ async def to_code(config: ConfigType) -> None: conf.get(CONF_THEN, []) ) - service_arg_names: list[str] = [] for name, var_ in conf[CONF_VARIABLES].items(): - if has_non_synchronous and var_ in SERVICE_ARG_FALLBACK_TYPES: - native = SERVICE_ARG_FALLBACK_TYPES[var_] + var_type = var_[CONF_TYPE] + if has_non_synchronous and var_type in SERVICE_ARG_FALLBACK_TYPES: + native = SERVICE_ARG_FALLBACK_TYPES[var_type] else: - native = SERVICE_ARG_NATIVE_TYPES[var_] + native = SERVICE_ARG_NATIVE_TYPES[var_type] service_template_args.append(native) func_args.append((native, name)) - service_arg_names.append(name) + strings = _action_strings(conf, has_metadata) + table = _add_action_strings(index, strings, interned_strings) + if CORE.is_esp8266: + scratch_size = max(scratch_size, _action_strings_size(strings)) # Template args: supports_response mode, then user service arg types templ = cg.TemplateArguments(supports_response, *service_template_args) + # Key is hashed here because the name is not readable at runtime on ESP8266 trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], - templ, - conf[CONF_ACTION], - service_arg_names, + conf[CONF_TRIGGER_ID], templ, table, fnv1_hash(conf[CONF_ACTION]) ) triggers.append(trigger) auto = await automation.build_automation(trigger, func_args, conf) @@ -458,6 +567,9 @@ async def to_code(config: ConfigType) -> None: cg.add(auto.add_actions([unregister_action])) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) + if CORE.is_esp8266 and has_user_actions: + # Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action + cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1)) if CONF_ON_CLIENT_CONNECTED in config: cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER") diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c11700782e..3a0e0abea9 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1034,6 +1034,8 @@ message ListEntitiesServicesArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; string name = 1; ServiceArgType type = 2; + string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; + string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; } message ListEntitiesServicesResponse { option (id) = 41; @@ -1044,6 +1046,7 @@ message ListEntitiesServicesResponse { fixed32 key = 2 [(force) = true]; repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true]; SupportsResponseType supports_response = 4; + string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; } message ExecuteServiceArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f56d791b67..2de1f0a15c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1275,12 +1275,24 @@ uint8_t *ListEntitiesServicesArgument::encode(ProtoWriteBuffer &buffer PROTO_ENC uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->description); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->example); +#endif return pos; } uint32_t ListEntitiesServicesArgument::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->name.size()); size += this->type ? 2 : 0; +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + size += ProtoSize::calc_length(1, this->description.size()); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + size += ProtoSize::calc_length(1, this->example.size()); +#endif return size; } uint8_t *ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1291,6 +1303,9 @@ uint8_t *ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENC ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->supports_response)); +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->description); +#endif return pos; } uint32_t ListEntitiesServicesResponse::calculate_size() const { @@ -1303,6 +1318,9 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const { } } size += this->supports_response ? 2 : 0; +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + size += ProtoSize::calc_length(1, this->description.size()); +#endif return size; } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index bed28d2956..5c3429a63a 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1317,6 +1317,12 @@ class ListEntitiesServicesArgument final : public ProtoMessage { public: StringRef name{}; enums::ServiceArgType type{}; +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + StringRef description{}; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + StringRef example{}; +#endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1328,7 +1334,7 @@ class ListEntitiesServicesArgument final : public ProtoMessage { class ListEntitiesServicesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 41; - static constexpr uint8_t ESTIMATED_SIZE = 50; + static constexpr uint8_t ESTIMATED_SIZE = 59; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); } #endif @@ -1336,6 +1342,9 @@ class ListEntitiesServicesResponse final : public ProtoMessage { uint32_t key{0}; FixedVector args{}; enums::SupportsResponseType supports_response{}; +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + StringRef description{}; +#endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 846c0ad652..dced81ee30 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1500,6 +1500,12 @@ const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesArgument")); dump_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("type"), static_cast(this->type)); +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + dump_field(out, ESPHOME_PSTR("description"), this->description); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + dump_field(out, ESPHOME_PSTR("example"), this->example); +#endif return out.c_str(); } const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const { @@ -1512,6 +1518,9 @@ const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const { out.append("\n"); } dump_field(out, ESPHOME_PSTR("supports_response"), static_cast(this->supports_response)); +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + dump_field(out, ESPHOME_PSTR("description"), this->description); +#endif return out.c_str(); } const char *ExecuteServiceArgument::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index 57ff616ca7..507b098fb4 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -99,7 +99,8 @@ ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(clie static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3; bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) { - auto resp = service->encode_list_service_response(); + UserActionScratch scratch; + auto resp = service->encode_list_service_response(scratch); if (!this->client_->send_message(resp)) return false; // at_ is this service's index diff --git a/esphome/components/api/user_services.cpp b/esphome/components/api/user_services.cpp index 28a43c656c..fad3cde29b 100644 --- a/esphome/components/api/user_services.cpp +++ b/esphome/components/api/user_services.cpp @@ -1,9 +1,52 @@ #include "user_services.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" namespace esphome::api { +StringRef UserServiceStatic::str_(size_t idx, std::span &scratch) const { + const char *s = progmem_read_ptr(&this->strings_[idx]); + if (s == nullptr) + return {}; +#ifdef USE_ESP8266 + // Codegen sizes the scratch buffer for the largest service; the bound only guards other callers + if (scratch.empty()) + return {}; + size_t len = strnlen_P(s, scratch.size() - 1); + progmem_memcpy(scratch.data(), s, len); + scratch[len] = '\0'; + StringRef ref(scratch.data(), len); + scratch = scratch.subspan(len + 1); + return ref; +#else + return StringRef(s); +#endif +} + +ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_( + std::span arg_types, std::span scratch) const { + ListEntitiesServicesResponse msg; + msg.name = this->str_(0, scratch); + msg.key = this->key_; + msg.supports_response = this->supports_response_; +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + msg.description = this->str_(1, scratch); +#endif + msg.args.init(arg_types.size()); + for (size_t i = 0; i < arg_types.size(); i++) { + size_t base = USER_ACTION_HEADER_STRINGS + i * USER_ACTION_ARG_STRINGS; + auto &arg = msg.args.emplace_back(); + arg.type = arg_types[i]; + arg.name = this->str_(base, scratch); +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + arg.description = this->str_(base + 1, scratch); + arg.example = this->str_(base + 2, scratch); +#endif + } + return msg; +} + template<> bool get_execute_arg_value(const ExecuteServiceArgument &arg) { return arg.bool_; } template<> int32_t get_execute_arg_value(const ExecuteServiceArgument &arg) { if (arg.legacy_int != 0) diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index ea57d0944b..3b17bdb7bc 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -19,7 +20,9 @@ class APIServer; class UserServiceDescriptor { public: - virtual ListEntitiesServicesResponse encode_list_service_response() = 0; + /// Build the list-entities message. On ESP8266 the strings live in PROGMEM and are copied into + /// `scratch`, so the returned message is only valid while `scratch` is; other platforms ignore it. + virtual ListEntitiesServicesResponse encode_list_service_response(std::span scratch) = 0; virtual bool execute_service(const ExecuteServiceRequest &req) = 0; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -34,29 +37,51 @@ template T get_execute_arg_value(const ExecuteServiceArgument &arg); template enums::ServiceArgType to_service_arg_type(); -// Base class for YAML-defined services (most common case) -// Stores only pointers to string literals in flash - no heap allocation -template class UserServiceBase : public UserServiceDescriptor { - public: - UserServiceBase(const char *name, const std::array &arg_names, - enums::SupportsResponseType supports_response = enums::SUPPORTS_RESPONSE_NONE) - : name_(name), arg_names_(arg_names), supports_response_(supports_response) { - this->key_ = fnv1_hash(name); - } +// Scratch buffer list-entities hands to encode_list_service_response(); only ESP8266 copies into it +#ifdef USE_ESP8266 +using UserActionScratch = std::array; +#else +using UserActionScratch = std::array; +#endif - ListEntitiesServicesResponse encode_list_service_response() override { - ListEntitiesServicesResponse msg; - msg.name = StringRef(this->name_); - msg.key = this->key_; - msg.supports_response = this->supports_response_; +// Non-template base for YAML-defined services so the list-entities encoder is compiled once. +// All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in +// __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then +// each argument name; with USE_API_USER_DEFINED_ACTION_METADATA the action description follows +// the name and every argument is (name, description, example). Unset metadata is nullptr. +#ifdef USE_API_USER_DEFINED_ACTION_METADATA +static constexpr size_t USER_ACTION_HEADER_STRINGS = 2; +static constexpr size_t USER_ACTION_ARG_STRINGS = 3; +#else +static constexpr size_t USER_ACTION_HEADER_STRINGS = 1; +static constexpr size_t USER_ACTION_ARG_STRINGS = 1; +#endif +class UserServiceStatic : public UserServiceDescriptor { + public: + UserServiceStatic(const char *const *strings, uint32_t key, + enums::SupportsResponseType supports_response = enums::SUPPORTS_RESPONSE_NONE) + : strings_(strings), key_(key), supports_response_(supports_response) {} + + protected: + ListEntitiesServicesResponse encode_list_service_response_(std::span arg_types, + std::span scratch) const; + /// Reference table entry `idx`; nullptr gives an empty StringRef. + /// On ESP8266 the bytes are copied out of PROGMEM into `scratch` with a terminator, and the span + /// is advanced past the copy. + StringRef str_(size_t idx, std::span &scratch) const; + + const char *const *strings_; // PROGMEM pointer table, read with progmem_read_ptr() + uint32_t key_; + enums::SupportsResponseType supports_response_; +}; + +template class UserServiceBase : public UserServiceStatic { + public: + using UserServiceStatic::UserServiceStatic; + + ListEntitiesServicesResponse encode_list_service_response(std::span scratch) override { std::array arg_types = {to_service_arg_type()...}; - msg.args.init(sizeof...(Ts)); - for (size_t i = 0; i < sizeof...(Ts); i++) { - auto &arg = msg.args.emplace_back(); - arg.type = arg_types[i]; - arg.name = StringRef(this->arg_names_[i]); - } - return msg; + return this->encode_list_service_response_(arg_types, scratch); } bool execute_service(const ExecuteServiceRequest &req) override { @@ -89,12 +114,6 @@ template class UserServiceBase : public UserServiceDescriptor { void execute_(const ArgsContainer &args, uint32_t call_id, bool return_response, std::index_sequence /*type*/) { this->execute(call_id, return_response, (get_execute_arg_value(args[S]))...); } - - // Pointers to string literals in flash - no heap allocation - const char *name_; - std::array arg_names_; - uint32_t key_{0}; - enums::SupportsResponseType supports_response_{enums::SUPPORTS_RESPONSE_NONE}; }; // Separate class for custom_api_device services (rare case) @@ -106,7 +125,7 @@ template class UserServiceDynamic : public UserServiceDescriptor this->key_ = fnv1_hash(this->name_.c_str()); } - ListEntitiesServicesResponse encode_list_service_response() override { + ListEntitiesServicesResponse encode_list_service_response(std::span /*scratch*/) override { ListEntitiesServicesResponse msg; msg.name = StringRef(this->name_); msg.key = this->key_; @@ -167,8 +186,8 @@ template class UserServiceTrigger final : public UserServiceBase, public Trigger { public: - UserServiceTrigger(const char *name, const std::array &arg_names) - : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {} + UserServiceTrigger(const char *const *strings, uint32_t key) + : UserServiceBase(strings, key, enums::SUPPORTS_RESPONSE_NONE) {} protected: void execute(uint32_t /*call_id*/, bool /*return_response*/, Ts... x) override { this->trigger(x...); } @@ -179,8 +198,8 @@ template class UserServiceTrigger final : public UserServiceBase, public Trigger { public: - UserServiceTrigger(const char *name, const std::array &arg_names) - : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {} + UserServiceTrigger(const char *const *strings, uint32_t key) + : UserServiceBase(strings, key, enums::SUPPORTS_RESPONSE_OPTIONAL) {} protected: void execute(uint32_t call_id, bool return_response, Ts... x) override { @@ -193,8 +212,8 @@ template class UserServiceTrigger final : public UserServiceBase, public Trigger { public: - UserServiceTrigger(const char *name, const std::array &arg_names) - : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_ONLY) {} + UserServiceTrigger(const char *const *strings, uint32_t key) + : UserServiceBase(strings, key, enums::SUPPORTS_RESPONSE_ONLY) {} protected: void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); } @@ -205,8 +224,8 @@ template class UserServiceTrigger final : public UserServiceBase, public Trigger { public: - UserServiceTrigger(const char *name, const std::array &arg_names) - : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_STATUS) {} + UserServiceTrigger(const char *const *strings, uint32_t key) + : UserServiceBase(strings, key, enums::SUPPORTS_RESPONSE_STATUS) {} protected: void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); } diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 10710c8d29..e445a4abde 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -16,6 +16,7 @@ CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" +CONF_DESCRIPTION = "description" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 48bb7bf6a1..b0290d7a84 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -246,7 +246,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_https_server", # HTTPS server - ESPHome has its own web server "esp_lcd", # LCD controller drivers - only needed by display component "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API - "esp_phy", # RF PHY - esp_wifi/bt/ieee802154 pull it back when they are in the build + "esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage @@ -3249,7 +3249,13 @@ def _write_sdkconfig(): if write_file_if_changed(internal_path, contents): # internal changed, update real one write_file_if_changed(sdk_path, contents) - clean_build(clear_pio_cache=False) + if not CORE.using_toolchain_esp_idf: + # PIO's dependency tracking under-declares sdkconfig inputs + # (ldgen, linker scripts); without a clean the image can be + # unbootable (esphome#15336). The esp-idf toolchain tracks + # sdkconfig via IDF's cmake and has_outdated_files(), so a + # reconfigure suffices there; everything else fails safe. + clean_build(clear_pio_cache=False) def _write_idf_component_yml(): diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index d3101f4a7c..40ac216f0c 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -1,5 +1,7 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component +from esphome.components.esp32.const import VARIANT_ESP32 from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -48,6 +50,10 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) + if CORE.is_esp32 and get_esp32_variant() == VARIANT_ESP32: + # temprature_sens_read() lives in the esp_phy blob, which is excluded by default + include_builtin_idf_component("esp_phy") + if CORE.using_zephyr and CORE.is_nrf52: zephyr_add_prj_conf("SENSOR", True) zephyr_add_prj_conf("TEMP_NRF5", True) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 81a4d2b4ab..61d15752be 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -483,6 +483,7 @@ LV_ANIM = LvConstant( LV_GRAD_DIR = LvConstant("LV_GRAD_DIR_", "NONE", "HOR", "VER") LV_DITHER = LvConstant("LV_DITHER_", "NONE", "ORDERED", "ERR_DIFF") +LV_GRAD_EXTEND = LvConstant("LV_GRAD_EXTEND_", "PAD", "REPEAT", "REFLECT") LV_LOG_LEVELS = { "VERBOSE": "TRACE", @@ -904,7 +905,7 @@ LV_COLOR_FORMATS = ( LV_DEFINES = ( "LV_USE_FREERTOS_TASK_NOTIFY", "LV_DRAW_BUF_STRIDE_ALIGN", "LV_USE_DRAW_SW", "LV_DRAW_SW_DRAW_UNIT_CNT", - "LV_DRAW_SW_COMPLEX", "LV_USE_DRAW_PXP", "LV_USE_PXP_DRAW_THREAD", "LV_USE_DRAW_G2D", + "LV_DRAW_SW_COMPLEX", "LV_USE_DRAW_SW_COMPLEX_GRADIENTS", "LV_USE_DRAW_PXP", "LV_USE_PXP_DRAW_THREAD", "LV_USE_DRAW_G2D", "LV_USE_G2D_DRAW_THREAD", "LV_VG_LITE_USE_BOX_SHADOW", "LV_VG_LITE_THORVG_16PIXELS_ALIGN", "LV_LOG_USE_TIMESTAMP", "LV_LOG_USE_FILE_LINE", "LV_USE_OBJ_ID_BUILTIN", "LV_USE_OBJ_PROPERTY_NAME", "LV_ATTRIBUTE_MEM_ALIGN_SIZE", "LV_FONT_MONTSERRAT_14", "LV_USE_FONT_PLACEHOLDER", "LV_WIDGETS_HAS_DEFAULT_VALUE", "LV_USE_ARCLABEL", diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index 2f1be20772..8db183fabe 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -13,18 +13,40 @@ from esphome.core import ID from esphome.cpp_generator import MockObj from .defines import ( + CONF_END_ANGLE, CONF_GRADIENTS, CONF_OPA, + CONF_START_ANGLE, LV_DITHER, + LV_GRAD_EXTEND, add_define, add_lv_use, add_warning, ) -from .lv_validation import lv_color, lv_percentage, opacity +from .lv_validation import ( + lv_angle_degrees, + lv_color, + lv_percentage, + opacity, + pixels_or_percent, +) from .lvcode import lv from .types import lv_color_t, lv_gradient_t, lv_opa_t CONF_STOPS = "stops" +CONF_LINEAR = "linear" +CONF_RADIAL = "radial" +CONF_CONICAL = "conical" +CONF_EXTEND = "extend" +CONF_FROM_X = "from_x" +CONF_FROM_Y = "from_y" +CONF_TO_X = "to_x" +CONF_TO_Y = "to_y" +CONF_CENTER_X = "center_x" +CONF_CENTER_Y = "center_y" +CONF_FOCAL_X = "focal_x" +CONF_FOCAL_Y = "focal_y" +CONF_FOCAL_RADIUS = "focal_radius" def min_stops(value): @@ -33,27 +55,109 @@ def min_stops(value): return value +STOPS_SCHEMA = cv.All( + [ + cv.Schema( + { + cv.Required(CONF_COLOR): lv_color, + cv.Optional(CONF_OPA, default=1.0): opacity, + cv.Required(CONF_POSITION): lv_percentage, + } + ) + ], + min_stops, +) + +LINEAR_SCHEMA = cv.Schema( + { + cv.Required(CONF_FROM_X): pixels_or_percent, + cv.Required(CONF_FROM_Y): pixels_or_percent, + cv.Required(CONF_TO_X): pixels_or_percent, + cv.Required(CONF_TO_Y): pixels_or_percent, + cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of, + } +) + +RADIAL_SCHEMA = cv.Schema( + { + cv.Required(CONF_CENTER_X): pixels_or_percent, + cv.Required(CONF_CENTER_Y): pixels_or_percent, + cv.Required(CONF_TO_X): pixels_or_percent, + cv.Required(CONF_TO_Y): pixels_or_percent, + cv.Optional(CONF_FOCAL_X): pixels_or_percent, + cv.Optional(CONF_FOCAL_Y): pixels_or_percent, + # No default: gradient_validator() must be able to tell whether this was actually + # given, to require it alongside focal_x/focal_y rather than silently drop it. + # LVGL's lv_grad_radial_set_focal() takes this as a scalar, not lv_pct() - + # unlike every other coordinate here, a percentage is not accepted. + cv.Optional(CONF_FOCAL_RADIUS): cv.positive_int, + cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of, + } +) + +CONICAL_SCHEMA = cv.Schema( + { + cv.Required(CONF_CENTER_X): pixels_or_percent, + cv.Required(CONF_CENTER_Y): pixels_or_percent, + cv.Optional(CONF_START_ANGLE, default=0): lv_angle_degrees, + cv.Optional(CONF_END_ANGLE, default=360): lv_angle_degrees, + cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of, + } +) + + +def gradient_validator(config): + direction = config[CONF_DIRECTION] + for gradient_direction, key in ( + ("LINEAR", CONF_LINEAR), + ("RADIAL", CONF_RADIAL), + ("CONICAL", CONF_CONICAL), + ): + if direction == gradient_direction: + if key not in config: + raise cv.Invalid( + f"'{key}' is required for {gradient_direction} gradient direction" + ) + elif key in config: + raise cv.Invalid( + f"'{key}' is only valid with 'direction: {gradient_direction}'" + ) + if CONF_RADIAL in config: + radial = config[CONF_RADIAL] + has_focal_x = CONF_FOCAL_X in radial + has_focal_y = CONF_FOCAL_Y in radial + has_focal_radius = CONF_FOCAL_RADIUS in radial + if has_focal_x != has_focal_y or (has_focal_radius and not has_focal_x): + raise cv.Invalid( + "'focal_x', 'focal_y' and 'focal_radius' must be specified together " + "in 'radial'" + ) + return config + + GRADIENT_SCHEMA = cv.ensure_list( - cv.Schema( - { - cv.GenerateID(CONF_ID): cv.declare_id(lv_gradient_t), - cv.Required(CONF_DIRECTION): cv.one_of( - "HOR", "HORIZONTAL", "VER", "VERTICAL", upper=True - ), - cv.Optional(CONF_DITHER): LV_DITHER.one_of, - cv.Required(CONF_STOPS): cv.All( - [ - cv.Schema( - { - cv.Required(CONF_COLOR): lv_color, - cv.Optional(CONF_OPA, default=1.0): opacity, - cv.Required(CONF_POSITION): lv_percentage, - } - ) - ], - min_stops, - ), - } + cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(lv_gradient_t), + cv.Required(CONF_DIRECTION): cv.one_of( + "HOR", + "HORIZONTAL", + "VER", + "VERTICAL", + "LINEAR", + "RADIAL", + "CONICAL", + upper=True, + ), + cv.Optional(CONF_DITHER): LV_DITHER.one_of, + cv.Optional(CONF_LINEAR): LINEAR_SCHEMA, + cv.Optional(CONF_RADIAL): RADIAL_SCHEMA, + cv.Optional(CONF_CONICAL): CONICAL_SCHEMA, + cv.Required(CONF_STOPS): STOPS_SCHEMA, + } + ), + gradient_validator, ) ) @@ -65,15 +169,60 @@ async def gradients_to_code(config): add_warning( "The 'dither' option for gradients is not supported by LVGL 9.x and will be ignored" ) + if any( + x[CONF_DIRECTION] in ("LINEAR", "RADIAL", "CONICAL") + for x in config.get(CONF_GRADIENTS, ()) + ): + # LVGL's software renderer only draws these gradient types when this is enabled; without + # it they silently fall back to a plain horizontal gradient. + add_define("LV_USE_DRAW_SW_COMPLEX_GRADIENTS") for gradient in config.get(CONF_GRADIENTS, ()): var = MockObj(cg.new_Pvariable(gradient[CONF_ID]), "->") idbase = gradient[CONF_ID].id stops = sorted(gradient[CONF_STOPS], key=itemgetter(CONF_POSITION)) max_stops = max(max_stops, len(stops)) - if gradient[CONF_DIRECTION].startswith("VER"): + direction = gradient[CONF_DIRECTION] + if direction.startswith("VER"): lv.grad_vertical_init(var) - else: + elif direction.startswith("HOR"): lv.grad_horizontal_init(var) + elif direction == "LINEAR": + linear = gradient[CONF_LINEAR] + lv.grad_linear_init( + var, + await pixels_or_percent.process(linear[CONF_FROM_X]), + await pixels_or_percent.process(linear[CONF_FROM_Y]), + await pixels_or_percent.process(linear[CONF_TO_X]), + await pixels_or_percent.process(linear[CONF_TO_Y]), + await LV_GRAD_EXTEND.process(linear[CONF_EXTEND]), + ) + elif direction == "RADIAL": + radial = gradient[CONF_RADIAL] + lv.grad_radial_init( + var, + await pixels_or_percent.process(radial[CONF_CENTER_X]), + await pixels_or_percent.process(radial[CONF_CENTER_Y]), + await pixels_or_percent.process(radial[CONF_TO_X]), + await pixels_or_percent.process(radial[CONF_TO_Y]), + await LV_GRAD_EXTEND.process(radial[CONF_EXTEND]), + ) + if CONF_FOCAL_X in radial: + lv.grad_radial_set_focal( + var, + await pixels_or_percent.process(radial[CONF_FOCAL_X]), + await pixels_or_percent.process(radial[CONF_FOCAL_Y]), + radial.get(CONF_FOCAL_RADIUS, 0), + ) + elif direction == "CONICAL": + conical = gradient[CONF_CONICAL] + lv.grad_conical_init( + var, + await pixels_or_percent.process(conical[CONF_CENTER_X]), + await pixels_or_percent.process(conical[CONF_CENTER_Y]), + await lv_angle_degrees.process(conical[CONF_START_ANGLE]), + await lv_angle_degrees.process(conical[CONF_END_ANGLE]), + await LV_GRAD_EXTEND.process(conical[CONF_EXTEND]), + ) stop_colors = cg.static_const_array( ID(idbase + "_colors_", type=lv_color_t), [await lv_color.process(x[CONF_COLOR]) for x in stops], diff --git a/esphome/components/mipi_rgb/models/elecrow.py b/esphome/components/mipi_rgb/models/elecrow.py new file mode 100644 index 0000000000..acc36beb74 --- /dev/null +++ b/esphome/components/mipi_rgb/models/elecrow.py @@ -0,0 +1,28 @@ +from . import RgbDriverChip + +# fmt: off +RgbDriverChip( + "CROWPANEL-ADVANCE-7", + requires={"psram"}, + initsequence=(), + pclk_frequency="20MHz", + hsync_pulse_width=4, + hsync_front_porch=8, + hsync_back_porch=8, + vsync_pulse_width=4, + vsync_front_porch=8, + vsync_back_porch=8, + pclk_inverted=True, + color_order="RGB", + width=800, + height=480, + de_pin=42, + hsync_pin=40, + vsync_pin=41, + pclk_pin=39, + data_pins={ + "red": [7, 17, 18, 3, 46], + "green": [9, 10, 11, 12, 13, 14], + "blue": [21, 47, 48, 45, 38], + }, +) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index aa998d283a..f77492f48b 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -3,6 +3,7 @@ #include #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -12,29 +13,49 @@ static const char *const TAG = "modbus"; static constexpr size_t MODBUS_MAX_LOG_BYTES = 64; -// Approximate bits per character on the wire (depends on parity/stop bit config) -static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11; -static constexpr uint32_t MS_PER_SEC = 1000; +static constexpr uint32_t US_PER_SEC = 1000000; +static constexpr uint32_t US_PER_MS = 1000; + +// Minimum interframe delay per the Modbus spec (fixed 1750us above 19200 baud) +static constexpr uint32_t MODBUS_MIN_FRAME_DELAY_US = 1750; + +// Diagnostics only: the backdated byte stamp can precede last_send_ (echo, or noise during our own +// send), where an unsigned wrap would print ~4.29e9. +static uint32_t us_since_send(uint32_t last_modbus_byte, uint32_t last_send) { + const uint32_t elapsed = last_modbus_byte - last_send; + return (int32_t) elapsed < 0 ? 0 : elapsed; +} void Modbus::setup() { if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->setup(); } - this->frame_delay_ms_ = - std::max(2, // 1750us minimum per spec - rounded up to 2ms. - // 3.5 characters * 11 bits per character * 1000ms/sec / (bits/sec) (Standard modbus frame delay) - (uint16_t) (3.5 * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate()) + 1); + // RTU specifies 11 bits per character but 8N1 is 10, so derive it from the framing. The schema + // forbids a zero, so one here means the hub never set it (weikai): fall back to 8N1 and a 1 baud floor. + const uint8_t data_bits = this->parent_->get_data_bits() != 0 ? this->parent_->get_data_bits() : 8; + const uint8_t stop_bits = this->parent_->get_stop_bits() != 0 ? this->parent_->get_stop_bits() : 1; + const uint32_t baud_rate = std::max(1u, this->parent_->get_baud_rate()); + this->bits_per_char_ = static_cast( + 1 + data_bits + (this->parent_->get_parity() == uart::UART_CONFIG_PARITY_NONE ? 0 : 1) + stop_bits); + + // 3.5 characters * bits per character * 1e6 us/sec / (bits/sec) (Standard modbus frame delay) + this->frame_delay_us_ = + std::max(MODBUS_MIN_FRAME_DELAY_US, (uint32_t) (3.5 * this->bits_per_char_ * US_PER_SEC / baud_rate) + 1); // When rx_full_threshold is configured (non-zero), the UART has a hardware FIFO with a // meaningful threshold (e.g., ESP32 native UART), so we can calculate a precise delay. // Otherwise (e.g., USB UART), use 50ms to handle data arriving in chunks. - static constexpr uint16_t DEFAULT_LONG_RX_BUFFER_DELAY_MS = 50; + static constexpr uint32_t DEFAULT_LONG_RX_BUFFER_DELAY_US = 50 * US_PER_MS; size_t rx_threshold = this->parent_->get_rx_full_threshold(); - this->long_rx_buffer_delay_ms_ = - rx_threshold != uart::UARTComponent::RX_FULL_THRESHOLD_UNSET - ? (rx_threshold * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate()) + 1 - : DEFAULT_LONG_RX_BUFFER_DELAY_MS; + this->long_rx_buffer_delay_us_ = rx_threshold != uart::UARTComponent::RX_FULL_THRESHOLD_UNSET + ? (uint32_t) (rx_threshold * this->bits_per_char_ * US_PER_SEC / baud_rate) + 1 + : DEFAULT_LONG_RX_BUFFER_DELAY_US; + + // The idle-timeout interrupt fires rx_timeout characters after the last byte, so that much silence + // has already passed by the time we read it: backdate so the gap measures silence on the wire. + this->rx_detect_latency_us_ = + (uint32_t) (this->parent_->get_rx_timeout() * this->bits_per_char_ * US_PER_SEC / baud_rate); } void Modbus::loop() { @@ -52,7 +73,7 @@ void ModbusClientHub::loop() { // Send-wait watchdog: only the cheap time check runs at loop rate; expire_waiting_() looks the // entry up and holds off if the response has started arriving. if (this->waiting_for_response_ && - this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_) { + this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_us_) { this->expire_waiting_(); } @@ -72,7 +93,7 @@ void ModbusClientHub::expire_waiting_() { } // Only a genuine WAITING entry warrants the log (a cleared or interrupted shell timing out is expected). if (cmd->state == FrameState::WAITING) { - ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", cmd->frame.address(), + ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "us after last send", cmd->frame.address(), this->last_receive_check_ - this->last_send_); } // Deliver on_no_response directly, the way the parse path delivers response()/error(): the entry @@ -86,37 +107,47 @@ void ModbusClientHub::expire_waiting_() { bool Modbus::timeout_() { // If the response frame is finished (including interframe delay) - we timeout. // The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts - // when the buffer is filling the back half of the response - const uint16_t timeout = std::max( - (uint16_t) this->frame_delay_ms_, - (uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_ - : 0)); + // when the buffer is filling the back half of the response. The latch decides, not the current size: + // parsing a leading frame can shrink the buffer below the threshold while the rest is still streaming. + // The latency term covers the final batch, which is idle-delivered. + const uint32_t timeout = + this->exceeded_rx_full_threshold_ + ? std::max(this->frame_delay_us_, this->long_rx_buffer_delay_us_ + this->rx_detect_latency_us_) + : this->frame_delay_us_; return this->last_receive_check_ - this->last_modbus_byte_ > timeout; } +// We use micros() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps +// It's critical in all timestamp comparisons that the left timestamp comes before the right one in time +// If we use a cached value in place of micros() and last_modbus_byte_ is updated inside our loop +// then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout +// So in this component we don't use any cached timestamp values to avoid these annoying bugs. +// Compare before subtracting: a signed difference would read a bus idle past half the micros() wrap +// (~35 min) as a huge delay still owed. +static inline uint32_t remaining_delay(uint32_t elapsed, uint32_t required) { + return elapsed >= required ? 0 : required - elapsed; +} + int32_t Modbus::tx_delay_remaining() { - // millis() here and everywhere in this component, never a cached loop timestamp: a cached "now" can - // predate last_modbus_byte_, and the unsigned subtraction then wraps huge and forces a false timeout. - const uint32_t now = millis(); - return std::max({(int32_t) 0, - (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)), - (int32_t) (this->frame_delay_ms_ - (now - this->last_modbus_byte_))}); + const uint32_t now = micros(); + return (int32_t) std::max(remaining_delay(now - this->last_send_, this->last_send_tx_offset_ + this->frame_delay_us_), + remaining_delay(now - this->last_modbus_byte_, this->frame_delay_us_)); } int32_t ModbusClientHub::tx_delay_remaining() { - const uint32_t now = millis(); - return std::max({(int32_t) 0, - (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - - (now - this->last_send_)), - (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); + const uint32_t now = micros(); + return (int32_t) std::max( + remaining_delay(now - this->last_send_, + this->last_send_tx_offset_ + this->frame_delay_us_ + this->turnaround_delay_us_), + remaining_delay(now - this->last_modbus_byte_, this->frame_delay_us_ + this->turnaround_delay_us_)); } bool Modbus::tx_blocked() { // Blocked while any rx bytes are pending, or within tx_delay of the last byte in either direction // (receivers must see our previous tx as done, and more rx may be coming). A remaining delay up to - // MODBUS_TX_MAX_DELAY_MS doesn't block - send_frame_ absorbs it instead of looping on small waits. - return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS; + // MODBUS_TX_MAX_DELAY_US doesn't block - send_frame_ absorbs it instead of looping on small waits. + return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_US; } bool ModbusClientHub::tx_blocked() { return this->waiting_for_response_ || this->Modbus::tx_blocked(); } @@ -133,20 +164,26 @@ bool ModbusClientHub::tx_buffer_empty() { } void Modbus::receive_bytes_() { - this->last_receive_check_ = millis(); + this->last_receive_check_ = micros(); size_t bytes = this->available(); if (bytes) { size_t buffer_size = this->rx_buffer_.size(); - this->last_modbus_byte_ = this->last_receive_check_; + // Below the threshold the batch can only be idle-delivered, so its last byte finished one detection + // latency ago; at or above it the frame may still be streaming, so stamp now. + this->last_modbus_byte_ = bytes < this->parent_->get_rx_full_threshold() + ? this->last_receive_check_ - this->rx_detect_latency_us_ + : this->last_receive_check_; this->rx_buffer_.resize(buffer_size + bytes); if (!this->read_array(this->rx_buffer_.data() + buffer_size, bytes)) { this->rx_buffer_.resize(buffer_size); return; } + if (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold()) + this->exceeded_rx_full_threshold_ = true; if (buffer_size == 0) { - ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "ms after last send", - this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), millis() - this->last_send_); + ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "us after last send", + this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), micros() - this->last_send_); } } } @@ -299,8 +336,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::spanwaiting_for_response_ ? this->find_waiting_() : nullptr; if (cmd == nullptr) { ESP_LOGW(TAG, - "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send", - address, function_code, this->last_modbus_byte_ - this->last_send_); + "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "us after last send", + address, function_code, us_since_send(this->last_modbus_byte_, this->last_send_)); return; } @@ -310,9 +347,9 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 - "ms after last send", + "us after last send", address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, - this->last_modbus_byte_ - this->last_send_); + us_since_send(this->last_modbus_byte_, this->last_send_)); // Unexpected frame: flip a WAITING entry to an INTERRUPTED shell that ignores the rest of this // transaction and blocks tx until the send-wait timeout, where it gets its on_no_response. cmd->interrupt(); @@ -325,8 +362,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::spanlast_modbus_byte_ - this->last_send_); + "us after last send", + address, us_since_send(this->last_modbus_byte_, this->last_send_)); return; } @@ -337,12 +374,12 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::spansweep_needed_ = true; if (helpers::is_function_code_exception(function_code)) { uint8_t exception = pdu[1]; // exception frames are fixed-length, so the code is always present - ESP_LOGW(TAG, "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", - function_code, exception, address, this->last_modbus_byte_ - this->last_send_); + ESP_LOGW(TAG, "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "us after last send", + function_code, exception, address, us_since_send(this->last_modbus_byte_, this->last_send_)); cmd->error(static_cast(exception)); } else if (!cmd->response(pdu)) { - ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", address, - this->last_modbus_byte_ - this->last_send_); + ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "us after last send", address, + us_since_send(this->last_modbus_byte_, this->last_send_)); } } @@ -738,9 +775,15 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func // Callers gate on tx_blocked() first, but the pre-send delay below can span several ms, so re-check // after it and refuse (return false) if a byte arrived in that window rather than transmit over it. bool Modbus::send_frame_(const ModbusFrame &frame) { - const int32_t tx_delay_remaining = this->tx_delay_remaining(); + int32_t tx_delay_remaining = this->tx_delay_remaining(); if (tx_delay_remaining > 0) { - delay(tx_delay_remaining); + // delay() only lands on tick boundaries, so yield with it to get close, then busy-wait the rest. + if (tx_delay_remaining > (int32_t) (2 * US_PER_MS)) { + delay((tx_delay_remaining - US_PER_MS) / US_PER_MS); + tx_delay_remaining = this->tx_delay_remaining(); + } + if (tx_delay_remaining > 0) + delayMicroseconds(tx_delay_remaining); } if (this->tx_blocked()) { @@ -755,14 +798,15 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { this->last_send_tx_offset_ = 0; } else { this->write_array(frame.data.data(), frame.size()); - this->last_send_tx_offset_ = frame.size() * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; + this->last_send_tx_offset_ = + frame.size() * this->bits_per_char_ * US_PER_SEC / std::max(1u, this->parent_->get_baud_rate()) + 1; } - uint32_t now = millis(); + uint32_t now = micros(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive", + ESP_LOGV(TAG, "Write: %s %" PRIu32 "us after last send, %" PRIu32 "us after last receive", format_hex_pretty_to(hex_buf, frame.data.data(), frame.size()), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; @@ -800,20 +844,25 @@ void ModbusClientHub::send_next_frame_() { void ModbusClientHub::dump_config() { ESP_LOGCONFIG(TAG, "Modbus:\n" - " Send Wait Time: %" PRIu16 " ms\n" - " Turnaround Time: %" PRIu16 " ms\n" - " Frame Delay: %" PRIu16 " ms\n" - " Long Rx Buffer Delay: %" PRIu16 " ms", - this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_, - this->long_rx_buffer_delay_ms_); + " Send Wait Time: %" PRIu32 " ms\n" + " Turnaround Time: %" PRIu32 " ms\n" + " Frame Delay: %" PRIu32 " us\n" + " Long Rx Buffer Delay: %" PRIu32 " us\n" + " Bits Per Character: %" PRIu8 "\n" + " Rx Detect Latency: %" PRIu32 " us", + this->send_wait_time_us_ / US_PER_MS, this->turnaround_delay_us_ / US_PER_MS, this->frame_delay_us_, + this->long_rx_buffer_delay_us_, this->bits_per_char_, this->rx_detect_latency_us_); LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); } void ModbusServerHub::dump_config() { ESP_LOGCONFIG(TAG, "Modbus:\n" - " Frame Delay: %" PRIu16 " ms\n" - " Long Rx Buffer Delay: %" PRIu16 " ms", - this->frame_delay_ms_, this->long_rx_buffer_delay_ms_); + " Frame Delay: %" PRIu32 " us\n" + " Long Rx Buffer Delay: %" PRIu32 " us\n" + " Bits Per Character: %" PRIu8 "\n" + " Rx Detect Latency: %" PRIu32 " us", + this->frame_delay_us_, this->long_rx_buffer_delay_us_, this->bits_per_char_, + this->rx_detect_latency_us_); LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); } @@ -1142,7 +1191,8 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { // without a heap allocation. Only one server reply is ever waiting, so a single buffer suffices. std::memcpy(this->deferred_payload_.data(), payload, len); this->deferred_payload_len_ = len; - this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() { + // set_timeout() takes milliseconds; round the microsecond delay up so we never fire early. + this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() { ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, this->deferred_payload_len_ - 1); if (!this->send_frame_(frame)) @@ -1162,11 +1212,11 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t bytes = bytes_to_clear; if (bytes > 0) { if (warn) { - ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), - millis() - this->last_send_); + ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason), + micros() - this->last_send_); } else { - ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), - millis() - this->last_send_); + ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason), + micros() - this->last_send_); } if (bytes == this->rx_buffer_.size()) { this->rx_buffer_.clear(); @@ -1174,6 +1224,8 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t this->rx_buffer_.erase(this->rx_buffer_.begin(), this->rx_buffer_.begin() + bytes); } } + if (this->rx_buffer_.empty()) + this->exceeded_rx_full_threshold_ = false; } void ModbusClientDevice::dispatch_response_(std::span request_pdu, std::span response_pdu, diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 69a7eb82e3..7d7818239d 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -19,7 +19,7 @@ namespace esphome::modbus { // Tx queue backstop: duplicates dedup into one entry, so only a runaway generator of distinct frames // (e.g. a loop writing a changing value) could grow the heap unboundedly. static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 128; -static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; +static constexpr uint16_t MODBUS_TX_MAX_DELAY_US = 5000; // Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes // (address + 5-byte PDU + 2-byte CRC). @@ -70,12 +70,18 @@ class Modbus : public uart::UARTDevice, public Component { bool send_frame_(const ModbusFrame &frame); uint16_t find_frame_end_by_crc_(uint16_t min_length) const; + // All timestamps and durations below are micros()-based uint32_t last_modbus_byte_{0}; uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; - uint16_t frame_delay_ms_{5}; - uint16_t long_rx_buffer_delay_ms_{0}; + uint32_t frame_delay_us_{5000}; + uint32_t long_rx_buffer_delay_us_{0}; + uint32_t rx_detect_latency_us_{0}; + // Bits on the wire per character (start + data + optional parity + stop); 12 at most. + uint8_t bits_per_char_{11}; + // Latched when a read reaches rx_full_threshold, cleared when the buffer drains. + bool exceeded_rx_full_threshold_{false}; GPIOPin *flow_control_pin_{nullptr}; @@ -232,8 +238,9 @@ class ModbusClientHub : public Modbus { ModbusClientHub() = default; void dump_config() override; void loop() override; - void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; } - void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } + // Config arrives in milliseconds; stored internally in microseconds like all other timing. + void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_us_ = time_in_ms * 1000UL; } + void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_us_ = time_in_ms * 1000UL; } bool tx_buffer_empty(); bool tx_blocked() override; ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") @@ -279,8 +286,8 @@ class ModbusClientHub : public Modbus { // End the wait for a response on send-wait timeout (the loop() watchdog body); see FrameState. void expire_waiting_(); - uint16_t send_wait_time_{2000}; - uint16_t turnaround_delay_ms_{0}; + uint32_t send_wait_time_us_{2000000}; + uint32_t turnaround_delay_us_{0}; // Set on transmit, cleared on the transaction-ending transition; send_next_frame_ won't select // while it is set, so at most one frame is awaiting a response. diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 77efc91bef..6e3f91af22 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -783,7 +783,8 @@ class EsphomeCore: can compare a locally computed hash against the one a device advertises. Machine-local data is kept out of the input: build_path (which embeds ESPHOME_BUILD_PATH and OS path separators) is excluded, - and Path values are dumped relative to the config directory. + and Path values are dumped relative to the config directory, with + the data directory always at its default ``.esphome`` location. """ if self._config_hash is None: from esphome import yaml_util @@ -794,11 +795,15 @@ class EsphomeCore: esphome_conf = dict(esphome_conf) esphome_conf.pop(CONF_BUILD_PATH, None) config[CONF_ESPHOME] = esphome_conf + relative_to = data_dir = None + if self.config_path is not None: + relative_to, data_dir = self.config_dir, self.data_dir config_str = yaml_util.dump( config, show_secrets=True, sort_keys=True, - relative_to=self.config_dir if self.config_path is not None else None, + relative_to=relative_to, + data_dir=data_dir, ) self._config_hash = fnv1a_32bit_hash(config_str) return self._config_hash diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 625d4879f5..1f5a10d47d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -218,9 +218,11 @@ #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS #define USE_API_CUSTOM_SERVICES +#define USE_API_USER_DEFINED_ACTION_METADATA #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 +#define API_USER_ACTION_STRINGS_SCRATCH_SIZE 64 #define MAX_API_CONNECTIONS 6 // The Improv library is not in the Zephyr tidy environment #define USE_IMPROV_SERIAL diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index aeaa4480a8..45d6559b3f 100644 --- a/esphome/cpp_types.py +++ b/esphome/cpp_types.py @@ -14,6 +14,7 @@ std_string_ref = std_ns.namespace("string &") std_vector = std_ns.class_("vector") std_span = std_ns.class_("span") int8 = global_ns.namespace("int8_t") +char = global_ns.namespace("char") uint8 = global_ns.namespace("uint8_t") uint16 = global_ns.namespace("uint16_t") uint32 = global_ns.namespace("uint32_t") diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index c280e550c9..7c6cf691b9 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1057,11 +1057,19 @@ def _load_yaml_internal_with_type( loader.dispose() -def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None): +def dump( + dict_, + show_secrets=False, + sort_keys=False, + relative_to: Path | None = None, + data_dir: Path | None = None, +): """Dump YAML to a string and remove null. When ``relative_to`` is given, Path values are dumped relative to that - directory (POSIX form) so the output is machine independent. + directory (POSIX form) so the output is machine independent; Path values + under ``data_dir`` are then dumped as ``.esphome/``. ``data_dir`` + has no effect unless ``relative_to`` is also given. """ if show_secrets: _SECRET_VALUES.clear() @@ -1073,6 +1081,7 @@ def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = class _Dumper(ESPHomeDumper): _redact_sensitive = not show_secrets _relative_to = relative_to + _data_dir = data_dir return yaml.dump( dict_, @@ -1231,6 +1240,9 @@ class ESPHomeDumper(yaml.SafeDumper): # directory (in POSIX form) so the output does not depend on where the # config lives on the machine that produced it. _relative_to: Path | None = None + # Paths under this directory are dumped as ``.esphome/`` so the + # add-on's ``/data`` mount matches the CLI layout. + _data_dir: Path | None = None def represent_mapping(self, tag, mapping, flow_style=None): value = [] @@ -1274,6 +1286,12 @@ class ESPHomeDumper(yaml.SafeDumper): # path that still cannot be relativized (e.g. a different drive) # keeps its POSIX form so separators stay stable across OSes. path = Path(os.path.normpath(value)) + # Checked first: the default data dir sits inside the config dir. + if self._data_dir is not None and path.is_relative_to( + data_dir := os.path.normpath(self._data_dir) + ): + rel = Path(".esphome") / path.relative_to(data_dir) + return self.represent_stringify(rel.as_posix()) with suppress(ValueError): path = path.relative_to( os.path.normpath(self._relative_to), walk_up=True diff --git a/requirements.txt b/requirements.txt index 63abc9c645..a065492dfa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==46.2.1 +aioesphomeapi==46.3.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py new file mode 100644 index 0000000000..adbfdf306e --- /dev/null +++ b/tests/component_tests/api/test_action_metadata.py @@ -0,0 +1,155 @@ +"""Tests for user-defined action field metadata (description / example).""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.api import ( + _action_strings, + _action_strings_size, + _has_action_metadata, + _validate_esp8266_action_strings, + validate_variable, +) +from esphome.config_validation import Invalid +from esphome.const import PlatformFramework +from esphome.core import CORE +from esphome.cpp_generator import safe_exp +from esphome.helpers import fnv1_hash +from tests.component_tests.helpers import get_define_value +from tests.component_tests.types import SetCoreConfigCallable + +CONFIG = "tests/component_tests/api/test_action_metadata.yaml" +CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml" +CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yaml" + + +def test_metadata_is_emitted_as_progmem_table( + generate_main: Callable[[str | Path], str], +) -> None: + """Every action string is a PROGMEM array referenced from one PROGMEM table.""" + main_cpp = generate_main(CONFIG) + + assert ( + 'static constexpr char api_action_str0[] PROGMEM = "play_buzzer";' in main_cpp + ) + assert ( + 'static constexpr char api_action_str1[] PROGMEM = "Play an RTTTL melody on the buzzer";' + in main_cpp + ) + assert ( + 'static constexpr char api_action_str4[] PROGMEM = "two_short:d=4,o=5,b=100:16e6,16e6";' + in main_cpp + ) + assert ( + "static constexpr const char * api_action0_strings[] PROGMEM = {" + "api_action_str0, api_action_str1, api_action_str2, api_action_str3, " + "api_action_str4, api_action_str5, nullptr, nullptr};" in main_cpp + ) + # An action without metadata still carries the metadata slots (as nullptr) + assert ( + "static constexpr const char * api_action1_strings[] PROGMEM = {" + "api_action_str6, nullptr, api_action_str7, nullptr, nullptr};" in main_cpp + ) + assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp + assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines} + assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None + + +def test_esp8266_sizes_scratch_buffer_for_largest_action( + generate_main: Callable[[str | Path], str], +) -> None: + """ESP8266 gets a scratch buffer define equal to the byte total of the largest action.""" + generate_main(CONFIG_ESP8266) + + # play_buzzer: name, description, two variable names, one description, one example, + # each with a terminator + assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") == "117" + + +def test_shorthand_variables_emit_no_metadata( + generate_main: Callable[[str | Path], str], +) -> None: + """The name: type shorthand emits a name-only table and no define.""" + main_cpp = generate_main(CONFIG_SHORTHAND) + + assert ( + "static constexpr const char * api_action0_strings[] PROGMEM = " + "{api_action_str0, api_action_str1};" in main_cpp + ) + assert "USE_API_USER_DEFINED_ACTION_METADATA" not in {d.name for d in CORE.defines} + + +def test_variable_shorthand_normalizes_to_mapping() -> None: + """A bare type string validates to the mapping form.""" + assert validate_variable("string") == {"type": "string"} + + +@pytest.mark.parametrize( + "value", + [ + {"description": "no type given"}, + {"type": "string", "selector": "text"}, + "stringy", + {"type": "stringy"}, + ], +) +def test_variable_rejects_invalid(value: object) -> None: + """Missing or unknown type and unknown keys raise in both forms.""" + with pytest.raises(Invalid): + validate_variable(value) + + +def _oversized_action_config() -> dict: + return { + "actions": [ + { + "action": "big", + "description": "x" * 300, + "variables": {"a": {"type": "string", "example": "y" * 300}}, + } + ] + } + + +def test_esp8266_rejects_actions_over_string_budget( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(Invalid, match="ESP8266 allows at most 384 bytes"): + _validate_esp8266_action_strings(_oversized_action_config()) + + +def test_other_platforms_have_no_string_budget( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = _oversized_action_config() + assert _validate_esp8266_action_strings(config) is config + + +def test_empty_metadata_is_unset_and_not_counted() -> None: + """An empty description or example emits nullptr and takes no scratch space.""" + conf = { + "action": "a", + "description": "", + "variables": {"b": {"type": "int", "description": "", "example": "ex"}}, + } + strings = _action_strings(conf, has_metadata=True) + assert strings == ["a", None, "b", None, "ex"] + # Every emitted string counts its terminator: "a" + "b" + "ex" + assert _action_strings_size(strings) == 2 + 2 + 3 + + +def test_empty_metadata_does_not_enable_the_define() -> None: + actions = [ + { + "action": "a", + "description": "", + "variables": {"b": {"type": "int", "example": ""}}, + } + ] + assert not _has_action_metadata(actions) + actions[0]["variables"]["b"]["example"] = "1" + assert _has_action_metadata(actions) diff --git a/tests/component_tests/api/test_action_metadata.yaml b/tests/component_tests/api/test_action_metadata.yaml new file mode 100644 index 0000000000..c998713874 --- /dev/null +++ b/tests/component_tests/api/test_action_metadata.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: MySSID + password: password1 + +logger: + +packages: + api: !include test_action_metadata_common.yaml diff --git a/tests/component_tests/api/test_action_metadata_common.yaml b/tests/component_tests/api/test_action_metadata_common.yaml new file mode 100644 index 0000000000..bd161efe63 --- /dev/null +++ b/tests/component_tests/api/test_action_metadata_common.yaml @@ -0,0 +1,18 @@ +api: + actions: + - action: play_buzzer + description: Play an RTTTL melody on the buzzer + variables: + song_str: + type: string + description: RTTTL melody string + example: "two_short:d=4,o=5,b=100:16e6,16e6" + volume: + type: int + then: + - logger.log: Action Called + - action: plain_action + variables: + value: int + then: + - logger.log: Action Called diff --git a/tests/component_tests/api/test_action_metadata_esp8266.yaml b/tests/component_tests/api/test_action_metadata_esp8266.yaml new file mode 100644 index 0000000000..94a5839b28 --- /dev/null +++ b/tests/component_tests/api/test_action_metadata_esp8266.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: MySSID + password: password1 + +logger: + +packages: + api: !include test_action_metadata_common.yaml diff --git a/tests/component_tests/api/test_action_metadata_shorthand.yaml b/tests/component_tests/api/test_action_metadata_shorthand.yaml new file mode 100644 index 0000000000..aa2e1ab424 --- /dev/null +++ b/tests/component_tests/api/test_action_metadata_shorthand.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: MySSID + password: password1 + +logger: + +api: + actions: + - action: plain_action + variables: + value: int + then: + - logger.log: Action Called diff --git a/tests/component_tests/api/test_homeassistant_action.py b/tests/component_tests/api/test_homeassistant_action.py index 611353e7c5..6ee5ac3412 100644 --- a/tests/component_tests/api/test_homeassistant_action.py +++ b/tests/component_tests/api/test_homeassistant_action.py @@ -9,7 +9,7 @@ def test_synchronous_chain_keeps_zero_copy_args(generate_main): assert ( "api::UserServiceTrigger" - '("zero_copy_args", {"message"})' in main_cpp + "(api_action0_strings," in main_cpp ) @@ -22,7 +22,7 @@ def test_response_callback_args_are_owning(generate_main): assert ( "api::UserServiceTrigger" - '("response_args", {"message"})' in main_cpp + "(api_action1_strings," in main_cpp ) assert "api::HomeAssistantServiceCallAction" in main_cpp assert "api::HomeAssistantServiceCallAction" not in main_cpp diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_internal_temperature.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_internal_temperature.yaml new file mode 100644 index 0000000000..d5a0aaf157 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_internal_temperature.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +sensor: + - platform: internal_temperature + name: Internal Temperature diff --git a/tests/component_tests/esp32/config/exclusion_stays_internal_temperature_s3.yaml b/tests/component_tests/esp32/config/exclusion_stays_internal_temperature_s3.yaml new file mode 100644 index 0000000000..6d4dbf90b5 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_stays_internal_temperature_s3.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +sensor: + - platform: internal_temperature + name: Internal Temperature diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index c72c4c3a6b..db7ed6b3fc 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -313,6 +313,12 @@ def test_esp32_configuration_errors( ("esp_wifi",), id="espnow", ), + pytest.param( + # temprature_sens_read() on the original ESP32 lives in the esp_phy blob. + "exclusion_reincludes_internal_temperature.yaml", + ("esp_phy",), + id="internal_temperature", + ), ], ) def test_default_exclusions_reincluded_by_owning_components( @@ -337,6 +343,15 @@ def test_default_exclusions_reincluded_by_owning_components( assert ("esp_http_server" in excluded) == ("esp_http_server" not in reincluded) +def test_esp_phy_stays_excluded_for_internal_temperature_on_newer_variants( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only the original ESP32 reads the PHY blob; other variants use esp_driver_tsens.""" + generate_main(component_config_path("exclusion_stays_internal_temperature_s3.yaml")) + assert "esp_phy" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] + + def test_nvs_sec_provider_stays_excluded_when_encryption_is_off( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index c9eb200471..5e3139da48 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -61,8 +61,12 @@ api: reboot_timeout: 0min actions: - action: hello_world + description: Log a greeting variables: - name: string + name: + type: string + description: Name to greet + example: World then: - logger.log: format: Hello World %s! diff --git a/tests/components/api/common.yaml b/tests/components/api/common.yaml index 6115838b6d..42eb32a92a 100644 --- a/tests/components/api/common.yaml +++ b/tests/components/api/common.yaml @@ -1,4 +1,5 @@ -<<: !include common-base.yaml +packages: + base: !include common-base.yaml api: encryption: diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 57be4e9043..b457ec2c0b 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -209,6 +209,63 @@ lvgl: position: 212 - color: 0xFF0000 position: 255 + - id: linear_grad + direction: LINEAR + linear: + from_x: 0% + from_y: 0% + to_x: 100% + to_y: 0% + extend: REFLECT + stops: + - color: 0xFF0000 + position: 0 + - color: 0x0000FF + position: 255 + - id: radial_grad + direction: RADIAL + radial: + center_x: 50% + center_y: 50% + to_x: 100% + to_y: 50% + extend: PAD + stops: + - color: 0xFFFFFF + position: 0 + - color: 0x000000 + position: 255 + - id: radial_focal_grad + direction: RADIAL + radial: + center_x: 50% + center_y: 50% + to_x: 100% + to_y: 50% + focal_x: 40% + focal_y: 40% + focal_radius: 10 + extend: REPEAT + stops: + - color: 0xFF0000 + position: 0 + - color: 0x0000FF + position: 255 + - id: conical_grad + direction: CONICAL + conical: + center_x: 50% + center_y: 50% + start_angle: 0 + end_angle: 360 + extend: PAD + stops: + - color: 0xFF0000 + position: 0 + - color: 0x00FF00 + position: 127 + - color: 0xFF0000 + position: 255 style_definitions: - id: style_test @@ -1070,6 +1127,14 @@ lvgl: logger.log: format: Slider released at %d/%d with value %.0f args: ['(int) point.x', '(int) point.y', x] + + # Exercises the style-application path for a complex gradient, not just its + # lv_grad_*_init() codegen: the other new gradients are only ever declared. + - obj: + bg_opa: cover + bg_grad: conical_grad + width: 40 + height: 40 - button: styles: spin_button id: spin_up diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h index e6c37b0e6d..83d30b3f6d 100644 --- a/tests/components/modbus/common.h +++ b/tests/components/modbus/common.h @@ -11,7 +11,14 @@ namespace esphome::modbus::testing { // A UART that discards all writes, for tests that never inspect the wire. class NullUART : public uart::UARTComponent { public: - NullUART() { this->set_baud_rate(115200); } + // 8N1, matching what the uart schema emits for a real hub; the framing drives the modbus + // interframe timing, so leaving data/stop bits at their zero defaults would not be representative. + NullUART() { + this->set_baud_rate(115200); + this->set_data_bits(8); + this->set_stop_bits(1); + this->set_parity(uart::UART_CONFIG_PARITY_NONE); + } void write_array(const uint8_t *data, size_t len) override {} bool peek_byte(uint8_t *data) override { return false; } bool read_array(uint8_t *data, size_t len) override { return false; } diff --git a/tests/components/modbus/modbus_framing_test.cpp b/tests/components/modbus/modbus_framing_test.cpp new file mode 100644 index 0000000000..a102db5a51 --- /dev/null +++ b/tests/components/modbus/modbus_framing_test.cpp @@ -0,0 +1,64 @@ +#include + +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Exposes the timing values setup() derives from the UART framing. +class FramingProbeHub : public ModbusClientHub { + public: + uint32_t bits_per_char() const { return this->bits_per_char_; } + uint32_t frame_delay_us() const { return this->frame_delay_us_; } +}; + +class FramedUART : public NullUART { + public: + FramedUART(uint32_t baud_rate, uint8_t data_bits, uint8_t stop_bits, uart::UARTParityOptions parity) { + this->set_baud_rate(baud_rate); + this->set_data_bits(data_bits); + this->set_stop_bits(stop_bits); + this->set_parity(parity); + } +}; + +} // namespace + +// 8N1 is 10 bits on the wire, so t3.5 at 9600 baud is 3.5 * 10 / 9600 = 3645.8us. +TEST(ModbusFraming, EightNoneOneDerivesTenBits) { + FramedUART uart(9600, 8, 1, uart::UART_CONFIG_PARITY_NONE); + FramingProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + + EXPECT_EQ(hub.bits_per_char(), 10u); + EXPECT_EQ(hub.frame_delay_us(), 3646u); +} + +// Spec-conformant RTU framing is 11 bits, which lengthens the interframe gap to +// 3.5 * 11 / 9600 = 4010.4us, rounded up. +TEST(ModbusFraming, EightEvenOneDerivesElevenBits) { + FramedUART uart(9600, 8, 1, uart::UART_CONFIG_PARITY_EVEN); + FramingProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + + EXPECT_EQ(hub.bits_per_char(), 11u); + EXPECT_EQ(hub.frame_delay_us(), 4011u); +} + +// Above 19200 baud the spec's fixed 1750us floor governs instead of 3.5 characters. +TEST(ModbusFraming, FastBaudUsesSpecFloor) { + FramedUART uart(115200, 8, 1, uart::UART_CONFIG_PARITY_NONE); + FramingProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + + EXPECT_EQ(hub.frame_delay_us(), 1750u); +} + +} // namespace esphome::modbus::testing diff --git a/tests/integration/fixtures/api_action_metadata.yaml b/tests/integration/fixtures/api_action_metadata.yaml new file mode 100644 index 0000000000..802b965110 --- /dev/null +++ b/tests/integration/fixtures/api_action_metadata.yaml @@ -0,0 +1,26 @@ +esphome: + name: api-action-metadata-test +host: +api: + batch_delay: 0ms + actions: + - action: play_buzzer + description: Play an RTTTL melody on the buzzer + variables: + song_str: + type: string + description: RTTTL melody string + example: "two_short:d=4,o=5,b=100:16e6,16e6" + volume: + type: int + then: + - logger.log: + format: "Buzzer: %s" + args: [song_str.c_str()] + - action: plain_action + variables: + value: int + then: + - logger.log: "Plain action called" + +logger: diff --git a/tests/integration/test_api_action_metadata.py b/tests/integration/test_api_action_metadata.py new file mode 100644 index 0000000000..74d40f141b --- /dev/null +++ b/tests/integration/test_api_action_metadata.py @@ -0,0 +1,65 @@ +"""Integration test for user-defined action field metadata.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from esphome.helpers import fnv1_hash + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_action_metadata( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Action and argument metadata reach the client and the actions still run.""" + loop = asyncio.get_running_loop() + buzzer_called = loop.create_future() + plain_called = loop.create_future() + buzzer_pattern = re.compile(r"Buzzer: two_short") + plain_pattern = re.compile(r"Plain action called") + + def check_output(line: str) -> None: + if not buzzer_called.done() and buzzer_pattern.search(line): + buzzer_called.set_result(True) + elif not plain_called.done() and plain_pattern.search(line): + plain_called.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + + by_name = {service.name: service for service in services} + assert set(by_name) == {"play_buzzer", "plain_action"} + # Keys are hashed at codegen time and must match what the client expects + for name, service in by_name.items(): + assert service.key == fnv1_hash(name), name + + buzzer = by_name["play_buzzer"] + assert buzzer.description == "Play an RTTTL melody on the buzzer" + args = {arg.name: arg for arg in buzzer.args} + assert args["song_str"].description == "RTTTL melody string" + assert args["song_str"].example == "two_short:d=4,o=5,b=100:16e6,16e6" + # An arg without metadata sends empty strings + assert args["volume"].description == "" + assert args["volume"].example == "" + + # An action without metadata sends empty strings + plain = by_name["plain_action"] + assert plain.description == "" + assert plain.args[0].description == "" + + await client.execute_service( + buzzer, {"song_str": "two_short:d=4,o=5,b=100:16e6,16e6", "volume": 3} + ) + await client.execute_service(plain, {"value": 1}) + await asyncio.wait_for(buzzer_called, timeout=5.0) + await asyncio.wait_for(plain_called, timeout=5.0) diff --git a/tests/unit_tests/components/esp32/test_sdkconfig.py b/tests/unit_tests/components/esp32/test_sdkconfig.py new file mode 100644 index 0000000000..b5a562f4d1 --- /dev/null +++ b/tests/unit_tests/components/esp32/test_sdkconfig.py @@ -0,0 +1,72 @@ +"""Tests for the esp32 sdkconfig write and its toolchain-gated clean.""" + +from __future__ import annotations + +import os +from pathlib import Path +import time +from unittest.mock import patch + +import pytest + +from esphome.components.esp32 import _write_sdkconfig +from esphome.components.esp32.const import KEY_SDKCONFIG_OPTIONS +from esphome.const import KEY_CORE, KEY_ESP32, KEY_FRAMEWORK_VERSION, Toolchain +from esphome.core import CORE +from esphome.espidf.toolchain import has_outdated_files + + +def _setup_core(tmp_path: Path, toolchain: Toolchain | None) -> None: + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path + CORE.toolchain = toolchain + CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: {"CONFIG_X": "y"}} + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: "5.5.5"} + + +def _seed_configured_build(tmp_path: Path) -> None: + """A settled native build: configure outputs predate what comes next.""" + build = tmp_path / "build" + (build / "config").mkdir(parents=True) + (build / "config" / "sdkconfig.h").write_text("") + (build / "CMakeCache.txt").write_text("") + (build / "build.ninja").write_text("") + # Explicitly older than what the test writes next: has_outdated_files() + # compares st_mtime with a strict >, so same-tick writes would pass + past = time.time() - 60 + for f in build.rglob("*"): + os.utime(f, (past, past)) + + +@pytest.mark.parametrize( + ("toolchain", "clean_expected"), + [(Toolchain.ESP_IDF, False), (Toolchain.PLATFORMIO, True), (None, True)], +) +def test_write_sdkconfig_cleans_only_on_platformio( + tmp_path: Path, toolchain: Toolchain | None, clean_expected: bool +) -> None: + """A changed sdkconfig forces a full clean only under PlatformIO; the + esp-idf toolchain reconfigures via has_outdated_files() instead; an + unresolved toolchain fails safe onto the clean.""" + _setup_core(tmp_path, toolchain) + _seed_configured_build(tmp_path) + with ( + patch.object(CORE, "name", "test"), + patch("esphome.components.esp32.clean_build") as clean, + ): + _write_sdkconfig() + assert "CONFIG_X" in CORE.relative_build_path("sdkconfig.test").read_text() + assert clean.called is clean_expected + if clean_expected: + clean.assert_called_once_with(clear_pio_cache=False) + # The change must still trigger a reconfigure: the internal + # sdkconfig snapshot is now newer than build/CMakeCache.txt + assert has_outdated_files() is True + clean.reset_mock() + # A settled configure restamps the cache; an unchanged rewrite + # must then neither clean nor mark the build stale + future = time.time() + 60 + os.utime(CORE.relative_build_path("build/CMakeCache.txt"), (future, future)) + _write_sdkconfig() + clean.assert_not_called() + assert has_outdated_files() is False diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 2e1befde41..50d445f780 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1127,6 +1127,34 @@ def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None: assert hash1 == hash2 +def test_config_hash_same_for_different_data_dirs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that downloaded file paths hash the same wherever data_dir lives.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + CORE.reset() + CORE.config_path = config_dir / "device.yaml" + CORE.config = { + "esphome": {"name": "test"}, + "file": config_dir / ".esphome" / "image" / "c44630d6", + } + hash1 = CORE.config_hash + + other_data_dir = tmp_path / "data" + CORE.reset() + monkeypatch.setenv("ESPHOME_DATA_DIR", str(other_data_dir)) + CORE.config_path = config_dir / "device.yaml" + CORE.config = { + "esphome": {"name": "test"}, + "file": other_data_dir / "image" / "c44630d6", + } + hash2 = CORE.config_hash + + assert hash1 == hash2 + + def test_make_app_name_cpp_no_mac_simple() -> None: """Test simple name without MAC suffix returns string literal.""" cpp_expr, global_decl, byte_len = make_app_name_cpp( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 3bdbd04396..8e1f9c25c0 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1706,6 +1706,53 @@ def test_dump_path_dotdot_reference_outside_anchor() -> None: assert output.strip() == "file: ../shared/font.ttf" +@pytest.mark.parametrize( + "data_dir", + [ + pytest.param(Path("/config/.esphome"), id="cli"), + pytest.param(Path("/data"), id="addon"), + ], +) +def test_dump_path_under_data_dir_uses_default_location(data_dir: Path) -> None: + """Test that Path values under data_dir dump as .esphome/ for any layout.""" + anchor = Path("/config").absolute() + path = data_dir.absolute() / "image" / "c44630d6" + output = yaml_util.dump( + {"file": path}, relative_to=anchor, data_dir=data_dir.absolute() + ) + assert output.strip() == "file: .esphome/image/c44630d6" + + +def test_dump_path_equal_to_data_dir() -> None: + """Test that the data dir itself dumps as .esphome, matching the default layout.""" + anchor = Path("/config").absolute() + data_dir = Path("/data").absolute() + output = yaml_util.dump({"dir": data_dir}, relative_to=anchor, data_dir=data_dir) + assert output.strip() == "dir: .esphome" + default = yaml_util.dump( + {"dir": anchor / ".esphome"}, relative_to=anchor, data_dir=anchor / ".esphome" + ) + assert default == output + + +def test_dump_path_outside_data_dir_still_relative_to_anchor() -> None: + """Test that data_dir does not affect paths that are not under it.""" + anchor = Path("/config").absolute() + path = anchor / "fonts" / "arial.ttf" + output = yaml_util.dump( + {"file": path}, relative_to=anchor, data_dir=Path("/data").absolute() + ) + assert output.strip() == "file: fonts/arial.ttf" + + +def test_dump_path_data_dir_without_relative_to_is_unchanged() -> None: + """Test that data_dir alone does not change the output.""" + data_dir = Path("/data").absolute() + path = data_dir / "image" / "c44630d6" + output = yaml_util.dump({"file": path}, data_dir=data_dir) + assert output.strip() == f"file: {path}" + + def test_dump_relative_to_does_not_leak_between_calls() -> None: """Test that the relative_to flag is scoped to a single dump call.""" anchor = Path("/config/esphome").absolute()