mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 10:06:03 +00:00
Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
This commit is contained in:
@@ -78,6 +78,7 @@ from esphome.cpp_types import ( # noqa: F401
|
||||
StringRef,
|
||||
arduino_json_ns,
|
||||
bool_,
|
||||
char,
|
||||
const_char_ptr,
|
||||
double,
|
||||
esphome_ns,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<uint32_t>(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<uint32_t>(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) {
|
||||
|
||||
@@ -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<ListEntitiesServicesArgument> 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
|
||||
|
||||
@@ -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<enums::ServiceArgType>(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<enums::SupportsResponseType>(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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<char> &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<const enums::ServiceArgType> arg_types, std::span<char> 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<bool>(const ExecuteServiceArgument &arg) { return arg.bool_; }
|
||||
template<> int32_t get_execute_arg_value<int32_t>(const ExecuteServiceArgument &arg) {
|
||||
if (arg.legacy_int != 0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <span>
|
||||
#include <tuple>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -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<char> scratch) = 0;
|
||||
|
||||
virtual bool execute_service(const ExecuteServiceRequest &req) = 0;
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
@@ -34,29 +37,51 @@ template<typename T> T get_execute_arg_value(const ExecuteServiceArgument &arg);
|
||||
|
||||
template<typename T> 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<typename... Ts> class UserServiceBase : public UserServiceDescriptor {
|
||||
public:
|
||||
UserServiceBase(const char *name, const std::array<const char *, sizeof...(Ts)> &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<char, API_USER_ACTION_STRINGS_SCRATCH_SIZE>;
|
||||
#else
|
||||
using UserActionScratch = std::array<char, 0>;
|
||||
#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<const enums::ServiceArgType> arg_types,
|
||||
std::span<char> 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<char> &scratch) const;
|
||||
|
||||
const char *const *strings_; // PROGMEM pointer table, read with progmem_read_ptr()
|
||||
uint32_t key_;
|
||||
enums::SupportsResponseType supports_response_;
|
||||
};
|
||||
|
||||
template<typename... Ts> class UserServiceBase : public UserServiceStatic {
|
||||
public:
|
||||
using UserServiceStatic::UserServiceStatic;
|
||||
|
||||
ListEntitiesServicesResponse encode_list_service_response(std::span<char> scratch) override {
|
||||
std::array<enums::ServiceArgType, sizeof...(Ts)> arg_types = {to_service_arg_type<Ts>()...};
|
||||
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<typename... Ts> class UserServiceBase : public UserServiceDescriptor {
|
||||
void execute_(const ArgsContainer &args, uint32_t call_id, bool return_response, std::index_sequence<S...> /*type*/) {
|
||||
this->execute(call_id, return_response, (get_execute_arg_value<Ts>(args[S]))...);
|
||||
}
|
||||
|
||||
// Pointers to string literals in flash - no heap allocation
|
||||
const char *name_;
|
||||
std::array<const char *, sizeof...(Ts)> 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<typename... Ts> 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<char> /*scratch*/) override {
|
||||
ListEntitiesServicesResponse msg;
|
||||
msg.name = StringRef(this->name_);
|
||||
msg.key = this->key_;
|
||||
@@ -167,8 +186,8 @@ template<typename... Ts>
|
||||
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_NONE, Ts...> final : public UserServiceBase<Ts...>,
|
||||
public Trigger<Ts...> {
|
||||
public:
|
||||
UserServiceTrigger(const char *name, const std::array<const char *, sizeof...(Ts)> &arg_names)
|
||||
: UserServiceBase<Ts...>(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {}
|
||||
UserServiceTrigger(const char *const *strings, uint32_t key)
|
||||
: UserServiceBase<Ts...>(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<typename... Ts>
|
||||
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_OPTIONAL, Ts...> final : public UserServiceBase<Ts...>,
|
||||
public Trigger<uint32_t, bool, Ts...> {
|
||||
public:
|
||||
UserServiceTrigger(const char *name, const std::array<const char *, sizeof...(Ts)> &arg_names)
|
||||
: UserServiceBase<Ts...>(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {}
|
||||
UserServiceTrigger(const char *const *strings, uint32_t key)
|
||||
: UserServiceBase<Ts...>(strings, key, enums::SUPPORTS_RESPONSE_OPTIONAL) {}
|
||||
|
||||
protected:
|
||||
void execute(uint32_t call_id, bool return_response, Ts... x) override {
|
||||
@@ -193,8 +212,8 @@ template<typename... Ts>
|
||||
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_ONLY, Ts...> final : public UserServiceBase<Ts...>,
|
||||
public Trigger<uint32_t, Ts...> {
|
||||
public:
|
||||
UserServiceTrigger(const char *name, const std::array<const char *, sizeof...(Ts)> &arg_names)
|
||||
: UserServiceBase<Ts...>(name, arg_names, enums::SUPPORTS_RESPONSE_ONLY) {}
|
||||
UserServiceTrigger(const char *const *strings, uint32_t key)
|
||||
: UserServiceBase<Ts...>(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<typename... Ts>
|
||||
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_STATUS, Ts...> final : public UserServiceBase<Ts...>,
|
||||
public Trigger<uint32_t, Ts...> {
|
||||
public:
|
||||
UserServiceTrigger(const char *name, const std::array<const char *, sizeof...(Ts)> &arg_names)
|
||||
: UserServiceBase<Ts...>(name, arg_names, enums::SUPPORTS_RESPONSE_STATUS) {}
|
||||
UserServiceTrigger(const char *const *strings, uint32_t key)
|
||||
: UserServiceBase<Ts...>(strings, key, enums::SUPPORTS_RESPONSE_STATUS) {}
|
||||
|
||||
protected:
|
||||
void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
)
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <algorithm>
|
||||
|
||||
#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<uint32_t>(1u, this->parent_->get_baud_rate());
|
||||
this->bits_per_char_ = static_cast<uint8_t>(
|
||||
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::span<con
|
||||
ModbusDeviceCommand *cmd = this->waiting_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<con
|
||||
if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) {
|
||||
ESP_LOGW(TAG,
|
||||
"Received incorrect frame address %" PRIu8 " <> %" 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::span<con
|
||||
// cleared-interrupted frame still ends in on_no_response rather than delivering a late response.
|
||||
ESP_LOGW(TAG,
|
||||
"Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32
|
||||
"ms after last send",
|
||||
address, this->last_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::span<con
|
||||
this->sweep_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<ExceptionCode>(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<uint32_t>(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<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+20
-2
@@ -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/<rest>``. ``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/<rest>`` 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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
logger:
|
||||
|
||||
packages:
|
||||
api: !include 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -9,7 +9,7 @@ def test_synchronous_chain_keeps_zero_copy_args(generate_main):
|
||||
|
||||
assert (
|
||||
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, StringRef>"
|
||||
'("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<api::enums::SUPPORTS_RESPONSE_NONE, std::string>"
|
||||
'("response_args", {"message"})' in main_cpp
|
||||
"(api_action1_strings," in main_cpp
|
||||
)
|
||||
assert "api::HomeAssistantServiceCallAction<std::string>" in main_cpp
|
||||
assert "api::HomeAssistantServiceCallAction<StringRef>" not in main_cpp
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
sensor:
|
||||
- platform: internal_temperature
|
||||
name: Internal Temperature
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
sensor:
|
||||
- platform: internal_temperature
|
||||
name: Internal Temperature
|
||||
@@ -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],
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<<: !include common-base.yaml
|
||||
packages:
|
||||
base: !include common-base.yaml
|
||||
|
||||
api:
|
||||
encryption:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#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
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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/<rest> 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()
|
||||
|
||||
Reference in New Issue
Block a user