mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 17:05:36 +00:00
Merge remote-tracking branch 'upstream/dev' into esp8266-wake-loop-any-context
This commit is contained in:
+53
-4
@@ -57,8 +57,23 @@ def maybe_conf(conf, *validators):
|
||||
return validate
|
||||
|
||||
|
||||
def register_action(name: str, action_type: MockObjClass, schema: cv.Schema):
|
||||
return ACTION_REGISTRY.register(name, action_type, schema)
|
||||
def register_action(
|
||||
name: str,
|
||||
action_type: MockObjClass,
|
||||
schema: cv.Schema,
|
||||
*,
|
||||
synchronous: bool = False,
|
||||
):
|
||||
"""Register an action type.
|
||||
|
||||
Actions default to ``synchronous=False`` (safe default), meaning string
|
||||
arguments use owning std::string to prevent dangling references.
|
||||
|
||||
Set ``synchronous=True`` only for actions that complete synchronously
|
||||
and never store trigger arguments for later execution. This allows
|
||||
the code generator to use non-owning StringRef for zero-copy access.
|
||||
"""
|
||||
return ACTION_REGISTRY.register(name, action_type, schema, synchronous=synchronous)
|
||||
|
||||
|
||||
def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schema):
|
||||
@@ -335,7 +350,9 @@ async def component_is_idle_condition_to_code(
|
||||
|
||||
|
||||
@register_action(
|
||||
"delay", DelayAction, cv.templatable(cv.positive_time_period_milliseconds)
|
||||
"delay",
|
||||
DelayAction,
|
||||
cv.templatable(cv.positive_time_period_milliseconds),
|
||||
)
|
||||
async def delay_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -366,6 +383,7 @@ async def delay_action_to_code(
|
||||
cv.has_at_least_one_key(CONF_THEN, CONF_ELSE),
|
||||
cv.has_at_least_one_key(CONF_CONDITION, CONF_ANY, CONF_ALL),
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def if_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -394,6 +412,7 @@ async def if_action_to_code(
|
||||
cv.Required(CONF_THEN): validate_action_list,
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def while_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -417,6 +436,7 @@ async def while_action_to_code(
|
||||
cv.Required(CONF_THEN): validate_action_list,
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def repeat_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -461,7 +481,12 @@ async def wait_until_action_to_code(
|
||||
return var
|
||||
|
||||
|
||||
@register_action("lambda", LambdaAction, cv.lambda_)
|
||||
# Lambda executes user C++ inline and returns — synchronous by execution model.
|
||||
# User code could theoretically store the StringRef for deferred use, but StringRef
|
||||
# is a view type and storing views beyond their scope is always unsafe regardless
|
||||
# of this optimization. Marking non-synchronous would disable StringRef for nearly
|
||||
# all user services since most use lambda.
|
||||
@register_action("lambda", LambdaAction, cv.lambda_, synchronous=True)
|
||||
async def lambda_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
@@ -480,6 +505,7 @@ async def lambda_action_to_code(
|
||||
cv.Required(CONF_ID): cv.use_id(cg.PollingComponent),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def component_update_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -499,6 +525,7 @@ async def component_update_action_to_code(
|
||||
cv.Required(CONF_ID): cv.use_id(cg.PollingComponent),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def component_suspend_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -521,6 +548,7 @@ async def component_suspend_action_to_code(
|
||||
),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def component_resume_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -578,6 +606,27 @@ async def build_condition_list(
|
||||
return conditions
|
||||
|
||||
|
||||
def has_non_synchronous_actions(actions: ConfigType) -> bool:
|
||||
"""Check if a validated action list contains any non-synchronous actions.
|
||||
|
||||
Non-synchronous actions (delay, wait_until, script.wait, etc.) store
|
||||
trigger args for later execution, making non-owning types like StringRef
|
||||
unsafe. Actions that haven't been audited default to non-synchronous.
|
||||
"""
|
||||
if isinstance(actions, list):
|
||||
return any(has_non_synchronous_actions(item) for item in actions)
|
||||
if isinstance(actions, dict):
|
||||
for key in actions:
|
||||
if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous:
|
||||
return True
|
||||
return any(
|
||||
has_non_synchronous_actions(v)
|
||||
for v in actions.values()
|
||||
if isinstance(v, (list, dict))
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def build_automation(
|
||||
trigger: MockObj, args: TemplateArgsType, config: ConfigType
|
||||
) -> MockObj:
|
||||
|
||||
@@ -76,7 +76,7 @@ SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = {
|
||||
"bool": cg.bool_,
|
||||
"int": cg.int32,
|
||||
"float": cg.float_,
|
||||
"string": cg.std_string,
|
||||
"string": cg.StringRef,
|
||||
"bool[]": cg.FixedVector.template(cg.bool_).operator("const").operator("ref"),
|
||||
"int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"),
|
||||
"float[]": cg.FixedVector.template(cg.float_).operator("const").operator("ref"),
|
||||
@@ -380,9 +380,18 @@ async def to_code(config: ConfigType) -> None:
|
||||
if is_optional:
|
||||
func_args.append((cg.bool_, "return_response"))
|
||||
|
||||
# Check if action chain has non-synchronous actions that would make
|
||||
# non-owning StringRef dangle (rx_buf_ reused after delay)
|
||||
has_non_synchronous = automation.has_non_synchronous_actions(
|
||||
conf.get(CONF_THEN, [])
|
||||
)
|
||||
|
||||
service_arg_names: list[str] = []
|
||||
for name, var_ in conf[CONF_VARIABLES].items():
|
||||
native = SERVICE_ARG_NATIVE_TYPES[var_]
|
||||
# Fall back to std::string for string args if non-synchronous actions exist
|
||||
if has_non_synchronous and native is cg.StringRef:
|
||||
native = cg.std_string
|
||||
service_template_args.append(native)
|
||||
func_args.append((native, name))
|
||||
service_arg_names.append(name)
|
||||
@@ -509,11 +518,13 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
"homeassistant.action",
|
||||
HomeAssistantServiceCallAction,
|
||||
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"homeassistant.service",
|
||||
HomeAssistantServiceCallAction,
|
||||
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def homeassistant_service_to_code(
|
||||
config: ConfigType,
|
||||
@@ -604,6 +615,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
||||
"homeassistant.event",
|
||||
HomeAssistantServiceCallAction,
|
||||
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def homeassistant_event_to_code(config, action_id, template_arg, args):
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
@@ -644,6 +656,7 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
"homeassistant.tag_scanned",
|
||||
HomeAssistantServiceCallAction,
|
||||
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args):
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
|
||||
@@ -834,6 +834,33 @@ message GetTimeRequest {
|
||||
option (source) = SOURCE_SERVER;
|
||||
}
|
||||
|
||||
enum DSTRuleType {
|
||||
DST_RULE_TYPE_NONE = 0;
|
||||
DST_RULE_TYPE_MONTH_WEEK_DAY = 1;
|
||||
DST_RULE_TYPE_JULIAN_NO_LEAP = 2;
|
||||
DST_RULE_TYPE_DAY_OF_YEAR = 3;
|
||||
}
|
||||
|
||||
message DSTRule {
|
||||
option (source) = SOURCE_CLIENT;
|
||||
|
||||
sint32 time_seconds = 1;
|
||||
uint32 day = 2;
|
||||
DSTRuleType type = 3;
|
||||
uint32 month = 4;
|
||||
uint32 week = 5;
|
||||
uint32 day_of_week = 6;
|
||||
}
|
||||
|
||||
message ParsedTimezone {
|
||||
option (source) = SOURCE_CLIENT;
|
||||
|
||||
sint32 std_offset_seconds = 1;
|
||||
sint32 dst_offset_seconds = 2;
|
||||
DSTRule dst_start = 3;
|
||||
DSTRule dst_end = 4;
|
||||
}
|
||||
|
||||
message GetTimeResponse {
|
||||
option (id) = 37;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -841,6 +868,7 @@ message GetTimeResponse {
|
||||
|
||||
fixed32 epoch_seconds = 1;
|
||||
string timezone = 2;
|
||||
ParsedTimezone parsed_timezone = 3;
|
||||
}
|
||||
|
||||
// ==================== USER-DEFINES SERVICES ====================
|
||||
|
||||
@@ -1123,7 +1123,30 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) {
|
||||
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
if (!value.timezone.empty()) {
|
||||
homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size());
|
||||
// Check if the sender provided pre-parsed timezone data.
|
||||
// If std_offset is non-zero or DST rules are present, the parsed data was populated.
|
||||
// For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent.
|
||||
const auto &pt = value.parsed_timezone;
|
||||
if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) {
|
||||
time::ParsedTimezone tz{};
|
||||
tz.std_offset_seconds = pt.std_offset_seconds;
|
||||
tz.dst_offset_seconds = pt.dst_offset_seconds;
|
||||
tz.dst_start.time_seconds = pt.dst_start.time_seconds;
|
||||
tz.dst_start.day = static_cast<uint16_t>(pt.dst_start.day);
|
||||
tz.dst_start.type = static_cast<time::DSTRuleType>(pt.dst_start.type);
|
||||
tz.dst_start.month = static_cast<uint8_t>(pt.dst_start.month);
|
||||
tz.dst_start.week = static_cast<uint8_t>(pt.dst_start.week);
|
||||
tz.dst_start.day_of_week = static_cast<uint8_t>(pt.dst_start.day_of_week);
|
||||
tz.dst_end.time_seconds = pt.dst_end.time_seconds;
|
||||
tz.dst_end.day = static_cast<uint16_t>(pt.dst_end.day);
|
||||
tz.dst_end.type = static_cast<time::DSTRuleType>(pt.dst_end.type);
|
||||
tz.dst_end.month = static_cast<uint8_t>(pt.dst_end.month);
|
||||
tz.dst_end.week = static_cast<uint8_t>(pt.dst_end.week);
|
||||
tz.dst_end.day_of_week = static_cast<uint8_t>(pt.dst_end.day_of_week);
|
||||
time::set_global_tz(tz);
|
||||
} else {
|
||||
homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1711,37 +1734,42 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes
|
||||
return;
|
||||
}
|
||||
|
||||
// Null-terminate state in-place for safe c_str() usage (e.g., parse_number in callbacks).
|
||||
// Safe: decode is complete, byte after string data was already consumed during parse,
|
||||
// and frame helpers reserve RX_BUF_NULL_TERMINATOR extra byte in rx_buf_.
|
||||
// const_cast is safe: msg references mutable rx_buf_ data; the const& handler
|
||||
// signature is a generated protobuf pattern, not a true immutability contract.
|
||||
if (!msg.state.empty()) {
|
||||
const_cast<char *>(msg.state.c_str())[msg.state.size()] = '\0';
|
||||
}
|
||||
|
||||
for (auto &it : this->parent_->get_state_subs()) {
|
||||
// Compare entity_id: check length matches and content matches
|
||||
size_t entity_id_len = strlen(it.entity_id);
|
||||
if (entity_id_len != msg.entity_id.size() ||
|
||||
memcmp(it.entity_id, msg.entity_id.c_str(), msg.entity_id.size()) != 0) {
|
||||
if (msg.entity_id != it.entity_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compare attribute: either both have matching attribute, or both have none
|
||||
size_t sub_attr_len = it.attribute != nullptr ? strlen(it.attribute) : 0;
|
||||
if (sub_attr_len != msg.attribute.size() ||
|
||||
(sub_attr_len > 0 && memcmp(it.attribute, msg.attribute.c_str(), sub_attr_len) != 0)) {
|
||||
// If subscriber has attribute filter (non-null), message attribute must match it;
|
||||
// if subscriber has no filter (nullptr), message must have no attribute.
|
||||
if (it.attribute != nullptr ? msg.attribute != it.attribute : !msg.attribute.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create null-terminated state for callback (parse_number needs null-termination)
|
||||
// HA state max length is 255 characters, but attributes can be much longer
|
||||
// Use stack buffer for common case (states), heap fallback for large attributes
|
||||
size_t state_len = msg.state.size();
|
||||
SmallBufferWithHeapFallback<MAX_STATE_LEN + 1> state_buf_alloc(state_len + 1);
|
||||
char *state_buf = reinterpret_cast<char *>(state_buf_alloc.get());
|
||||
if (state_len > 0) {
|
||||
memcpy(state_buf, msg.state.c_str(), state_len);
|
||||
}
|
||||
state_buf[state_len] = '\0';
|
||||
it.callback(StringRef(state_buf, state_len));
|
||||
it.callback(msg.state);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
void APIConnection::on_execute_service_request(const ExecuteServiceRequest &msg) {
|
||||
// Null-terminate string args in-place for safe c_str() usage in YAML service triggers.
|
||||
// Safe: full ExecuteServiceRequest decode is complete, all bytes in rx_buf_ consumed,
|
||||
// and frame helpers reserve RX_BUF_NULL_TERMINATOR extra byte for the last field.
|
||||
// const_cast is safe: msg references mutable rx_buf_ data; the const& handler
|
||||
// signature is a generated protobuf pattern, not a true immutability contract.
|
||||
for (auto &arg : const_cast<ExecuteServiceRequest &>(msg).args) {
|
||||
if (!arg.string_.empty()) {
|
||||
const_cast<char *>(arg.string_.c_str())[arg.string_.size()] = '\0';
|
||||
}
|
||||
}
|
||||
bool found = false;
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
// Register the call and get a unique server-generated action_call_id
|
||||
|
||||
@@ -29,6 +29,10 @@ static constexpr uint16_t MAX_MESSAGE_SIZE = 8192; // 8 KiB for ESP8266
|
||||
static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and other platforms
|
||||
#endif
|
||||
|
||||
// Extra byte reserved in rx_buf_ beyond the message size so protobuf
|
||||
// StringRef fields can be null-terminated in-place after decode.
|
||||
static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1;
|
||||
|
||||
// Maximum number of messages to batch in a single write operation
|
||||
// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there)
|
||||
static constexpr size_t MAX_MESSAGES_PER_BATCH = 34;
|
||||
|
||||
@@ -194,16 +194,21 @@ APIError APINoiseFrameHelper::try_read_frame_() {
|
||||
uint16_t msg_size = (((uint16_t) rx_header_buf_[1]) << 8) | rx_header_buf_[2];
|
||||
|
||||
// Check against size limits to prevent OOM: MAX_HANDSHAKE_SIZE for handshake, MAX_MESSAGE_SIZE for data
|
||||
uint16_t limit = (state_ == State::DATA) ? MAX_MESSAGE_SIZE : MAX_HANDSHAKE_SIZE;
|
||||
bool is_data = (state_ == State::DATA);
|
||||
uint16_t limit = is_data ? MAX_MESSAGE_SIZE : MAX_HANDSHAKE_SIZE;
|
||||
if (msg_size > limit) {
|
||||
state_ = State::FAILED;
|
||||
HELPER_LOG("Bad packet: message size %u exceeds maximum %u", msg_size, limit);
|
||||
return (state_ == State::DATA) ? APIError::BAD_DATA_PACKET : APIError::BAD_HANDSHAKE_PACKET_LEN;
|
||||
return is_data ? APIError::BAD_DATA_PACKET : APIError::BAD_HANDSHAKE_PACKET_LEN;
|
||||
}
|
||||
|
||||
// Reserve space for body
|
||||
if (this->rx_buf_.size() != msg_size) {
|
||||
this->rx_buf_.resize(msg_size);
|
||||
// Reserve space for body (+ null terminator in DATA state so protobuf
|
||||
// StringRef fields can be safely null-terminated in-place after decode.
|
||||
// During handshake, rx_buf_.size() is used in prologue construction, so
|
||||
// the buffer must be exactly msg_size to avoid prologue mismatch.)
|
||||
uint16_t alloc_size = msg_size + (is_data ? RX_BUF_NULL_TERMINATOR : 0);
|
||||
if (this->rx_buf_.size() != alloc_size) {
|
||||
this->rx_buf_.resize(alloc_size);
|
||||
}
|
||||
|
||||
if (rx_buf_len_ < msg_size) {
|
||||
@@ -407,7 +412,18 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
||||
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_inout(mbuf, this->rx_buf_.data(), this->rx_buf_.size(), this->rx_buf_.size());
|
||||
// read_packet() must only be called in DATA state; the extra
|
||||
// RX_BUF_NULL_TERMINATOR byte is only allocated in DATA state
|
||||
// (see try_read_frame_), so calling this during handshake would
|
||||
// underflow the size calculation below.
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(this->state_ == State::DATA);
|
||||
#endif
|
||||
// rx_buf_ has RX_BUF_NULL_TERMINATOR extra byte for null termination
|
||||
// (only added in DATA state — see try_read_frame_), so subtract it
|
||||
// to get the actual encrypted data size for decryption.
|
||||
size_t encrypted_size = this->rx_buf_.size() - RX_BUF_NULL_TERMINATOR;
|
||||
noise_buffer_set_inout(mbuf, this->rx_buf_.data(), encrypted_size, encrypted_size);
|
||||
int err = noise_cipherstate_decrypt(this->recv_cipher_, &mbuf);
|
||||
APIError decrypt_err =
|
||||
handle_noise_error_(err, LOG_STR("noise_cipherstate_decrypt"), APIError::CIPHERSTATE_DECRYPT_FAILED);
|
||||
@@ -574,7 +590,9 @@ APIError APINoiseFrameHelper::init_handshake_() {
|
||||
}
|
||||
|
||||
APIError APINoiseFrameHelper::check_handshake_finished_() {
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(state_ == State::HANDSHAKE);
|
||||
#endif
|
||||
|
||||
int action = noise_handshakestate_get_action(handshake_);
|
||||
if (action == NOISE_ACTION_READ_MESSAGE || action == NOISE_ACTION_WRITE_MESSAGE)
|
||||
|
||||
@@ -163,9 +163,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
|
||||
}
|
||||
// header reading done
|
||||
|
||||
// Reserve space for body
|
||||
if (this->rx_buf_.size() != this->rx_header_parsed_len_) {
|
||||
this->rx_buf_.resize(this->rx_header_parsed_len_);
|
||||
// Reserve space for body (+ null terminator so protobuf StringRef fields
|
||||
// can be safely null-terminated in-place after decode)
|
||||
if (this->rx_buf_.size() != this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR) {
|
||||
this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR);
|
||||
}
|
||||
|
||||
if (rx_buf_len_ < rx_header_parsed_len_) {
|
||||
|
||||
@@ -954,12 +954,66 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
bool DSTRule::decode_varint(uint32_t field_id, ProtoVarInt value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
this->time_seconds = value.as_sint32();
|
||||
break;
|
||||
case 2:
|
||||
this->day = value.as_uint32();
|
||||
break;
|
||||
case 3:
|
||||
this->type = static_cast<enums::DSTRuleType>(value.as_uint32());
|
||||
break;
|
||||
case 4:
|
||||
this->month = value.as_uint32();
|
||||
break;
|
||||
case 5:
|
||||
this->week = value.as_uint32();
|
||||
break;
|
||||
case 6:
|
||||
this->day_of_week = value.as_uint32();
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool ParsedTimezone::decode_varint(uint32_t field_id, ProtoVarInt value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
this->std_offset_seconds = value.as_sint32();
|
||||
break;
|
||||
case 2:
|
||||
this->dst_offset_seconds = value.as_sint32();
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
|
||||
switch (field_id) {
|
||||
case 3:
|
||||
value.decode_to_message(this->dst_start);
|
||||
break;
|
||||
case 4:
|
||||
value.decode_to_message(this->dst_end);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
|
||||
switch (field_id) {
|
||||
case 2: {
|
||||
this->timezone = StringRef(reinterpret_cast<const char *>(value.data()), value.size());
|
||||
break;
|
||||
}
|
||||
case 3:
|
||||
value.decode_to_message(this->parsed_timezone);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ enum LogLevel : uint32_t {
|
||||
LOG_LEVEL_VERBOSE = 6,
|
||||
LOG_LEVEL_VERY_VERBOSE = 7,
|
||||
};
|
||||
enum DSTRuleType : uint32_t {
|
||||
DST_RULE_TYPE_NONE = 0,
|
||||
DST_RULE_TYPE_MONTH_WEEK_DAY = 1,
|
||||
DST_RULE_TYPE_JULIAN_NO_LEAP = 2,
|
||||
DST_RULE_TYPE_DAY_OF_YEAR = 3,
|
||||
};
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
enum ServiceArgType : uint32_t {
|
||||
SERVICE_ARG_TYPE_BOOL = 0,
|
||||
@@ -316,7 +322,6 @@ enum ZWaveProxyRequestType : uint32_t {
|
||||
|
||||
class InfoResponseProtoMessage : public ProtoMessage {
|
||||
public:
|
||||
~InfoResponseProtoMessage() override = default;
|
||||
StringRef object_id{};
|
||||
uint32_t key{0};
|
||||
StringRef name{};
|
||||
@@ -330,28 +335,29 @@ class InfoResponseProtoMessage : public ProtoMessage {
|
||||
#endif
|
||||
|
||||
protected:
|
||||
~InfoResponseProtoMessage() = default;
|
||||
};
|
||||
|
||||
class StateResponseProtoMessage : public ProtoMessage {
|
||||
public:
|
||||
~StateResponseProtoMessage() override = default;
|
||||
uint32_t key{0};
|
||||
#ifdef USE_DEVICES
|
||||
uint32_t device_id{0};
|
||||
#endif
|
||||
|
||||
protected:
|
||||
~StateResponseProtoMessage() = default;
|
||||
};
|
||||
|
||||
class CommandProtoMessage : public ProtoDecodableMessage {
|
||||
public:
|
||||
~CommandProtoMessage() override = default;
|
||||
uint32_t key{0};
|
||||
#ifdef USE_DEVICES
|
||||
uint32_t device_id{0};
|
||||
#endif
|
||||
|
||||
protected:
|
||||
~CommandProtoMessage() = default;
|
||||
};
|
||||
class HelloRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
@@ -1116,15 +1122,45 @@ class GetTimeRequest final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
class DSTRule final : public ProtoDecodableMessage {
|
||||
public:
|
||||
int32_t time_seconds{0};
|
||||
uint32_t day{0};
|
||||
enums::DSTRuleType type{};
|
||||
uint32_t month{0};
|
||||
uint32_t week{0};
|
||||
uint32_t day_of_week{0};
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool decode_varint(uint32_t field_id, ProtoVarInt value) override;
|
||||
};
|
||||
class ParsedTimezone final : public ProtoDecodableMessage {
|
||||
public:
|
||||
int32_t std_offset_seconds{0};
|
||||
int32_t dst_offset_seconds{0};
|
||||
DSTRule dst_start{};
|
||||
DSTRule dst_end{};
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;
|
||||
bool decode_varint(uint32_t field_id, ProtoVarInt value) override;
|
||||
};
|
||||
class GetTimeResponse final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 37;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 14;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 31;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *message_name() const override { return "get_time_response"; }
|
||||
#endif
|
||||
uint32_t epoch_seconds{0};
|
||||
StringRef timezone{};
|
||||
ParsedTimezone parsed_timezone{};
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
@@ -208,6 +208,20 @@ template<> const char *proto_enum_to_string<enums::LogLevel>(enums::LogLevel val
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
template<> const char *proto_enum_to_string<enums::DSTRuleType>(enums::DSTRuleType value) {
|
||||
switch (value) {
|
||||
case enums::DST_RULE_TYPE_NONE:
|
||||
return "DST_RULE_TYPE_NONE";
|
||||
case enums::DST_RULE_TYPE_MONTH_WEEK_DAY:
|
||||
return "DST_RULE_TYPE_MONTH_WEEK_DAY";
|
||||
case enums::DST_RULE_TYPE_JULIAN_NO_LEAP:
|
||||
return "DST_RULE_TYPE_JULIAN_NO_LEAP";
|
||||
case enums::DST_RULE_TYPE_DAY_OF_YEAR:
|
||||
return "DST_RULE_TYPE_DAY_OF_YEAR";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
template<> const char *proto_enum_to_string<enums::ServiceArgType>(enums::ServiceArgType value) {
|
||||
switch (value) {
|
||||
@@ -1254,10 +1268,35 @@ const char *GetTimeRequest::dump_to(DumpBuffer &out) const {
|
||||
out.append("GetTimeRequest {}");
|
||||
return out.c_str();
|
||||
}
|
||||
const char *DSTRule::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, "DSTRule");
|
||||
dump_field(out, "time_seconds", this->time_seconds);
|
||||
dump_field(out, "day", this->day);
|
||||
dump_field(out, "type", static_cast<enums::DSTRuleType>(this->type));
|
||||
dump_field(out, "month", this->month);
|
||||
dump_field(out, "week", this->week);
|
||||
dump_field(out, "day_of_week", this->day_of_week);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *ParsedTimezone::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, "ParsedTimezone");
|
||||
dump_field(out, "std_offset_seconds", this->std_offset_seconds);
|
||||
dump_field(out, "dst_offset_seconds", this->dst_offset_seconds);
|
||||
out.append(" dst_start: ");
|
||||
this->dst_start.dump_to(out);
|
||||
out.append("\n");
|
||||
out.append(" dst_end: ");
|
||||
this->dst_end.dump_to(out);
|
||||
out.append("\n");
|
||||
return out.c_str();
|
||||
}
|
||||
const char *GetTimeResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, "GetTimeResponse");
|
||||
dump_field(out, "epoch_seconds", this->epoch_seconds);
|
||||
dump_field(out, "timezone", this->timezone);
|
||||
out.append(" parsed_timezone: ");
|
||||
this->parsed_timezone.dump_to(out);
|
||||
out.append("\n");
|
||||
return out.c_str();
|
||||
}
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
|
||||
@@ -452,7 +452,6 @@ class DumpBuffer {
|
||||
|
||||
class ProtoMessage {
|
||||
public:
|
||||
virtual ~ProtoMessage() = default;
|
||||
// Default implementation for messages with no fields
|
||||
virtual void encode(ProtoWriteBuffer &buffer) const {}
|
||||
// Default implementation for messages with no fields
|
||||
@@ -463,6 +462,11 @@ class ProtoMessage {
|
||||
virtual const char *dump_to(DumpBuffer &out) const = 0;
|
||||
virtual const char *message_name() const { return "unknown"; }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
// Non-virtual: messages are never deleted polymorphically.
|
||||
// Protected prevents accidental `delete base_ptr` (compile error).
|
||||
~ProtoMessage() = default;
|
||||
};
|
||||
|
||||
// Base class for messages that support decoding
|
||||
@@ -482,6 +486,7 @@ class ProtoDecodableMessage : public ProtoMessage {
|
||||
static uint32_t count_repeated_field(const uint8_t *buffer, size_t length, uint32_t target_field_id);
|
||||
|
||||
protected:
|
||||
~ProtoDecodableMessage() = default;
|
||||
virtual bool decode_varint(uint32_t field_id, ProtoVarInt value) { return false; }
|
||||
virtual bool decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; }
|
||||
virtual bool decode_32bit(uint32_t field_id, Proto32Bit value) { return false; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "user_services.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
@@ -11,6 +12,8 @@ template<> int32_t get_execute_arg_value<int32_t>(const ExecuteServiceArgument &
|
||||
}
|
||||
template<> float get_execute_arg_value<float>(const ExecuteServiceArgument &arg) { return arg.float_; }
|
||||
template<> std::string get_execute_arg_value<std::string>(const ExecuteServiceArgument &arg) { return arg.string_; }
|
||||
// Zero-copy StringRef version for YAML-generated services (string_ is null-terminated after decode)
|
||||
template<> StringRef get_execute_arg_value<StringRef>(const ExecuteServiceArgument &arg) { return arg.string_; }
|
||||
|
||||
// Legacy std::vector versions for external components using custom_api_device.h - optimized with reserve
|
||||
template<> std::vector<bool> get_execute_arg_value<std::vector<bool>>(const ExecuteServiceArgument &arg) {
|
||||
@@ -61,6 +64,8 @@ template<> enums::ServiceArgType to_service_arg_type<bool>() { return enums::SER
|
||||
template<> enums::ServiceArgType to_service_arg_type<int32_t>() { return enums::SERVICE_ARG_TYPE_INT; }
|
||||
template<> enums::ServiceArgType to_service_arg_type<float>() { return enums::SERVICE_ARG_TYPE_FLOAT; }
|
||||
template<> enums::ServiceArgType to_service_arg_type<std::string>() { return enums::SERVICE_ARG_TYPE_STRING; }
|
||||
// Zero-copy StringRef version for YAML-generated services
|
||||
template<> enums::ServiceArgType to_service_arg_type<StringRef>() { return enums::SERVICE_ARG_TYPE_STRING; }
|
||||
|
||||
// Legacy std::vector versions for external components using custom_api_device.h
|
||||
template<> enums::ServiceArgType to_service_arg_type<std::vector<bool>>() { return enums::SERVICE_ARG_TYPE_BOOL_ARRAY; }
|
||||
|
||||
@@ -123,7 +123,9 @@ BUTTON_PRESS_SCHEMA = maybe_simple_id(
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action("button.press", PressAction, BUTTON_PRESS_SCHEMA)
|
||||
@automation.register_action(
|
||||
"button.press", PressAction, BUTTON_PRESS_SCHEMA, synchronous=True
|
||||
)
|
||||
async def button_press_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
@@ -248,25 +248,33 @@ COVER_ACTION_SCHEMA = maybe_simple_id(
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action("cover.open", OpenAction, COVER_ACTION_SCHEMA)
|
||||
@automation.register_action(
|
||||
"cover.open", OpenAction, COVER_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def cover_open_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
|
||||
@automation.register_action("cover.close", CloseAction, COVER_ACTION_SCHEMA)
|
||||
@automation.register_action(
|
||||
"cover.close", CloseAction, COVER_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def cover_close_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
|
||||
@automation.register_action("cover.stop", StopAction, COVER_ACTION_SCHEMA)
|
||||
@automation.register_action(
|
||||
"cover.stop", StopAction, COVER_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def cover_stop_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
|
||||
@automation.register_action("cover.toggle", ToggleAction, COVER_ACTION_SCHEMA)
|
||||
@automation.register_action(
|
||||
"cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def cover_toggle_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
@@ -283,7 +291,9 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action("cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA)
|
||||
@automation.register_action(
|
||||
"cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def cover_control_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
@@ -19,16 +19,7 @@ static constexpr size_t KEY_BUFFER_SIZE = 12;
|
||||
|
||||
struct NVSData {
|
||||
uint32_t key;
|
||||
std::unique_ptr<uint8_t[]> data;
|
||||
size_t len;
|
||||
|
||||
void set_data(const uint8_t *src, size_t size) {
|
||||
if (!this->data || this->len != size) {
|
||||
this->data = std::make_unique<uint8_t[]>(size);
|
||||
this->len = size;
|
||||
}
|
||||
memcpy(this->data.get(), src, size);
|
||||
}
|
||||
SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.)
|
||||
};
|
||||
|
||||
static std::vector<NVSData> s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -41,14 +32,14 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend {
|
||||
// try find in pending saves and update that
|
||||
for (auto &obj : s_pending_save) {
|
||||
if (obj.key == this->key) {
|
||||
obj.set_data(data, len);
|
||||
obj.data.set(data, len);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
NVSData save{};
|
||||
save.key = this->key;
|
||||
save.set_data(data, len);
|
||||
s_pending_save.emplace_back(std::move(save));
|
||||
save.data.set(data, len);
|
||||
s_pending_save.push_back(std::move(save));
|
||||
ESP_LOGVV(TAG, "s_pending_save: key: %" PRIu32 ", len: %zu", this->key, len);
|
||||
return true;
|
||||
}
|
||||
@@ -56,11 +47,11 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend {
|
||||
// try find in pending saves and load from that
|
||||
for (auto &obj : s_pending_save) {
|
||||
if (obj.key == this->key) {
|
||||
if (obj.len != len) {
|
||||
if (obj.data.size() != len) {
|
||||
// size mismatch
|
||||
return false;
|
||||
}
|
||||
memcpy(data, obj.data.get(), len);
|
||||
memcpy(data, obj.data.data(), len);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -133,10 +124,10 @@ class ESP32Preferences : public ESPPreferences {
|
||||
snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key);
|
||||
ESP_LOGVV(TAG, "Checking if NVS data %s has changed", key_str);
|
||||
if (this->is_changed_(this->nvs_handle, save, key_str)) {
|
||||
esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.get(), save.len);
|
||||
ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.len);
|
||||
esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.data(), save.data.size());
|
||||
ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size());
|
||||
if (err != 0) {
|
||||
ESP_LOGV(TAG, "nvs_set_blob('%s', len=%zu) failed: %s", key_str, save.len, esp_err_to_name(err));
|
||||
ESP_LOGV(TAG, "nvs_set_blob('%s', len=%zu) failed: %s", key_str, save.data.size(), esp_err_to_name(err));
|
||||
failed++;
|
||||
last_err = err;
|
||||
last_key = save.key;
|
||||
@@ -144,7 +135,7 @@ class ESP32Preferences : public ESPPreferences {
|
||||
}
|
||||
written++;
|
||||
} else {
|
||||
ESP_LOGV(TAG, "NVS data not changed skipping %" PRIu32 " len=%zu", save.key, save.len);
|
||||
ESP_LOGV(TAG, "NVS data not changed skipping %" PRIu32 " len=%zu", save.key, save.data.size());
|
||||
cached++;
|
||||
}
|
||||
}
|
||||
@@ -176,7 +167,7 @@ class ESP32Preferences : public ESPPreferences {
|
||||
return true;
|
||||
}
|
||||
// Check size first before allocating memory
|
||||
if (actual_len != to_save.len) {
|
||||
if (actual_len != to_save.data.size()) {
|
||||
return true;
|
||||
}
|
||||
// Most preferences are small, use stack buffer with heap fallback for large ones
|
||||
@@ -186,7 +177,7 @@ class ESP32Preferences : public ESPPreferences {
|
||||
ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", key_str, esp_err_to_name(err));
|
||||
return true;
|
||||
}
|
||||
return memcmp(to_save.data.get(), stored_data.get(), to_save.len) != 0;
|
||||
return memcmp(to_save.data.data(), stored_data.get(), to_save.data.size()) != 0;
|
||||
}
|
||||
|
||||
bool reset() override {
|
||||
|
||||
@@ -311,13 +311,17 @@ FAN_ACTION_SCHEMA = maybe_simple_id(
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action("fan.toggle", ToggleAction, FAN_ACTION_SCHEMA)
|
||||
@automation.register_action(
|
||||
"fan.toggle", ToggleAction, FAN_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def fan_toggle_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
|
||||
@automation.register_action("fan.turn_off", TurnOffAction, FAN_ACTION_SCHEMA)
|
||||
@automation.register_action(
|
||||
"fan.turn_off", TurnOffAction, FAN_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def fan_turn_off_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
@@ -336,6 +340,7 @@ async def fan_turn_off_to_code(config, action_id, template_arg, args):
|
||||
),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def fan_turn_on_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
|
||||
@@ -102,6 +102,7 @@ async def to_code(config):
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.string_strict),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def globals_set_to_code(config, action_id, template_arg, args):
|
||||
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
|
||||
|
||||
@@ -43,3 +43,4 @@ async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await improv_base.setup_improv_core(var, config, "improv_serial")
|
||||
cg.add_define("USE_IMPROV_SERIAL")
|
||||
|
||||
@@ -17,16 +17,7 @@ static constexpr size_t KEY_BUFFER_SIZE = 12;
|
||||
|
||||
struct NVSData {
|
||||
uint32_t key;
|
||||
std::unique_ptr<uint8_t[]> data;
|
||||
size_t len;
|
||||
|
||||
void set_data(const uint8_t *src, size_t size) {
|
||||
if (!this->data || this->len != size) {
|
||||
this->data = std::make_unique<uint8_t[]>(size);
|
||||
this->len = size;
|
||||
}
|
||||
memcpy(this->data.get(), src, size);
|
||||
}
|
||||
SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.)
|
||||
};
|
||||
|
||||
static std::vector<NVSData> s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -41,14 +32,14 @@ class LibreTinyPreferenceBackend : public ESPPreferenceBackend {
|
||||
// try find in pending saves and update that
|
||||
for (auto &obj : s_pending_save) {
|
||||
if (obj.key == this->key) {
|
||||
obj.set_data(data, len);
|
||||
obj.data.set(data, len);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
NVSData save{};
|
||||
save.key = this->key;
|
||||
save.set_data(data, len);
|
||||
s_pending_save.emplace_back(std::move(save));
|
||||
save.data.set(data, len);
|
||||
s_pending_save.push_back(std::move(save));
|
||||
ESP_LOGVV(TAG, "s_pending_save: key: %" PRIu32 ", len: %zu", this->key, len);
|
||||
return true;
|
||||
}
|
||||
@@ -57,11 +48,11 @@ class LibreTinyPreferenceBackend : public ESPPreferenceBackend {
|
||||
// try find in pending saves and load from that
|
||||
for (auto &obj : s_pending_save) {
|
||||
if (obj.key == this->key) {
|
||||
if (obj.len != len) {
|
||||
if (obj.data.size() != len) {
|
||||
// size mismatch
|
||||
return false;
|
||||
}
|
||||
memcpy(data, obj.data.get(), len);
|
||||
memcpy(data, obj.data.data(), len);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -122,11 +113,11 @@ class LibreTinyPreferences : public ESPPreferences {
|
||||
snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key);
|
||||
ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str);
|
||||
if (this->is_changed_(&this->db, save, key_str)) {
|
||||
ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.len);
|
||||
fdb_blob_make(&this->blob, save.data.get(), save.len);
|
||||
ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size());
|
||||
fdb_blob_make(&this->blob, save.data.data(), save.data.size());
|
||||
fdb_err_t err = fdb_kv_set_blob(&this->db, key_str, &this->blob);
|
||||
if (err != FDB_NO_ERR) {
|
||||
ESP_LOGV(TAG, "fdb_kv_set_blob('%s', len=%zu) failed: %d", key_str, save.len, err);
|
||||
ESP_LOGV(TAG, "fdb_kv_set_blob('%s', len=%zu) failed: %d", key_str, save.data.size(), err);
|
||||
failed++;
|
||||
last_err = err;
|
||||
last_key = save.key;
|
||||
@@ -134,7 +125,7 @@ class LibreTinyPreferences : public ESPPreferences {
|
||||
}
|
||||
written++;
|
||||
} else {
|
||||
ESP_LOGD(TAG, "FDB data not changed; skipping %" PRIu32 " len=%zu", save.key, save.len);
|
||||
ESP_LOGD(TAG, "FDB data not changed; skipping %" PRIu32 " len=%zu", save.key, save.data.size());
|
||||
cached++;
|
||||
}
|
||||
}
|
||||
@@ -159,7 +150,7 @@ class LibreTinyPreferences : public ESPPreferences {
|
||||
}
|
||||
|
||||
// Check size first - if different, data has changed
|
||||
if (kv.value_len != to_save.len) {
|
||||
if (kv.value_len != to_save.data.size()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -173,7 +164,7 @@ class LibreTinyPreferences : public ESPPreferences {
|
||||
}
|
||||
|
||||
// Compare the actual data
|
||||
return memcmp(to_save.data.get(), stored_data.get(), kv.value_len) != 0;
|
||||
return memcmp(to_save.data.data(), stored_data.get(), kv.value_len) != 0;
|
||||
}
|
||||
|
||||
bool reset() override {
|
||||
|
||||
@@ -51,6 +51,7 @@ from .types import (
|
||||
),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_toggle_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -111,13 +112,13 @@ LIGHT_TURN_ON_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.turn_off", LightControlAction, LIGHT_TURN_OFF_ACTION_SCHEMA
|
||||
"light.turn_off", LightControlAction, LIGHT_TURN_OFF_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"light.turn_on", LightControlAction, LIGHT_TURN_ON_ACTION_SCHEMA
|
||||
"light.turn_on", LightControlAction, LIGHT_TURN_ON_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"light.control", LightControlAction, LIGHT_CONTROL_ACTION_SCHEMA
|
||||
"light.control", LightControlAction, LIGHT_CONTROL_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def light_control_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -193,7 +194,10 @@ LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.dim_relative", DimRelativeAction, LIGHT_DIM_RELATIVE_ACTION_SCHEMA
|
||||
"light.dim_relative",
|
||||
DimRelativeAction,
|
||||
LIGHT_DIM_RELATIVE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_dim_relative_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
|
||||
@@ -519,7 +519,9 @@ LOGGER_LOG_ACTION_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(CONF_LOGGER_LOG, LambdaAction, LOGGER_LOG_ACTION_SCHEMA)
|
||||
@automation.register_action(
|
||||
CONF_LOGGER_LOG, LambdaAction, LOGGER_LOG_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def logger_log_action_to_code(config, action_id, template_arg, args):
|
||||
esp_log = LOG_LEVEL_TO_ESP_LOG[config[CONF_LEVEL]]
|
||||
args_ = [cg.RawExpression(str(x)) for x in config[CONF_ARGS]]
|
||||
|
||||
@@ -492,7 +492,7 @@ MQTT_PUBLISH_ACTION_SCHEMA = cv.Schema(
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"mqtt.publish", MQTTPublishAction, MQTT_PUBLISH_ACTION_SCHEMA
|
||||
"mqtt.publish", MQTTPublishAction, MQTT_PUBLISH_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def mqtt_publish_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -521,7 +521,10 @@ MQTT_PUBLISH_JSON_ACTION_SCHEMA = cv.Schema(
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"mqtt.publish_json", MQTTPublishJsonAction, MQTT_PUBLISH_JSON_ACTION_SCHEMA
|
||||
"mqtt.publish_json",
|
||||
MQTTPublishJsonAction,
|
||||
MQTT_PUBLISH_JSON_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def mqtt_publish_json_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
|
||||
@@ -352,6 +352,7 @@ OPERATION_BASE_SCHEMA = cv.Schema(
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.float_),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def number_set_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -374,6 +375,7 @@ async def number_set_to_code(config, action_id, template_arg, args):
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"number.decrement",
|
||||
@@ -388,6 +390,7 @@ async def number_set_to_code(config, action_id, template_arg, args):
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"number.to_min",
|
||||
@@ -401,6 +404,7 @@ async def number_set_to_code(config, action_id, template_arg, args):
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"number.to_max",
|
||||
@@ -414,6 +418,7 @@ async def number_set_to_code(config, action_id, template_arg, args):
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"number.operation",
|
||||
@@ -426,6 +431,7 @@ async def number_set_to_code(config, action_id, template_arg, args):
|
||||
cv.Optional(CONF_CYCLE, default=True): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def number_to_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
|
||||
@@ -53,6 +53,9 @@ def set_sdkconfig_options(config):
|
||||
# There is a conflict if the logger's uart also uses the default UART, which is seen as a watchdog failure on "ot_cli"
|
||||
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CLI", False)
|
||||
|
||||
# Diag unused, if needed for lab/cert/etc tests then enable separately
|
||||
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DIAG", False)
|
||||
|
||||
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True)
|
||||
|
||||
if tlv := config.get(CONF_TLV):
|
||||
|
||||
@@ -74,14 +74,16 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id(
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action("output.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA)
|
||||
@automation.register_action(
|
||||
"output.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def output_turn_on_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"output.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA
|
||||
"output.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def output_turn_off_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -97,6 +99,7 @@ async def output_turn_off_to_code(config, action_id, template_arg, args):
|
||||
cv.Required(CONF_LEVEL): cv.templatable(cv.percentage),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def output_set_level_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
|
||||
@@ -8,18 +8,26 @@ namespace esphome::rtttl {
|
||||
|
||||
static const char *const TAG = "rtttl";
|
||||
|
||||
// These values can also be found as constants in the Tone library (Tone.h)
|
||||
static const uint16_t NOTES[] = {0, 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494,
|
||||
523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988, 1047,
|
||||
1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976, 2093, 2217,
|
||||
2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951};
|
||||
static constexpr uint8_t SONG_NAME_LENGTH_LIMIT = 64;
|
||||
static constexpr uint8_t SEMITONES_IN_OCTAVE = 12;
|
||||
|
||||
#if defined(USE_OUTPUT) || defined(USE_SPEAKER)
|
||||
static const uint32_t DOUBLE_NOTE_GAP_MS = 10;
|
||||
#endif // USE_OUTPUT || USE_SPEAKER
|
||||
static constexpr uint8_t MIN_OCTAVE = 4;
|
||||
static constexpr uint8_t MAX_OCTAVE = 7;
|
||||
|
||||
static constexpr uint8_t DEFAULT_BPM = 63; // Default beats per minute
|
||||
|
||||
// These values can also be found as constants in the Tone library (Tone.h)
|
||||
static constexpr uint16_t NOTES[] = {0, 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494,
|
||||
523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988, 1047,
|
||||
1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976, 2093, 2217,
|
||||
2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951};
|
||||
static constexpr uint8_t NOTES_COUNT = static_cast<uint8_t>(sizeof(NOTES) / sizeof(NOTES[0]));
|
||||
|
||||
static constexpr uint8_t REPEATING_NOTE_GAP_MS = 10;
|
||||
|
||||
#ifdef USE_SPEAKER
|
||||
static const size_t SAMPLE_BUFFER_SIZE = 2048;
|
||||
static constexpr uint16_t SAMPLE_BUFFER_SIZE = 2048;
|
||||
static constexpr uint16_t SAMPLE_RATE = 16000;
|
||||
|
||||
struct SpeakerSample {
|
||||
int8_t left{0};
|
||||
@@ -27,7 +35,7 @@ struct SpeakerSample {
|
||||
};
|
||||
|
||||
inline double deg2rad(double degrees) {
|
||||
static const double PI_ON_180 = 4.0 * atan(1.0) / 180.0;
|
||||
static constexpr double PI_ON_180 = M_PI / 180.0;
|
||||
return degrees * PI_ON_180;
|
||||
}
|
||||
#endif // USE_SPEAKER
|
||||
@@ -85,7 +93,7 @@ void Rtttl::loop() {
|
||||
}
|
||||
|
||||
#ifdef USE_OUTPUT
|
||||
if (this->output_ != nullptr && millis() - this->last_note_ < this->note_duration_) {
|
||||
if (this->output_ != nullptr && millis() - this->last_note_start_time_ < this->note_duration_) {
|
||||
return;
|
||||
}
|
||||
#endif // USE_OUTPUT
|
||||
@@ -113,36 +121,34 @@ void Rtttl::loop() {
|
||||
}
|
||||
if (this->samples_sent_ != this->samples_count_) {
|
||||
SpeakerSample sample[SAMPLE_BUFFER_SIZE + 2];
|
||||
int x = 0;
|
||||
uint16_t sample_index = 0;
|
||||
double rem = 0.0;
|
||||
|
||||
while (true) {
|
||||
// Try and send out the remainder of the existing note, one per loop()
|
||||
|
||||
if (this->samples_per_wave_ != 0 && this->samples_sent_ >= this->samples_gap_) { // Play note//
|
||||
// Try and send out the remainder of the existing note, one per `loop()`
|
||||
if (this->samples_per_wave_ != 0 && this->samples_sent_ >= this->samples_gap_) { // Play note
|
||||
rem = ((this->samples_sent_ << 10) % this->samples_per_wave_) * (360.0 / this->samples_per_wave_);
|
||||
|
||||
int16_t val = (127 * this->gain_) * sin(deg2rad(rem)); // 16bit = 49152
|
||||
|
||||
sample[x].left = val;
|
||||
sample[x].right = val;
|
||||
int8_t val = (127 * this->gain_) * sin(deg2rad(rem));
|
||||
|
||||
sample[sample_index].left = val;
|
||||
sample[sample_index].right = val;
|
||||
} else {
|
||||
sample[x].left = 0;
|
||||
sample[x].right = 0;
|
||||
sample[sample_index].left = 0;
|
||||
sample[sample_index].right = 0;
|
||||
}
|
||||
|
||||
if (static_cast<size_t>(x) >= SAMPLE_BUFFER_SIZE || this->samples_sent_ >= this->samples_count_) {
|
||||
if (sample_index >= SAMPLE_BUFFER_SIZE || this->samples_sent_ >= this->samples_count_) {
|
||||
break;
|
||||
}
|
||||
this->samples_sent_++;
|
||||
x++;
|
||||
sample_index++;
|
||||
}
|
||||
if (x > 0) {
|
||||
size_t bytes_to_send = x * sizeof(SpeakerSample);
|
||||
if (sample_index > 0) {
|
||||
size_t bytes_to_send = sample_index * sizeof(SpeakerSample);
|
||||
size_t send = this->speaker_->play((uint8_t *) (&sample), bytes_to_send);
|
||||
if (send != bytes_to_send) {
|
||||
this->samples_sent_ -= (x - (send / sizeof(SpeakerSample)));
|
||||
this->samples_sent_ -= (sample_index - (send / sizeof(SpeakerSample)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -155,83 +161,84 @@ void Rtttl::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// align to note: most rtttl's out there does not add and space after the ',' separator but just in case...
|
||||
// Align to note: most rtttl's out there does not add any space after the ',' separator but just in case
|
||||
while (this->rtttl_[this->position_] == ',' || this->rtttl_[this->position_] == ' ') {
|
||||
this->position_++;
|
||||
}
|
||||
|
||||
// first, get note duration, if available
|
||||
uint8_t num = this->get_integer_();
|
||||
// First, get note duration, if available
|
||||
uint8_t note_denominator = this->get_integer_();
|
||||
|
||||
if (num) {
|
||||
this->note_duration_ = this->wholenote_ / num;
|
||||
if (note_denominator) {
|
||||
this->note_duration_ = this->wholenote_duration_ / note_denominator;
|
||||
} else {
|
||||
this->note_duration_ =
|
||||
this->wholenote_ / this->default_duration_; // we will need to check if we are a dotted note after
|
||||
// We will need to check if we are a dotted note after
|
||||
this->note_duration_ = this->wholenote_duration_ / this->default_note_denominator_;
|
||||
}
|
||||
|
||||
uint8_t note = note_index_from_char(this->rtttl_[this->position_]);
|
||||
uint8_t note_index_in_octave = note_index_from_char(this->rtttl_[this->position_]);
|
||||
|
||||
this->position_++;
|
||||
|
||||
// now, get optional '#' sharp
|
||||
// Now, get optional '#' sharp
|
||||
if (this->rtttl_[this->position_] == '#') {
|
||||
note++;
|
||||
note_index_in_octave++;
|
||||
this->position_++;
|
||||
}
|
||||
|
||||
// now, get scale
|
||||
// Now, get scale
|
||||
uint8_t scale = this->get_integer_();
|
||||
if (scale == 0) {
|
||||
scale = this->default_octave_;
|
||||
}
|
||||
|
||||
if (scale < 4 || scale > 7) {
|
||||
ESP_LOGE(TAG, "Octave must be between 4 and 7 (it is %d)", scale);
|
||||
if (scale < MIN_OCTAVE || scale > MAX_OCTAVE) {
|
||||
ESP_LOGE(TAG, "Octave must be between %d and %d (it is %d)", MIN_OCTAVE, MAX_OCTAVE, scale);
|
||||
this->finish_();
|
||||
return;
|
||||
}
|
||||
|
||||
// now, get optional '.' dotted note
|
||||
// Now, get optional '.' dotted note
|
||||
if (this->rtttl_[this->position_] == '.') {
|
||||
this->note_duration_ += this->note_duration_ / 2;
|
||||
this->note_duration_ += this->note_duration_ / 2; // Duration +50%
|
||||
this->position_++;
|
||||
}
|
||||
|
||||
// Now play the note
|
||||
bool need_note_gap = false;
|
||||
if (note) {
|
||||
auto note_index = (scale - 4) * 12 + note;
|
||||
if (note_index < 0 || note_index >= (int) (sizeof(NOTES) / sizeof(NOTES[0]))) {
|
||||
ESP_LOGE(TAG, "Note out of range (note: %d, scale: %d, index: %d, max: %d)", note, scale, note_index,
|
||||
(int) (sizeof(NOTES) / sizeof(NOTES[0])));
|
||||
|
||||
// Now play the note
|
||||
if (note_index_in_octave == 0) {
|
||||
this->output_freq_ = 0;
|
||||
ESP_LOGVV(TAG, "Waiting: %dms", this->note_duration_);
|
||||
} else {
|
||||
uint8_t note_index = (scale - MIN_OCTAVE) * SEMITONES_IN_OCTAVE + note_index_in_octave;
|
||||
if (note_index >= NOTES_COUNT) {
|
||||
ESP_LOGE(TAG, "Note out of range (note: %d, scale: %d, index: %d, max: %d)", note_index_in_octave, scale,
|
||||
note_index, NOTES_COUNT);
|
||||
this->finish_();
|
||||
return;
|
||||
}
|
||||
auto freq = NOTES[note_index];
|
||||
uint16_t freq = NOTES[note_index];
|
||||
need_note_gap = freq == this->output_freq_;
|
||||
|
||||
// Add small silence gap between same note
|
||||
this->output_freq_ = freq;
|
||||
|
||||
ESP_LOGVV(TAG, "playing note: %d for %dms", note, this->note_duration_);
|
||||
} else {
|
||||
ESP_LOGVV(TAG, "waiting: %dms", this->note_duration_);
|
||||
this->output_freq_ = 0;
|
||||
ESP_LOGVV(TAG, "Playing note: %d for %dms", note_index_in_octave, this->note_duration_);
|
||||
}
|
||||
|
||||
#ifdef USE_OUTPUT
|
||||
if (this->output_ != nullptr) {
|
||||
if (need_note_gap && this->note_duration_ > DOUBLE_NOTE_GAP_MS) {
|
||||
if (this->output_freq_ == 0) {
|
||||
this->output_->set_level(0.0);
|
||||
delay(DOUBLE_NOTE_GAP_MS);
|
||||
this->note_duration_ -= DOUBLE_NOTE_GAP_MS;
|
||||
}
|
||||
if (this->output_freq_ != 0) {
|
||||
} else {
|
||||
if (need_note_gap && this->note_duration_ > REPEATING_NOTE_GAP_MS) {
|
||||
this->output_->set_level(0.0);
|
||||
delay(REPEATING_NOTE_GAP_MS);
|
||||
this->note_duration_ -= REPEATING_NOTE_GAP_MS;
|
||||
}
|
||||
this->output_->update_frequency(this->output_freq_);
|
||||
this->output_->set_level(this->gain_);
|
||||
} else {
|
||||
this->output_->set_level(0.0);
|
||||
}
|
||||
}
|
||||
#endif // USE_OUTPUT
|
||||
@@ -241,28 +248,26 @@ void Rtttl::loop() {
|
||||
this->samples_sent_ = 0;
|
||||
this->samples_gap_ = 0;
|
||||
this->samples_per_wave_ = 0;
|
||||
this->samples_count_ = (this->sample_rate_ * this->note_duration_) / 1000;
|
||||
this->samples_count_ = (SAMPLE_RATE * this->note_duration_) / 1000;
|
||||
if (need_note_gap) {
|
||||
this->samples_gap_ = (this->sample_rate_ * DOUBLE_NOTE_GAP_MS) / 1000;
|
||||
this->samples_gap_ = (SAMPLE_RATE * REPEATING_NOTE_GAP_MS) / 1000;
|
||||
}
|
||||
if (this->output_freq_ != 0) {
|
||||
// make sure there is enough samples to add a full last sinus.
|
||||
|
||||
uint16_t samples_wish = this->samples_count_;
|
||||
this->samples_per_wave_ = (this->sample_rate_ << 10) / this->output_freq_;
|
||||
// Make sure there is enough samples to add a full last sinus.
|
||||
uint32_t samples_wish = this->samples_count_;
|
||||
this->samples_per_wave_ = (SAMPLE_RATE << 10) / this->output_freq_;
|
||||
|
||||
uint16_t division = ((this->samples_count_ << 10) / this->samples_per_wave_) + 1;
|
||||
|
||||
this->samples_count_ = (division * this->samples_per_wave_);
|
||||
this->samples_count_ = this->samples_count_ >> 10;
|
||||
ESP_LOGVV(TAG, "- Calc play time: wish: %d gets: %d (div: %d spw: %d)", samples_wish, this->samples_count_,
|
||||
division, this->samples_per_wave_);
|
||||
this->samples_count_ = (division * this->samples_per_wave_) >> 10;
|
||||
ESP_LOGVV(TAG, "Calc play time: wish: %" PRIu32 " gets: %" PRIu32 " (div: %d spw: %" PRIu32 ")", samples_wish,
|
||||
this->samples_count_, division, this->samples_per_wave_);
|
||||
}
|
||||
// Convert from frequency in Hz to high and low samples in fixed point
|
||||
}
|
||||
#endif // USE_SPEAKER
|
||||
|
||||
this->last_note_ = millis();
|
||||
this->last_note_start_time_ = millis();
|
||||
}
|
||||
|
||||
void Rtttl::play(std::string rtttl) {
|
||||
@@ -275,25 +280,28 @@ void Rtttl::play(std::string rtttl) {
|
||||
|
||||
this->rtttl_ = std::move(rtttl);
|
||||
|
||||
this->default_duration_ = 4;
|
||||
this->default_octave_ = 6;
|
||||
this->default_note_denominator_ = DEFAULT_NOTE_DENOMINATOR;
|
||||
this->default_octave_ = DEFAULT_OCTAVE;
|
||||
this->note_duration_ = 0;
|
||||
|
||||
int bpm = 63;
|
||||
uint16_t num;
|
||||
uint16_t bpm = DEFAULT_BPM;
|
||||
uint16_t num; // Used for: default note-denominator, default octave, BPM
|
||||
|
||||
// Get name
|
||||
this->position_ = this->rtttl_.find(':');
|
||||
|
||||
// it's somewhat documented to be up to 10 characters but let's be a bit flexible here
|
||||
if (this->position_ == std::string::npos || this->position_ > 15) {
|
||||
if (this->position_ == std::string::npos) {
|
||||
ESP_LOGE(TAG, "Unable to determine name; missing ':'");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->position_ >= SONG_NAME_LENGTH_LIMIT) {
|
||||
ESP_LOGE(TAG, "Name is too long: length=%u, limit=%u", static_cast<unsigned>(this->position_),
|
||||
static_cast<unsigned>(SONG_NAME_LENGTH_LIMIT));
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str());
|
||||
|
||||
// get default duration
|
||||
// Get default duration
|
||||
this->position_ = this->rtttl_.find("d=", this->position_);
|
||||
if (this->position_ == std::string::npos) {
|
||||
ESP_LOGE(TAG, "Missing 'd='");
|
||||
@@ -301,11 +309,14 @@ void Rtttl::play(std::string rtttl) {
|
||||
}
|
||||
this->position_ += 2;
|
||||
num = this->get_integer_();
|
||||
if (num > 0) {
|
||||
this->default_duration_ = num;
|
||||
if (num == 1 || num == 2 || num == 4 || num == 8 || num == 16 || num == 32) {
|
||||
this->default_note_denominator_ = num;
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Invalid default duration: %d", num);
|
||||
return;
|
||||
}
|
||||
|
||||
// get default octave
|
||||
// Get default octave
|
||||
this->position_ = this->rtttl_.find("o=", this->position_);
|
||||
if (this->position_ == std::string::npos) {
|
||||
ESP_LOGE(TAG, "Missing 'o=");
|
||||
@@ -313,11 +324,14 @@ void Rtttl::play(std::string rtttl) {
|
||||
}
|
||||
this->position_ += 2;
|
||||
num = this->get_integer_();
|
||||
if (num >= 3 && num <= 7) {
|
||||
if (num >= MIN_OCTAVE && num <= MAX_OCTAVE) {
|
||||
this->default_octave_ = num;
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Invalid default octave: %d", num);
|
||||
return;
|
||||
}
|
||||
|
||||
// get BPM
|
||||
// Get BPM
|
||||
this->position_ = this->rtttl_.find("b=", this->position_);
|
||||
if (this->position_ == std::string::npos) {
|
||||
ESP_LOGE(TAG, "Missing b=");
|
||||
@@ -325,8 +339,11 @@ void Rtttl::play(std::string rtttl) {
|
||||
}
|
||||
this->position_ += 2;
|
||||
num = this->get_integer_();
|
||||
if (num != 0) {
|
||||
if (num >= 4) { // Below 4 is not realistic and would cause a integer overflow
|
||||
bpm = num;
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Invalid BPM: %d", num);
|
||||
return;
|
||||
}
|
||||
|
||||
this->position_ = this->rtttl_.find(':', this->position_);
|
||||
@@ -337,10 +354,10 @@ void Rtttl::play(std::string rtttl) {
|
||||
this->position_++;
|
||||
|
||||
// BPM usually expresses the number of quarter notes per minute
|
||||
this->wholenote_ = 60 * 1000L * 4 / bpm; // this is the time for whole note (in milliseconds)
|
||||
this->wholenote_duration_ = 60 * 1000L * 4 / bpm; // This is the time for whole note (in milliseconds)
|
||||
|
||||
this->output_freq_ = 0;
|
||||
this->last_note_ = millis();
|
||||
this->last_note_start_time_ = millis();
|
||||
this->note_duration_ = 1;
|
||||
|
||||
#ifdef USE_OUTPUT
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
|
||||
namespace esphome::rtttl {
|
||||
|
||||
inline constexpr uint8_t DEFAULT_NOTE_DENOMINATOR = 4; // Default note-denominator (quarter note)
|
||||
inline constexpr uint8_t DEFAULT_OCTAVE =
|
||||
6; // Default octave for a note (see: `MIN_OCTAVE`, `MAX_OCTAVE` in `rtttl.cpp`)
|
||||
|
||||
enum class State : uint8_t {
|
||||
STOPPED = 0,
|
||||
INIT,
|
||||
@@ -67,19 +71,18 @@ class Rtttl : public Component {
|
||||
std::string rtttl_{""};
|
||||
/// The current position in the RTTTL string.
|
||||
size_t position_{0};
|
||||
/// The duration of a whole note in milliseconds.
|
||||
uint16_t wholenote_;
|
||||
/// The default duration of a note (e.g. 4 for a quarter note).
|
||||
uint16_t default_duration_;
|
||||
uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR};
|
||||
/// The default octave for a note.
|
||||
uint16_t default_octave_;
|
||||
/// The time the last note was started.
|
||||
uint32_t last_note_;
|
||||
uint8_t default_octave_{DEFAULT_OCTAVE};
|
||||
/// The duration of the current note in milliseconds.
|
||||
uint16_t note_duration_;
|
||||
|
||||
uint16_t note_duration_{0};
|
||||
/// The duration of a whole note in milliseconds.
|
||||
uint16_t wholenote_duration_;
|
||||
/// The time in milliseconds since microcontroller boot when the last note was started.
|
||||
uint32_t last_note_start_time_;
|
||||
/// The frequency of the current note in Hz.
|
||||
uint32_t output_freq_;
|
||||
uint32_t output_freq_{0};
|
||||
/// The gain of the output.
|
||||
float gain_{0.6f};
|
||||
/// The current state of the RTTTL player.
|
||||
@@ -93,16 +96,14 @@ class Rtttl : public Component {
|
||||
#ifdef USE_SPEAKER
|
||||
/// The speaker to write the sound to.
|
||||
speaker::Speaker *speaker_{nullptr};
|
||||
/// The sample rate of the speaker.
|
||||
int sample_rate_{16000};
|
||||
/// The number of samples for one full cycle of a note's waveform, in Q10 fixed-point format.
|
||||
int samples_per_wave_{0};
|
||||
uint32_t samples_per_wave_{0};
|
||||
/// The number of samples sent.
|
||||
int samples_sent_{0};
|
||||
uint32_t samples_sent_{0};
|
||||
/// The total number of samples to send.
|
||||
int samples_count_{0};
|
||||
uint32_t samples_count_{0};
|
||||
/// The number of samples for the gap between notes.
|
||||
int samples_gap_{0};
|
||||
uint32_t samples_gap_{0};
|
||||
#endif // USE_SPEAKER
|
||||
|
||||
/// The callback to call when playback is finished.
|
||||
|
||||
@@ -160,6 +160,7 @@ async def to_code(config):
|
||||
cv.Optional(validate_parameter_name): cv.templatable(cv.valid),
|
||||
},
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def script_execute_action_to_code(config, action_id, template_arg, args):
|
||||
def convert(type: str):
|
||||
@@ -208,6 +209,7 @@ async def script_execute_action_to_code(config, action_id, template_arg, args):
|
||||
"script.stop",
|
||||
ScriptStopAction,
|
||||
maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}),
|
||||
synchronous=True,
|
||||
)
|
||||
async def script_stop_action_to_code(config, action_id, template_arg, args):
|
||||
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
|
||||
|
||||
@@ -145,6 +145,7 @@ OPERATION_BASE_SCHEMA = cv.Schema(
|
||||
cv.Required(CONF_OPTION): cv.templatable(cv.string_strict),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def select_set_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -162,6 +163,7 @@ async def select_set_to_code(config, action_id, template_arg, args):
|
||||
cv.Required(CONF_INDEX): cv.templatable(cv.positive_int),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def select_set_index_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -217,6 +219,7 @@ async def select_is_to_code(config, condition_id, template_arg, args):
|
||||
cv.Optional(CONF_CYCLE, default=True): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"select.next",
|
||||
@@ -229,6 +232,7 @@ async def select_is_to_code(config, condition_id, template_arg, args):
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"select.previous",
|
||||
@@ -243,6 +247,7 @@ async def select_is_to_code(config, condition_id, template_arg, args):
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"select.first",
|
||||
@@ -254,6 +259,7 @@ async def select_is_to_code(config, condition_id, template_arg, args):
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"select.last",
|
||||
@@ -265,6 +271,7 @@ async def select_is_to_code(config, condition_id, template_arg, args):
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def select_operation_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
|
||||
@@ -204,7 +204,7 @@ SWITCH_CONTROL_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"switch.control", ControlAction, SWITCH_CONTROL_ACTION_SCHEMA
|
||||
"switch.control", ControlAction, SWITCH_CONTROL_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def switch_control_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -214,9 +214,15 @@ async def switch_control_to_code(config, action_id, template_arg, args):
|
||||
return var
|
||||
|
||||
|
||||
@automation.register_action("switch.toggle", ToggleAction, SWITCH_ACTION_SCHEMA)
|
||||
@automation.register_action("switch.turn_off", TurnOffAction, SWITCH_ACTION_SCHEMA)
|
||||
@automation.register_action("switch.turn_on", TurnOnAction, SWITCH_ACTION_SCHEMA)
|
||||
@automation.register_action(
|
||||
"switch.toggle", ToggleAction, SWITCH_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"switch.turn_off", TurnOffAction, SWITCH_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"switch.turn_on", TurnOnAction, SWITCH_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def switch_toggle_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
@@ -164,6 +164,7 @@ OPERATION_BASE_SCHEMA = cv.Schema(
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.string_strict),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def text_set_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from importlib import resources
|
||||
import logging
|
||||
|
||||
from aioesphomeapi.posix_tz import (
|
||||
DSTRuleType as PyDSTRuleType,
|
||||
parse_posix_tz as parse_posix_tz_python,
|
||||
)
|
||||
import tzlocal
|
||||
|
||||
from esphome import automation
|
||||
@@ -39,6 +43,19 @@ CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Co
|
||||
SyncTrigger = time_ns.class_("SyncTrigger", automation.Trigger.template(), cg.Component)
|
||||
TimeHasTimeCondition = time_ns.class_("TimeHasTimeCondition", Condition)
|
||||
|
||||
# C++ types for pre-parsed timezone struct generation
|
||||
DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True)
|
||||
DSTRule_cpp = time_ns.struct("DSTRule")
|
||||
ParsedTimezone_cpp = time_ns.struct("ParsedTimezone")
|
||||
|
||||
# Map Python DSTRuleType enum values to C++ enum expressions
|
||||
_DST_RULE_TYPE_MAP = {
|
||||
PyDSTRuleType.NONE: DSTRuleType_cpp.NONE,
|
||||
PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY,
|
||||
PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP,
|
||||
PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR,
|
||||
}
|
||||
|
||||
|
||||
def _load_tzdata(iana_key: str) -> bytes | None:
|
||||
# From https://tzdata.readthedocs.io/en/latest/#examples
|
||||
@@ -260,11 +277,17 @@ def validate_tz(value: str) -> str:
|
||||
value = cv.string_strict(value)
|
||||
|
||||
tzfile = _load_tzdata(value)
|
||||
if tzfile is None:
|
||||
# Not a IANA key, probably a TZ string
|
||||
return value
|
||||
if tzfile is not None:
|
||||
value = _extract_tz_string(tzfile)
|
||||
|
||||
return _extract_tz_string(tzfile)
|
||||
# Validate that the POSIX TZ string is parseable (skip empty strings)
|
||||
if value:
|
||||
try:
|
||||
parse_posix_tz_python(value)
|
||||
except ValueError as e:
|
||||
raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e
|
||||
|
||||
return value
|
||||
|
||||
|
||||
TIME_SCHEMA = cv.Schema(
|
||||
@@ -305,11 +328,46 @@ TIME_SCHEMA = cv.Schema(
|
||||
).extend(cv.polling_component_schema("15min"))
|
||||
|
||||
|
||||
def _emit_dst_rule_fields(prefix, rule):
|
||||
"""Emit field-by-field assignments for a DSTRule to avoid rodata struct blob."""
|
||||
cg.add(cg.RawExpression(f"{prefix}.time_seconds = {rule.time_seconds}"))
|
||||
cg.add(cg.RawExpression(f"{prefix}.day = {rule.day}"))
|
||||
cg.add(cg.RawExpression(f"{prefix}.type = {_DST_RULE_TYPE_MAP[rule.type]}"))
|
||||
cg.add(cg.RawExpression(f"{prefix}.month = {rule.month}"))
|
||||
cg.add(cg.RawExpression(f"{prefix}.week = {rule.week}"))
|
||||
cg.add(cg.RawExpression(f"{prefix}.day_of_week = {rule.day_of_week}"))
|
||||
|
||||
|
||||
def _emit_parsed_timezone_fields(parsed):
|
||||
"""Emit field-by-field assignments for a local ParsedTimezone, then set_global_tz().
|
||||
|
||||
Uses individual assignments on a stack variable instead of a struct initializer
|
||||
to keep constants as immediate operands in instructions (.irom0.text/flash)
|
||||
rather than a const blob in .rodata (which maps to RAM on ESP8266).
|
||||
Wrapped in a scope block to allow multiple time platforms in the same build.
|
||||
"""
|
||||
cg.add(cg.RawStatement("{"))
|
||||
cg.add(cg.RawExpression("time::ParsedTimezone tz{}"))
|
||||
cg.add(cg.RawExpression(f"tz.std_offset_seconds = {parsed.std_offset_seconds}"))
|
||||
cg.add(cg.RawExpression(f"tz.dst_offset_seconds = {parsed.dst_offset_seconds}"))
|
||||
_emit_dst_rule_fields("tz.dst_start", parsed.dst_start)
|
||||
_emit_dst_rule_fields("tz.dst_end", parsed.dst_end)
|
||||
cg.add(time_ns.set_global_tz(cg.RawExpression("tz")))
|
||||
cg.add(cg.RawStatement("}"))
|
||||
|
||||
|
||||
async def setup_time_core_(time_var, config):
|
||||
if timezone := config.get(CONF_TIMEZONE):
|
||||
cg.add(time_var.set_timezone(timezone))
|
||||
cg.add_define("USE_TIME_TIMEZONE")
|
||||
|
||||
if CORE.is_host:
|
||||
# Host platform needs setenv("TZ")/tzset() for libc compatibility
|
||||
cg.add(time_var.set_timezone(timezone))
|
||||
else:
|
||||
# Embedded: pre-parse at codegen time, emit struct directly
|
||||
parsed = parse_posix_tz_python(timezone)
|
||||
_emit_parsed_timezone_fields(parsed)
|
||||
|
||||
for conf in config.get(CONF_ON_TIME, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from dataclasses import dataclass
|
||||
from logging import getLogger
|
||||
import math
|
||||
import re
|
||||
@@ -117,38 +116,6 @@ MULTI_CONF = True
|
||||
MULTI_CONF_NO_DEFAULT = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class UARTData:
|
||||
"""State data for UART component configuration generation."""
|
||||
|
||||
wake_loop_on_rx: bool = False
|
||||
|
||||
|
||||
def _get_data() -> UARTData:
|
||||
"""Get UART component data from CORE.data."""
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = UARTData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_wake_loop_on_rx() -> None:
|
||||
"""Request that the UART wake the main loop when data is received.
|
||||
|
||||
Components that need low-latency notification of incoming UART data
|
||||
should call this function during their code generation.
|
||||
This enables the RX event task which wakes the main loop when data arrives.
|
||||
"""
|
||||
data = _get_data()
|
||||
if not data.wake_loop_on_rx:
|
||||
data.wake_loop_on_rx = True
|
||||
|
||||
# UART RX event task uses wake_loop_threadsafe() to notify the main loop
|
||||
# Automatically enable the socket wake infrastructure when RX wake is requested
|
||||
from esphome.components import socket
|
||||
|
||||
socket.require_wake_loop_threadsafe()
|
||||
|
||||
|
||||
def validate_raw_data(value):
|
||||
if isinstance(value, str):
|
||||
return value.encode("utf-8")
|
||||
@@ -542,7 +509,14 @@ async def uart_write_to_code(config, action_id, template_arg, args):
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def final_step():
|
||||
"""Final code generation step to configure optional UART features."""
|
||||
if _get_data().wake_loop_on_rx:
|
||||
if CORE.is_esp32 and CORE.has_networking:
|
||||
# Wake-on-RX is essentially free on ESP32 (just an ISR function pointer
|
||||
# registration) — enable by default to reduce RX buffer overflow risk
|
||||
# by waking the main loop immediately when data arrives.
|
||||
# Requires networking for the wake_loop_isrsafe() infrastructure.
|
||||
from esphome.components import socket
|
||||
|
||||
socket.require_wake_loop_threadsafe()
|
||||
cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
|
||||
|
||||
|
||||
|
||||
@@ -2121,7 +2121,7 @@ bool WiFiComponent::can_proceed() {
|
||||
#endif
|
||||
|
||||
void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
|
||||
bool WiFiComponent::is_connected() {
|
||||
bool WiFiComponent::is_connected() const {
|
||||
return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED &&
|
||||
this->wifi_sta_connect_status_() == WiFiSTAConnectStatus::CONNECTED && !this->error_from_callback_;
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@ class WiFiComponent : public Component {
|
||||
|
||||
void set_reboot_timeout(uint32_t reboot_timeout);
|
||||
|
||||
bool is_connected();
|
||||
bool is_connected() const;
|
||||
|
||||
void set_power_save_mode(WiFiPowerSaveMode power_save);
|
||||
void set_min_auth_mode(WifiMinAuthMode min_auth_mode) { min_auth_mode_ = min_auth_mode; }
|
||||
@@ -677,7 +677,7 @@ class WiFiComponent : public Component {
|
||||
bool wifi_apply_hostname_();
|
||||
bool wifi_sta_connect_(const WiFiAP &ap);
|
||||
void wifi_pre_setup_();
|
||||
WiFiSTAConnectStatus wifi_sta_connect_status_();
|
||||
WiFiSTAConnectStatus wifi_sta_connect_status_() const;
|
||||
bool wifi_scan_start_(bool passive);
|
||||
|
||||
#ifdef USE_WIFI_AP
|
||||
|
||||
@@ -622,7 +622,7 @@ void WiFiComponent::wifi_pre_setup_() {
|
||||
this->wifi_mode_(false, false);
|
||||
}
|
||||
|
||||
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() {
|
||||
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const {
|
||||
station_status_t status = wifi_station_get_connect_status();
|
||||
if (status == STATION_GOT_IP)
|
||||
return WiFiSTAConnectStatus::CONNECTED;
|
||||
|
||||
@@ -921,7 +921,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
|
||||
}
|
||||
}
|
||||
|
||||
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() {
|
||||
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const {
|
||||
if (s_sta_connected && this->got_ipv4_address_) {
|
||||
#if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
|
||||
if (this->num_ipv6_addresses_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT) {
|
||||
|
||||
@@ -634,7 +634,7 @@ void WiFiComponent::wifi_pre_setup_() {
|
||||
// Make sure WiFi is in clean state before anything starts
|
||||
this->wifi_mode_(false, false);
|
||||
}
|
||||
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() {
|
||||
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const {
|
||||
// Use state machine instead of querying WiFi.status() directly
|
||||
// State is updated in main loop from queued events, ensuring thread safety
|
||||
switch (s_sta_state) {
|
||||
|
||||
@@ -120,7 +120,7 @@ const char *get_disconnect_reason_str(uint8_t reason) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() {
|
||||
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const {
|
||||
// Use cyw43_wifi_link_status instead of cyw43_tcpip_link_status because the Arduino
|
||||
// framework's __wrap_cyw43_cb_tcpip_init is a no-op — the SDK's internal netif
|
||||
// (cyw43_state.netif[]) is never initialized. cyw43_tcpip_link_status checks that netif's
|
||||
|
||||
@@ -41,6 +41,3 @@ async def to_code(config):
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
cg.add_define("USE_ZWAVE_PROXY")
|
||||
|
||||
# Request UART to wake the main loop when data arrives for low-latency processing
|
||||
uart.request_wake_loop_on_rx()
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
#define USE_HOMEASSISTANT_TIME
|
||||
#define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT
|
||||
#define USE_IMAGE
|
||||
#define USE_IMPROV_SERIAL
|
||||
#define USE_IMPROV_SERIAL_NEXT_URL
|
||||
#define USE_INFRARED
|
||||
#define USE_IR_RF
|
||||
|
||||
@@ -133,6 +133,78 @@ template<typename T> class ConstVector {
|
||||
size_t size_;
|
||||
};
|
||||
|
||||
/// Small buffer optimization - stores data inline when small, heap-allocates for large data
|
||||
/// This avoids heap fragmentation for common small allocations while supporting arbitrary sizes.
|
||||
/// Memory management is encapsulated - callers just use set() and data().
|
||||
template<size_t InlineSize = 8> class SmallInlineBuffer {
|
||||
public:
|
||||
SmallInlineBuffer() = default;
|
||||
~SmallInlineBuffer() {
|
||||
if (!this->is_inline_())
|
||||
delete[] this->heap_;
|
||||
}
|
||||
|
||||
// Move constructor
|
||||
SmallInlineBuffer(SmallInlineBuffer &&other) noexcept : len_(other.len_) {
|
||||
if (other.is_inline_()) {
|
||||
memcpy(this->inline_, other.inline_, this->len_);
|
||||
} else {
|
||||
this->heap_ = other.heap_;
|
||||
other.heap_ = nullptr;
|
||||
}
|
||||
other.len_ = 0;
|
||||
}
|
||||
|
||||
// Move assignment
|
||||
SmallInlineBuffer &operator=(SmallInlineBuffer &&other) noexcept {
|
||||
if (this != &other) {
|
||||
if (!this->is_inline_())
|
||||
delete[] this->heap_;
|
||||
this->len_ = other.len_;
|
||||
if (other.is_inline_()) {
|
||||
memcpy(this->inline_, other.inline_, this->len_);
|
||||
} else {
|
||||
this->heap_ = other.heap_;
|
||||
other.heap_ = nullptr;
|
||||
}
|
||||
other.len_ = 0;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
// Disable copy (would need deep copy of heap data)
|
||||
SmallInlineBuffer(const SmallInlineBuffer &) = delete;
|
||||
SmallInlineBuffer &operator=(const SmallInlineBuffer &) = delete;
|
||||
|
||||
/// Set buffer contents, allocating heap if needed
|
||||
void set(const uint8_t *src, size_t size) {
|
||||
// Free existing heap allocation if switching from heap to inline or different heap size
|
||||
if (!this->is_inline_() && (size <= InlineSize || size != this->len_)) {
|
||||
delete[] this->heap_;
|
||||
this->heap_ = nullptr; // Defensive: prevent use-after-free if logic changes
|
||||
}
|
||||
// Allocate new heap buffer if needed
|
||||
if (size > InlineSize && (this->is_inline_() || size != this->len_)) {
|
||||
this->heap_ = new uint8_t[size]; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
}
|
||||
this->len_ = size;
|
||||
memcpy(this->data(), src, size);
|
||||
}
|
||||
|
||||
uint8_t *data() { return this->is_inline_() ? this->inline_ : this->heap_; }
|
||||
const uint8_t *data() const { return this->is_inline_() ? this->inline_ : this->heap_; }
|
||||
size_t size() const { return this->len_; }
|
||||
|
||||
protected:
|
||||
bool is_inline_() const { return this->len_ <= InlineSize; }
|
||||
|
||||
size_t len_{0};
|
||||
union {
|
||||
uint8_t inline_[InlineSize]{}; // Zero-init ensures clean initial state
|
||||
uint8_t *heap_;
|
||||
};
|
||||
};
|
||||
|
||||
/// Minimal static vector - saves memory by avoiding std::vector overhead
|
||||
template<typename T, size_t N> class StaticVector {
|
||||
public:
|
||||
|
||||
@@ -81,6 +81,20 @@ class StringRef {
|
||||
|
||||
operator std::string() const { return str(); }
|
||||
|
||||
/// Compare (compatible with std::string::compare)
|
||||
int compare(const StringRef &other) const {
|
||||
int result = std::memcmp(base_, other.base_, std::min(len_, other.len_));
|
||||
if (result != 0)
|
||||
return result;
|
||||
if (len_ < other.len_)
|
||||
return -1;
|
||||
if (len_ > other.len_)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
int compare(const char *s) const { return compare(StringRef(s)); }
|
||||
int compare(const std::string &s) const { return compare(StringRef(s)); }
|
||||
|
||||
/// Find first occurrence of substring, returns std::string::npos if not found.
|
||||
/// Note: Requires the underlying string to be null-terminated.
|
||||
size_type find(const char *s, size_type pos = 0) const {
|
||||
|
||||
+14
-2
@@ -24,11 +24,14 @@ class RegistryEntry:
|
||||
fun: Callable[..., Any],
|
||||
type_id: "MockObjClass",
|
||||
schema: "Schema",
|
||||
*,
|
||||
synchronous: bool = False,
|
||||
):
|
||||
self.name = name
|
||||
self.fun = fun
|
||||
self.type_id = type_id
|
||||
self.raw_schema = schema
|
||||
self.synchronous = synchronous
|
||||
|
||||
@property
|
||||
def coroutine_fun(self):
|
||||
@@ -49,9 +52,18 @@ class Registry(dict[str, RegistryEntry]):
|
||||
self.base_schema = base_schema or {}
|
||||
self.type_id_key = type_id_key
|
||||
|
||||
def register(self, name: str, type_id: "MockObjClass", schema: "Schema"):
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
type_id: "MockObjClass",
|
||||
schema: "Schema",
|
||||
*,
|
||||
synchronous: bool = False,
|
||||
):
|
||||
def decorator(fun: Callable[..., Any]):
|
||||
self[name] = RegistryEntry(name, fun, type_id, schema)
|
||||
self[name] = RegistryEntry(
|
||||
name, fun, type_id, schema, synchronous=synchronous
|
||||
)
|
||||
return fun
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -2473,9 +2473,6 @@ def build_base_class(
|
||||
out = f"class {base_class_name} : public {parent_class} {{\n"
|
||||
out += " public:\n"
|
||||
|
||||
# Add destructor with override
|
||||
public_content.insert(0, f"~{base_class_name}() override = default;")
|
||||
|
||||
# Base classes don't implement encode/decode/calculate_size
|
||||
# Derived classes handle these with their specific field numbers
|
||||
cpp = ""
|
||||
@@ -2483,6 +2480,8 @@ def build_base_class(
|
||||
out += indent("\n".join(public_content)) + "\n"
|
||||
out += "\n"
|
||||
out += " protected:\n"
|
||||
# Non-virtual protected destructor prevents accidental polymorphic deletion
|
||||
protected_content.insert(0, f"~{base_class_name}() = default;")
|
||||
out += indent("\n".join(protected_content))
|
||||
if protected_content:
|
||||
out += "\n"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
from esphome.components.uart import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BAUD_RATE, CONF_DATA, CONF_DELAY, CONF_ID, CONF_INTERVAL
|
||||
|
||||
CODEOWNERS = ["@esphome/tests"]
|
||||
MULTI_CONF = True
|
||||
|
||||
uart_mock_ns = cg.esphome_ns.namespace("uart_mock")
|
||||
MockUartComponent = uart_mock_ns.class_(
|
||||
"MockUartComponent", uart.UARTComponent, cg.Component
|
||||
)
|
||||
|
||||
CONF_INJECTIONS = "injections"
|
||||
CONF_RESPONSES = "responses"
|
||||
CONF_INJECT_RX = "inject_rx"
|
||||
CONF_EXPECT_TX = "expect_tx"
|
||||
CONF_PERIODIC_RX = "periodic_rx"
|
||||
|
||||
UART_PARITY_OPTIONS = {
|
||||
"NONE": uart.UARTParityOptions.UART_CONFIG_PARITY_NONE,
|
||||
"EVEN": uart.UARTParityOptions.UART_CONFIG_PARITY_EVEN,
|
||||
"ODD": uart.UARTParityOptions.UART_CONFIG_PARITY_ODD,
|
||||
}
|
||||
|
||||
INJECTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_INJECT_RX): [cv.hex_uint8_t],
|
||||
cv.Optional(CONF_DELAY, default="0ms"): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
)
|
||||
|
||||
RESPONSE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_EXPECT_TX): [cv.hex_uint8_t],
|
||||
cv.Required(CONF_INJECT_RX): [cv.hex_uint8_t],
|
||||
}
|
||||
)
|
||||
|
||||
PERIODIC_RX_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_DATA): [cv.hex_uint8_t],
|
||||
cv.Required(CONF_INTERVAL): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MockUartComponent),
|
||||
cv.Required(CONF_BAUD_RATE): cv.int_range(min=1),
|
||||
cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True),
|
||||
cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8),
|
||||
cv.Optional(CONF_PARITY, default="NONE"): cv.enum(
|
||||
UART_PARITY_OPTIONS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_INJECTIONS, default=[]): cv.ensure_list(INJECTION_SCHEMA),
|
||||
cv.Optional(CONF_RESPONSES, default=[]): cv.ensure_list(RESPONSE_SCHEMA),
|
||||
cv.Optional(CONF_PERIODIC_RX, default=[]): cv.ensure_list(PERIODIC_RX_SCHEMA),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_baud_rate(config[CONF_BAUD_RATE]))
|
||||
cg.add(var.set_stop_bits(config[CONF_STOP_BITS]))
|
||||
cg.add(var.set_data_bits(config[CONF_DATA_BITS]))
|
||||
cg.add(var.set_parity(config[CONF_PARITY]))
|
||||
|
||||
for injection in config[CONF_INJECTIONS]:
|
||||
rx_data = injection[CONF_INJECT_RX]
|
||||
delay_ms = injection[CONF_DELAY]
|
||||
cg.add(var.add_injection(rx_data, delay_ms))
|
||||
|
||||
for response in config[CONF_RESPONSES]:
|
||||
tx_data = response[CONF_EXPECT_TX]
|
||||
rx_data = response[CONF_INJECT_RX]
|
||||
cg.add(var.add_response(tx_data, rx_data))
|
||||
|
||||
for periodic in config[CONF_PERIODIC_RX]:
|
||||
data = periodic[CONF_DATA]
|
||||
interval = periodic[CONF_INTERVAL]
|
||||
cg.add(var.add_periodic_rx(data, interval))
|
||||
@@ -0,0 +1,159 @@
|
||||
// Host-only test component — do not copy to production code.
|
||||
// See uart_mock.h for details.
|
||||
|
||||
#include "uart_mock.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::uart_mock {
|
||||
|
||||
static const char *const TAG = "uart_mock";
|
||||
|
||||
void MockUartComponent::setup() {
|
||||
ESP_LOGI(TAG, "Mock UART initialized with %zu injections, %zu responses, %zu periodic", this->injections_.size(),
|
||||
this->responses_.size(), this->periodic_rx_.size());
|
||||
}
|
||||
|
||||
void MockUartComponent::loop() {
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
|
||||
// Initialize scenario start time on first loop() call, after all components have
|
||||
// finished setup(). This prevents injection delays from being consumed during setup.
|
||||
if (!this->loop_started_) {
|
||||
this->loop_started_ = true;
|
||||
this->scenario_start_ms_ = now;
|
||||
this->cumulative_delay_ms_ = 0;
|
||||
ESP_LOGD(TAG, "Scenario started at %u ms", now);
|
||||
}
|
||||
|
||||
// Process at most ONE timed injection per loop iteration.
|
||||
// This ensures each injection is in a separate loop cycle, giving the consuming
|
||||
// component (e.g., LD2410) a chance to process each batch independently.
|
||||
if (this->injection_index_ < this->injections_.size()) {
|
||||
auto &injection = this->injections_[this->injection_index_];
|
||||
uint32_t target_time = this->scenario_start_ms_ + this->cumulative_delay_ms_ + injection.delay_ms;
|
||||
if (now >= target_time) {
|
||||
ESP_LOGD(TAG, "Injecting %zu RX bytes (injection %u)", injection.rx_data.size(), this->injection_index_);
|
||||
this->inject_to_rx_buffer_(injection.rx_data);
|
||||
this->cumulative_delay_ms_ += injection.delay_ms;
|
||||
this->injection_index_++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process periodic RX
|
||||
for (auto &periodic : this->periodic_rx_) {
|
||||
if (now - periodic.last_inject_ms >= periodic.interval_ms) {
|
||||
this->inject_to_rx_buffer_(periodic.data);
|
||||
periodic.last_inject_ms = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MockUartComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Mock UART Component:\n"
|
||||
" Baud Rate: %u\n"
|
||||
" Injections: %zu\n"
|
||||
" Responses: %zu\n"
|
||||
" Periodic RX: %zu",
|
||||
this->baud_rate_, this->injections_.size(), this->responses_.size(), this->periodic_rx_.size());
|
||||
}
|
||||
|
||||
void MockUartComponent::write_array(const uint8_t *data, size_t len) {
|
||||
this->tx_count_ += len;
|
||||
this->tx_buffer_.insert(this->tx_buffer_.end(), data, data + len);
|
||||
|
||||
// Log all TX data so tests can verify what the component sends
|
||||
if (len > 0 && len <= 64) {
|
||||
char hex_buf[format_hex_pretty_size(64)];
|
||||
ESP_LOGD(TAG, "TX %zu bytes: %s", len, format_hex_pretty_to(hex_buf, sizeof(hex_buf), data, len));
|
||||
} else if (len > 64) {
|
||||
ESP_LOGD(TAG, "TX %zu bytes (too large to log)", len);
|
||||
}
|
||||
|
||||
#ifdef USE_UART_DEBUGGER
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
this->debug_callback_.call(uart::UART_DIRECTION_TX, data[i]);
|
||||
}
|
||||
#endif
|
||||
|
||||
this->try_match_response_();
|
||||
}
|
||||
|
||||
bool MockUartComponent::peek_byte(uint8_t *data) {
|
||||
if (this->rx_buffer_.empty()) {
|
||||
return false;
|
||||
}
|
||||
*data = this->rx_buffer_.front();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MockUartComponent::read_array(uint8_t *data, size_t len) {
|
||||
if (this->rx_buffer_.size() < len) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
data[i] = this->rx_buffer_.front();
|
||||
this->rx_buffer_.pop_front();
|
||||
}
|
||||
this->rx_count_ += len;
|
||||
|
||||
#ifdef USE_UART_DEBUGGER
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
this->debug_callback_.call(uart::UART_DIRECTION_RX, data[i]);
|
||||
}
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t MockUartComponent::available() { return this->rx_buffer_.size(); }
|
||||
|
||||
void MockUartComponent::flush() {
|
||||
// Nothing to flush in mock
|
||||
}
|
||||
|
||||
void MockUartComponent::add_injection(const std::vector<uint8_t> &rx_data, uint32_t delay_ms) {
|
||||
this->injections_.push_back({rx_data, delay_ms});
|
||||
}
|
||||
|
||||
void MockUartComponent::add_response(const std::vector<uint8_t> &expect_tx, const std::vector<uint8_t> &inject_rx) {
|
||||
this->responses_.push_back({expect_tx, inject_rx});
|
||||
}
|
||||
|
||||
void MockUartComponent::add_periodic_rx(const std::vector<uint8_t> &data, uint32_t interval_ms) {
|
||||
this->periodic_rx_.push_back({data, interval_ms, 0});
|
||||
}
|
||||
|
||||
void MockUartComponent::try_match_response_() {
|
||||
for (auto &response : this->responses_) {
|
||||
if (this->tx_buffer_.size() < response.expect_tx.size()) {
|
||||
continue;
|
||||
}
|
||||
// Check if tx_buffer_ ends with expect_tx
|
||||
size_t offset = this->tx_buffer_.size() - response.expect_tx.size();
|
||||
if (std::equal(response.expect_tx.begin(), response.expect_tx.end(), this->tx_buffer_.begin() + offset)) {
|
||||
ESP_LOGD(TAG, "TX match found, injecting %zu RX bytes", response.inject_rx.size());
|
||||
this->inject_to_rx_buffer_(response.inject_rx);
|
||||
this->tx_buffer_.clear();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MockUartComponent::inject_to_rx_buffer_(const std::vector<uint8_t> &data) {
|
||||
// Log injected RX data so tests can see what's being fed to the component
|
||||
if (!data.empty() && data.size() <= 64) {
|
||||
char hex_buf[format_hex_pretty_size(64)];
|
||||
ESP_LOGD(TAG, "RX inject %zu bytes: %s", data.size(),
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
} else if (data.size() > 64) {
|
||||
ESP_LOGD(TAG, "RX inject %zu bytes (too large to log inline)", data.size());
|
||||
}
|
||||
for (uint8_t byte : data) {
|
||||
this->rx_buffer_.push_back(byte);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::uart_mock
|
||||
@@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
|
||||
// ============================================================================
|
||||
// HOST-ONLY TEST COMPONENT — DO NOT COPY TO PRODUCTION CODE
|
||||
//
|
||||
// This component runs exclusively on the host platform for integration testing.
|
||||
// It intentionally uses std::vector, std::deque, and dynamic allocation which
|
||||
// would be inappropriate for production embedded components. Do not use this
|
||||
// code as a reference for writing ESPHome components targeting real hardware.
|
||||
// ============================================================================
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::uart_mock {
|
||||
|
||||
class MockUartComponent : public uart::UARTComponent, public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::BUS; }
|
||||
|
||||
// UARTComponent interface
|
||||
void write_array(const uint8_t *data, size_t len) override;
|
||||
bool peek_byte(uint8_t *data) override;
|
||||
bool read_array(uint8_t *data, size_t len) override;
|
||||
size_t available() override;
|
||||
void flush() override;
|
||||
|
||||
// Scenario configuration - called from generated code
|
||||
void add_injection(const std::vector<uint8_t> &rx_data, uint32_t delay_ms);
|
||||
void add_response(const std::vector<uint8_t> &expect_tx, const std::vector<uint8_t> &inject_rx);
|
||||
void add_periodic_rx(const std::vector<uint8_t> &data, uint32_t interval_ms);
|
||||
|
||||
protected:
|
||||
void check_logger_conflict() override {}
|
||||
void try_match_response_();
|
||||
void inject_to_rx_buffer_(const std::vector<uint8_t> &data);
|
||||
|
||||
// Timed injections
|
||||
struct Injection {
|
||||
std::vector<uint8_t> rx_data;
|
||||
uint32_t delay_ms;
|
||||
};
|
||||
std::vector<Injection> injections_;
|
||||
uint32_t injection_index_{0};
|
||||
uint32_t scenario_start_ms_{0};
|
||||
uint32_t cumulative_delay_ms_{0};
|
||||
bool loop_started_{false};
|
||||
|
||||
// TX-triggered responses
|
||||
struct Response {
|
||||
std::vector<uint8_t> expect_tx;
|
||||
std::vector<uint8_t> inject_rx;
|
||||
};
|
||||
std::vector<Response> responses_;
|
||||
std::vector<uint8_t> tx_buffer_;
|
||||
|
||||
// RX buffer
|
||||
std::deque<uint8_t> rx_buffer_;
|
||||
|
||||
// Periodic RX
|
||||
struct PeriodicRx {
|
||||
std::vector<uint8_t> data;
|
||||
uint32_t interval_ms;
|
||||
uint32_t last_inject_ms{0};
|
||||
};
|
||||
std::vector<PeriodicRx> periodic_rx_;
|
||||
|
||||
// Observability
|
||||
uint32_t tx_count_{0};
|
||||
uint32_t rx_count_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::uart_mock
|
||||
@@ -0,0 +1,145 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2410-test
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy ld2410's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 256000
|
||||
injections:
|
||||
# Phase 1 (t=100ms): Valid LD2410 normal mode data frame - happy path
|
||||
# The buffer is clean at this point, so this frame should parse correctly.
|
||||
# Moving target: 100cm, energy 50
|
||||
# Still target: 120cm, energy 25
|
||||
# Detection distance: 300cm
|
||||
# Target state: 0x03 (moving + still)
|
||||
#
|
||||
# Note: LD2410's two_byte_to_int() uses signed char, so low bytes must be
|
||||
# <=127 to produce correct values. Values >127 wrap negative.
|
||||
#
|
||||
# Frame layout (24 bytes):
|
||||
# [0-3] F4 F3 F2 F1 = data frame header
|
||||
# [4-5] 0D 00 = length 13
|
||||
# [6] 02 = data type (normal)
|
||||
# [7] AA = data header marker
|
||||
# [8] 03 = target states (moving+still)
|
||||
# [9-10] 64 00 = moving distance 100 (0x0064)
|
||||
# [11] 32 = moving energy 50
|
||||
# [12-13] 78 00 = still distance 120 (0x0078)
|
||||
# [14] 19 = still energy 25
|
||||
# [15-16] 2C 01 = detection distance 300 (0x012C)
|
||||
# [17] 00 = padding
|
||||
# [18] 55 = data footer marker
|
||||
# [19] 00 = CRC/check
|
||||
# [20-23] F8 F7 F6 F5 = data frame footer
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x0D, 0x00,
|
||||
0x02, 0xAA,
|
||||
0x03,
|
||||
0x64, 0x00,
|
||||
0x32,
|
||||
0x78, 0x00,
|
||||
0x19,
|
||||
0x2C, 0x01,
|
||||
0x00,
|
||||
0x55, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
# Phase 2 (t=200ms): Garbage bytes - corrupt the buffer
|
||||
# These random bytes will accumulate in the LD2410's internal buffer.
|
||||
# No footer will be found, so the buffer just grows.
|
||||
- delay: 100ms
|
||||
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
|
||||
|
||||
# Phase 3 (t=300ms): Truncated frame (header + partial data, no footer)
|
||||
# More bytes accumulating in the buffer without a footer match.
|
||||
- delay: 100ms
|
||||
inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x0D, 0x00, 0x02, 0xAA]
|
||||
|
||||
# Phase 4 (t=500ms): Overflow - inject 85 bytes of 0xFF (MAX_LINE_LENGTH=50)
|
||||
# Buffer has 15 bytes from phases 2+3.
|
||||
# Overflow math: need 35 bytes to trigger first overflow (pos 15->49),
|
||||
# then 50 more to trigger second overflow (pos 0->49). Total = 85 bytes.
|
||||
# After two overflows, buffer_pos_ = 0 with a completely clean buffer.
|
||||
# The LD2410 logs "Max command length exceeded" at each overflow.
|
||||
- delay: 200ms
|
||||
inject_rx:
|
||||
[
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
]
|
||||
|
||||
# Phase 5 (t=600ms): Valid frame after overflow - recovery test
|
||||
# Buffer was reset by overflow. This valid frame should parse correctly.
|
||||
# Moving target: 50cm, energy 100
|
||||
# Still target: 75cm, energy 80
|
||||
# Detection distance: 127cm
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x0D, 0x00,
|
||||
0x02, 0xAA,
|
||||
0x03,
|
||||
0x32, 0x00,
|
||||
0x64,
|
||||
0x4B, 0x00,
|
||||
0x50,
|
||||
0x7F, 0x00,
|
||||
0x00,
|
||||
0x55, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
ld2410:
|
||||
id: ld2410_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
sensor:
|
||||
- platform: ld2410
|
||||
ld2410_id: ld2410_dev
|
||||
moving_distance:
|
||||
name: "Moving Distance"
|
||||
still_distance:
|
||||
name: "Still Distance"
|
||||
moving_energy:
|
||||
name: "Moving Energy"
|
||||
still_energy:
|
||||
name: "Still Energy"
|
||||
detection_distance:
|
||||
name: "Detection Distance"
|
||||
|
||||
binary_sensor:
|
||||
- platform: ld2410
|
||||
ld2410_id: ld2410_dev
|
||||
has_target:
|
||||
name: "Has Target"
|
||||
has_moving_target:
|
||||
name: "Has Moving Target"
|
||||
has_still_target:
|
||||
name: "Has Still Target"
|
||||
@@ -0,0 +1,156 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2410-eng-test
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy ld2410's DEPENDENCIES = ["uart"]
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 256000
|
||||
injections:
|
||||
# Phase 1 (t=100ms): Valid LD2410 engineering mode data frame
|
||||
# Captured from a real Screek Human Presence Sensor 1U with LD2410 firmware 2.4.x
|
||||
#
|
||||
# Engineering mode frame layout (45 bytes):
|
||||
# [0-3] F4 F3 F2 F1 = data frame header
|
||||
# [4-5] 23 00 = length 35
|
||||
# [6] 01 = data type (engineering mode)
|
||||
# [7] AA = data header marker
|
||||
# [8] 03 = target states (moving+still)
|
||||
# [9-10] 1E 00 = moving distance 30 (0x001E)
|
||||
# [11] 64 = moving energy 100
|
||||
# [12-13] 1E 00 = still distance 30 (0x001E)
|
||||
# [14] 64 = still energy 100
|
||||
# [15-16] 00 00 = detection distance 0
|
||||
# [17] 08 = max moving distance gate
|
||||
# [18] 08 = max still distance gate
|
||||
# [19-27] gate moving energies (gates 0-8)
|
||||
# [28-36] gate still energies (gates 0-8)
|
||||
# [37] 57 = light sensor value 87
|
||||
# [38] 01 = out pin presence (HIGH)
|
||||
# [39] 55 = data footer marker
|
||||
# [40] 00 = check
|
||||
# [41-44] F8 F7 F6 F5 = data frame footer
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01, 0xAA,
|
||||
0x03,
|
||||
0x1E, 0x00,
|
||||
0x64,
|
||||
0x1E, 0x00,
|
||||
0x64,
|
||||
0x00, 0x00,
|
||||
0x08, 0x08,
|
||||
0x64, 0x41, 0x06, 0x0E, 0x2B, 0x16, 0x03, 0x03, 0x07,
|
||||
0x00, 0x00, 0x64, 0x64, 0x64, 0x64, 0x64, 0x64, 0x64,
|
||||
0x57, 0x01,
|
||||
0x55, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
# Phase 2 (t=200ms): Second engineering mode frame with different values
|
||||
# Real capture: moving at 73cm, still at 30cm, detection at 33cm
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01, 0xAA,
|
||||
0x03,
|
||||
0x49, 0x00,
|
||||
0x64,
|
||||
0x1E, 0x00,
|
||||
0x64,
|
||||
0x21, 0x00,
|
||||
0x08, 0x08,
|
||||
0x11, 0x64, 0x05, 0x29, 0x39, 0x10, 0x03, 0x11, 0x0E,
|
||||
0x00, 0x00, 0x64, 0x64, 0x64, 0x64, 0x64, 0x64, 0x64,
|
||||
0x57, 0x01,
|
||||
0x55, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
# Phase 3 (t=300ms): Frame with still target at 291cm (multi-byte distance)
|
||||
# This tests the two_byte_to_int function with high byte > 0
|
||||
# Note: low byte 0x2F < 0x80 so it avoids the signed char bug
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01, 0xAA,
|
||||
0x03,
|
||||
0x2F, 0x00,
|
||||
0x36,
|
||||
0x23, 0x01,
|
||||
0x64,
|
||||
0x21, 0x00,
|
||||
0x08, 0x08,
|
||||
0x2F, 0x36, 0x09, 0x0D, 0x15, 0x0B, 0x06, 0x06, 0x08,
|
||||
0x00, 0x00, 0x64, 0x64, 0x64, 0x64, 0x64, 0x5A, 0x3D,
|
||||
0x57, 0x01,
|
||||
0x55, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
ld2410:
|
||||
id: ld2410_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
sensor:
|
||||
- platform: ld2410
|
||||
ld2410_id: ld2410_dev
|
||||
moving_distance:
|
||||
name: "Moving Distance"
|
||||
still_distance:
|
||||
name: "Still Distance"
|
||||
moving_energy:
|
||||
name: "Moving Energy"
|
||||
still_energy:
|
||||
name: "Still Energy"
|
||||
detection_distance:
|
||||
name: "Detection Distance"
|
||||
light:
|
||||
name: "Light"
|
||||
g0:
|
||||
move_energy:
|
||||
name: "Gate 0 Move Energy"
|
||||
still_energy:
|
||||
name: "Gate 0 Still Energy"
|
||||
g1:
|
||||
move_energy:
|
||||
name: "Gate 1 Move Energy"
|
||||
still_energy:
|
||||
name: "Gate 1 Still Energy"
|
||||
g2:
|
||||
move_energy:
|
||||
name: "Gate 2 Move Energy"
|
||||
still_energy:
|
||||
name: "Gate 2 Still Energy"
|
||||
|
||||
binary_sensor:
|
||||
- platform: ld2410
|
||||
ld2410_id: ld2410_dev
|
||||
has_target:
|
||||
name: "Has Target"
|
||||
has_moving_target:
|
||||
name: "Has Moving Target"
|
||||
has_still_target:
|
||||
name: "Has Still Target"
|
||||
out_pin_presence_status:
|
||||
name: "Out Pin Presence"
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Integration test for LD2410 component with mock UART.
|
||||
|
||||
Tests:
|
||||
test_uart_mock_ld2410 (normal mode):
|
||||
1. Happy path - valid data frame publishes correct sensor values
|
||||
2. Garbage resilience - random bytes don't crash the component
|
||||
3. Truncated frame handling - partial frame doesn't corrupt state
|
||||
4. Buffer overflow recovery - overflow resets the parser
|
||||
5. Post-overflow parsing - next valid frame after overflow is parsed correctly
|
||||
6. TX logging - verifies LD2410 sends expected setup commands
|
||||
|
||||
test_uart_mock_ld2410_engineering (engineering mode):
|
||||
1. Engineering mode frames with per-gate energy data and light sensor
|
||||
2. Multi-byte still distance (291cm) using high byte > 0
|
||||
3. Out pin presence binary sensor
|
||||
4. Gate energy sensor values from real device captures
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from aioesphomeapi import (
|
||||
BinarySensorInfo,
|
||||
BinarySensorState,
|
||||
EntityState,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2410(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test LD2410 data parsing with happy path, garbage, overflow, and recovery."""
|
||||
# Replace external component path placeholder
|
||||
external_components_path = str(
|
||||
Path(__file__).parent / "fixtures" / "external_components"
|
||||
)
|
||||
yaml_config = yaml_config.replace(
|
||||
"EXTERNAL_COMPONENT_PATH", external_components_path
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Track overflow warning in logs
|
||||
overflow_seen = loop.create_future()
|
||||
|
||||
# Track TX data logged by the mock for assertions
|
||||
tx_log_lines: list[str] = []
|
||||
|
||||
def line_callback(line: str) -> None:
|
||||
if "Max command length exceeded" in line and not overflow_seen.done():
|
||||
overflow_seen.set_result(True)
|
||||
# Capture all TX log lines from uart_mock
|
||||
if "uart_mock" in line and "TX " in line:
|
||||
tx_log_lines.append(line)
|
||||
|
||||
# Track sensor state updates (after initial state is swallowed)
|
||||
sensor_states: dict[str, list[float]] = {
|
||||
"moving_distance": [],
|
||||
"still_distance": [],
|
||||
"moving_energy": [],
|
||||
"still_energy": [],
|
||||
"detection_distance": [],
|
||||
}
|
||||
binary_states: dict[str, list[bool]] = {
|
||||
"has_target": [],
|
||||
"has_moving_target": [],
|
||||
"has_still_target": [],
|
||||
}
|
||||
|
||||
# Signal when we see recovery frame values
|
||||
recovery_received = loop.create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, SensorState) and not state.missing_state:
|
||||
sensor_name = key_to_sensor.get(state.key)
|
||||
if sensor_name and sensor_name in sensor_states:
|
||||
sensor_states[sensor_name].append(state.state)
|
||||
# Check if this is the recovery frame (moving_distance = 50)
|
||||
if (
|
||||
sensor_name == "moving_distance"
|
||||
and state.state == pytest.approx(50.0)
|
||||
and not recovery_received.done()
|
||||
):
|
||||
recovery_received.set_result(True)
|
||||
elif isinstance(state, BinarySensorState):
|
||||
sensor_name = key_to_sensor.get(state.key)
|
||||
if sensor_name and sensor_name in binary_states:
|
||||
binary_states[sensor_name].append(state.state)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
|
||||
# Build key mappings for all sensor types
|
||||
all_names = list(sensor_states.keys()) + list(binary_states.keys())
|
||||
key_to_sensor = build_key_to_entity_mapping(entities, all_names)
|
||||
|
||||
# Set up initial state helper
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
# Phase 1 values are in the initial states (swallowed by InitialStateHelper).
|
||||
# Verify them via initial_states dict.
|
||||
moving_dist_entity = find_entity(entities, "moving_distance", SensorInfo)
|
||||
assert moving_dist_entity is not None
|
||||
initial_moving = initial_state_helper.initial_states.get(moving_dist_entity.key)
|
||||
assert initial_moving is not None and isinstance(initial_moving, SensorState)
|
||||
assert initial_moving.state == pytest.approx(100.0), (
|
||||
f"Initial moving distance should be 100, got {initial_moving.state}"
|
||||
)
|
||||
|
||||
still_dist_entity = find_entity(entities, "still_distance", SensorInfo)
|
||||
assert still_dist_entity is not None
|
||||
initial_still = initial_state_helper.initial_states.get(still_dist_entity.key)
|
||||
assert initial_still is not None and isinstance(initial_still, SensorState)
|
||||
assert initial_still.state == pytest.approx(120.0), (
|
||||
f"Initial still distance should be 120, got {initial_still.state}"
|
||||
)
|
||||
|
||||
moving_energy_entity = find_entity(entities, "moving_energy", SensorInfo)
|
||||
assert moving_energy_entity is not None
|
||||
initial_me = initial_state_helper.initial_states.get(moving_energy_entity.key)
|
||||
assert initial_me is not None and isinstance(initial_me, SensorState)
|
||||
assert initial_me.state == pytest.approx(50.0), (
|
||||
f"Initial moving energy should be 50, got {initial_me.state}"
|
||||
)
|
||||
|
||||
still_energy_entity = find_entity(entities, "still_energy", SensorInfo)
|
||||
assert still_energy_entity is not None
|
||||
initial_se = initial_state_helper.initial_states.get(still_energy_entity.key)
|
||||
assert initial_se is not None and isinstance(initial_se, SensorState)
|
||||
assert initial_se.state == pytest.approx(25.0), (
|
||||
f"Initial still energy should be 25, got {initial_se.state}"
|
||||
)
|
||||
|
||||
detect_dist_entity = find_entity(entities, "detection_distance", SensorInfo)
|
||||
assert detect_dist_entity is not None
|
||||
initial_dd = initial_state_helper.initial_states.get(detect_dist_entity.key)
|
||||
assert initial_dd is not None and isinstance(initial_dd, SensorState)
|
||||
assert initial_dd.state == pytest.approx(300.0), (
|
||||
f"Initial detection distance should be 300, got {initial_dd.state}"
|
||||
)
|
||||
|
||||
# Wait for the recovery frame (Phase 5) to be parsed
|
||||
# This proves the component survived garbage + truncated + overflow
|
||||
try:
|
||||
await asyncio.wait_for(recovery_received, timeout=15.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Timeout waiting for recovery frame. Received sensor states:\n"
|
||||
f" moving_distance: {sensor_states['moving_distance']}\n"
|
||||
f" still_distance: {sensor_states['still_distance']}\n"
|
||||
f" moving_energy: {sensor_states['moving_energy']}\n"
|
||||
f" still_energy: {sensor_states['still_energy']}\n"
|
||||
f" detection_distance: {sensor_states['detection_distance']}"
|
||||
)
|
||||
|
||||
# Verify overflow warning was logged
|
||||
assert overflow_seen.done(), (
|
||||
"Expected 'Max command length exceeded' warning in logs"
|
||||
)
|
||||
|
||||
# Verify LD2410 sent setup commands (TX logging)
|
||||
# LD2410 sends 7 commands during setup: FF (config on), A0 (version),
|
||||
# A5 (MAC), AB (distance res), AE (light), 61 (params), FE (config off)
|
||||
assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock"
|
||||
tx_data = " ".join(tx_log_lines)
|
||||
# Verify command frame header appears (FD:FC:FB:FA)
|
||||
assert "FD:FC:FB:FA" in tx_data, (
|
||||
"Expected LD2410 command frame header FD:FC:FB:FA in TX log"
|
||||
)
|
||||
# Verify command frame footer appears (04:03:02:01)
|
||||
assert "04:03:02:01" in tx_data, (
|
||||
"Expected LD2410 command frame footer 04:03:02:01 in TX log"
|
||||
)
|
||||
|
||||
# Recovery frame values (Phase 5, after overflow)
|
||||
assert len(sensor_states["moving_distance"]) >= 1, (
|
||||
f"Expected recovery moving_distance, got: {sensor_states['moving_distance']}"
|
||||
)
|
||||
# Find the recovery value (moving_distance = 50)
|
||||
recovery_values = [
|
||||
v for v in sensor_states["moving_distance"] if v == pytest.approx(50.0)
|
||||
]
|
||||
assert len(recovery_values) >= 1, (
|
||||
f"Expected moving_distance=50 in recovery, got: {sensor_states['moving_distance']}"
|
||||
)
|
||||
|
||||
# Recovery frame: moving=50, still=75, energy=100/80, detect=127
|
||||
recovery_idx = next(
|
||||
i
|
||||
for i, v in enumerate(sensor_states["moving_distance"])
|
||||
if v == pytest.approx(50.0)
|
||||
)
|
||||
assert sensor_states["still_distance"][recovery_idx] == pytest.approx(75.0), (
|
||||
f"Recovery still distance should be 75, got {sensor_states['still_distance'][recovery_idx]}"
|
||||
)
|
||||
assert sensor_states["moving_energy"][recovery_idx] == pytest.approx(100.0), (
|
||||
f"Recovery moving energy should be 100, got {sensor_states['moving_energy'][recovery_idx]}"
|
||||
)
|
||||
assert sensor_states["still_energy"][recovery_idx] == pytest.approx(80.0), (
|
||||
f"Recovery still energy should be 80, got {sensor_states['still_energy'][recovery_idx]}"
|
||||
)
|
||||
assert sensor_states["detection_distance"][recovery_idx] == pytest.approx(
|
||||
127.0
|
||||
), (
|
||||
f"Recovery detection distance should be 127, got {sensor_states['detection_distance'][recovery_idx]}"
|
||||
)
|
||||
|
||||
# Verify binary sensors detected targets
|
||||
# Binary sensors could be in initial states or forwarded states
|
||||
has_target_entity = find_entity(entities, "has_target", BinarySensorInfo)
|
||||
assert has_target_entity is not None
|
||||
initial_ht = initial_state_helper.initial_states.get(has_target_entity.key)
|
||||
assert initial_ht is not None and isinstance(initial_ht, BinarySensorState)
|
||||
assert initial_ht.state is True, "Has target should be True"
|
||||
|
||||
has_moving_entity = find_entity(entities, "has_moving_target", BinarySensorInfo)
|
||||
assert has_moving_entity is not None
|
||||
initial_hm = initial_state_helper.initial_states.get(has_moving_entity.key)
|
||||
assert initial_hm is not None and isinstance(initial_hm, BinarySensorState)
|
||||
assert initial_hm.state is True, "Has moving target should be True"
|
||||
|
||||
has_still_entity = find_entity(entities, "has_still_target", BinarySensorInfo)
|
||||
assert has_still_entity is not None
|
||||
initial_hs = initial_state_helper.initial_states.get(has_still_entity.key)
|
||||
assert initial_hs is not None and isinstance(initial_hs, BinarySensorState)
|
||||
assert initial_hs.state is True, "Has still target should be True"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2410_engineering(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test LD2410 engineering mode with per-gate energy, light, and multi-byte distance."""
|
||||
external_components_path = str(
|
||||
Path(__file__).parent / "fixtures" / "external_components"
|
||||
)
|
||||
yaml_config = yaml_config.replace(
|
||||
"EXTERNAL_COMPONENT_PATH", external_components_path
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Track sensor state updates (after initial state is swallowed)
|
||||
sensor_states: dict[str, list[float]] = {
|
||||
"moving_distance": [],
|
||||
"still_distance": [],
|
||||
"moving_energy": [],
|
||||
"still_energy": [],
|
||||
"detection_distance": [],
|
||||
"light": [],
|
||||
"gate_0_move_energy": [],
|
||||
"gate_1_move_energy": [],
|
||||
"gate_2_move_energy": [],
|
||||
"gate_0_still_energy": [],
|
||||
"gate_1_still_energy": [],
|
||||
"gate_2_still_energy": [],
|
||||
}
|
||||
binary_states: dict[str, list[bool]] = {
|
||||
"has_target": [],
|
||||
"has_moving_target": [],
|
||||
"has_still_target": [],
|
||||
"out_pin_presence": [],
|
||||
}
|
||||
|
||||
# Signal when we see Phase 3 frame (still_distance = 291)
|
||||
phase3_received = loop.create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, SensorState) and not state.missing_state:
|
||||
sensor_name = key_to_sensor.get(state.key)
|
||||
if sensor_name and sensor_name in sensor_states:
|
||||
sensor_states[sensor_name].append(state.state)
|
||||
if (
|
||||
sensor_name == "still_distance"
|
||||
and state.state == pytest.approx(291.0)
|
||||
and not phase3_received.done()
|
||||
):
|
||||
phase3_received.set_result(True)
|
||||
elif isinstance(state, BinarySensorState):
|
||||
sensor_name = key_to_sensor.get(state.key)
|
||||
if sensor_name and sensor_name in binary_states:
|
||||
binary_states[sensor_name].append(state.state)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
|
||||
all_names = list(sensor_states.keys()) + list(binary_states.keys())
|
||||
key_to_sensor = build_key_to_entity_mapping(entities, all_names)
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
# Phase 1 initial values (engineering mode frame):
|
||||
# moving=30, energy=100, still=30, energy=100, detect=0
|
||||
moving_dist_entity = find_entity(entities, "moving_distance", SensorInfo)
|
||||
assert moving_dist_entity is not None
|
||||
initial_moving = initial_state_helper.initial_states.get(moving_dist_entity.key)
|
||||
assert initial_moving is not None and isinstance(initial_moving, SensorState)
|
||||
assert initial_moving.state == pytest.approx(30.0), (
|
||||
f"Initial moving distance should be 30, got {initial_moving.state}"
|
||||
)
|
||||
|
||||
still_dist_entity = find_entity(entities, "still_distance", SensorInfo)
|
||||
assert still_dist_entity is not None
|
||||
initial_still = initial_state_helper.initial_states.get(still_dist_entity.key)
|
||||
assert initial_still is not None and isinstance(initial_still, SensorState)
|
||||
assert initial_still.state == pytest.approx(30.0), (
|
||||
f"Initial still distance should be 30, got {initial_still.state}"
|
||||
)
|
||||
|
||||
# Verify engineering mode sensors from initial state
|
||||
# Gate 0 moving energy = 0x64 = 100
|
||||
gate0_move_entity = find_entity(entities, "gate_0_move_energy", SensorInfo)
|
||||
assert gate0_move_entity is not None
|
||||
initial_g0m = initial_state_helper.initial_states.get(gate0_move_entity.key)
|
||||
assert initial_g0m is not None and isinstance(initial_g0m, SensorState)
|
||||
assert initial_g0m.state == pytest.approx(100.0), (
|
||||
f"Gate 0 move energy should be 100, got {initial_g0m.state}"
|
||||
)
|
||||
|
||||
# Gate 1 moving energy = 0x41 = 65
|
||||
gate1_move_entity = find_entity(entities, "gate_1_move_energy", SensorInfo)
|
||||
assert gate1_move_entity is not None
|
||||
initial_g1m = initial_state_helper.initial_states.get(gate1_move_entity.key)
|
||||
assert initial_g1m is not None and isinstance(initial_g1m, SensorState)
|
||||
assert initial_g1m.state == pytest.approx(65.0), (
|
||||
f"Gate 1 move energy should be 65, got {initial_g1m.state}"
|
||||
)
|
||||
|
||||
# Light sensor = 0x57 = 87
|
||||
light_entity = find_entity(entities, "light", SensorInfo)
|
||||
assert light_entity is not None
|
||||
initial_light = initial_state_helper.initial_states.get(light_entity.key)
|
||||
assert initial_light is not None and isinstance(initial_light, SensorState)
|
||||
assert initial_light.state == pytest.approx(87.0), (
|
||||
f"Light sensor should be 87, got {initial_light.state}"
|
||||
)
|
||||
|
||||
# Out pin presence = 0x01 = True
|
||||
out_pin_entity = find_entity(entities, "out_pin_presence", BinarySensorInfo)
|
||||
assert out_pin_entity is not None
|
||||
initial_out = initial_state_helper.initial_states.get(out_pin_entity.key)
|
||||
assert initial_out is not None and isinstance(initial_out, BinarySensorState)
|
||||
assert initial_out.state is True, "Out pin presence should be True"
|
||||
|
||||
# Wait for Phase 3 frame (still_distance = 291cm, multi-byte)
|
||||
try:
|
||||
await asyncio.wait_for(phase3_received, timeout=15.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Timeout waiting for Phase 3 frame. Received sensor states:\n"
|
||||
f" still_distance: {sensor_states['still_distance']}\n"
|
||||
f" moving_distance: {sensor_states['moving_distance']}"
|
||||
)
|
||||
|
||||
# Phase 3: still distance = 0x0123 = 291cm (multi-byte distance test)
|
||||
phase3_still = [
|
||||
v for v in sensor_states["still_distance"] if v == pytest.approx(291.0)
|
||||
]
|
||||
assert len(phase3_still) >= 1, (
|
||||
f"Expected still_distance=291, got: {sensor_states['still_distance']}"
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Tests for esphome.automation module."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.automation import has_non_synchronous_actions
|
||||
from esphome.util import RegistryEntry
|
||||
|
||||
|
||||
def _make_registry(non_synchronous_actions: set[str]) -> dict[str, RegistryEntry]:
|
||||
"""Create a mock ACTION_REGISTRY with specified non-synchronous actions.
|
||||
|
||||
Uses the default synchronous=False, matching the real registry behavior.
|
||||
"""
|
||||
registry: dict[str, RegistryEntry] = {}
|
||||
for name in non_synchronous_actions:
|
||||
registry[name] = RegistryEntry(name, lambda: None, None, None)
|
||||
return registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_registry() -> Generator[dict[str, RegistryEntry]]:
|
||||
"""Fixture that patches ACTION_REGISTRY with delay, wait_until, script.wait as non-synchronous."""
|
||||
registry: dict[str, RegistryEntry] = _make_registry(
|
||||
{"delay", "wait_until", "script.wait"}
|
||||
)
|
||||
registry["logger.log"] = RegistryEntry(
|
||||
"logger.log", lambda: None, None, None, synchronous=True
|
||||
)
|
||||
with patch("esphome.automation.ACTION_REGISTRY", registry):
|
||||
yield registry
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_empty_list(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions([]) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_empty_dict(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions({}) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_non_dict_non_list(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions("string") is False
|
||||
assert has_non_synchronous_actions(42) is False
|
||||
assert has_non_synchronous_actions(None) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_delay(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions([{"delay": "1s"}]) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_wait_until(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions([{"wait_until": {"condition": {}}}]) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_script_wait(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions([{"script.wait": "script_id"}]) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_synchronous(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions([{"logger.log": "hello"}]) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_unknown_not_in_registry(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""Unknown actions not in registry are not flagged (only registered actions count)."""
|
||||
assert has_non_synchronous_actions([{"unknown.action": "value"}]) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_default_non_synchronous(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""Actions registered without explicit synchronous=True default to non-synchronous."""
|
||||
mock_registry["some.action"] = RegistryEntry(
|
||||
"some.action", lambda: None, None, None
|
||||
)
|
||||
assert has_non_synchronous_actions([{"some.action": "value"}]) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_nested_in_then(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""Non-synchronous action nested inside a synchronous action's then block."""
|
||||
actions: list[dict[str, object]] = [
|
||||
{
|
||||
"logger.log": "first",
|
||||
"then": [{"delay": "1s"}],
|
||||
}
|
||||
]
|
||||
assert has_non_synchronous_actions(actions) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_deeply_nested(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""Non-synchronous action deeply nested in action structure."""
|
||||
actions: list[dict[str, object]] = [
|
||||
{
|
||||
"if": {
|
||||
"then": [
|
||||
{"logger.log": "hello"},
|
||||
{"delay": "500ms"},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
assert has_non_synchronous_actions(actions) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_none_in_nested(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""No non-synchronous actions even with nesting."""
|
||||
actions: list[dict[str, object]] = [
|
||||
{
|
||||
"if": {
|
||||
"then": [
|
||||
{"logger.log": "hello"},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
assert has_non_synchronous_actions(actions) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_multiple_one_non_synchronous(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert (
|
||||
has_non_synchronous_actions(
|
||||
[
|
||||
{"logger.log": "first"},
|
||||
{"delay": "1s"},
|
||||
{"logger.log": "second"},
|
||||
]
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_multiple_all_synchronous(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert (
|
||||
has_non_synchronous_actions(
|
||||
[
|
||||
{"logger.log": "first"},
|
||||
{"logger.log": "second"},
|
||||
]
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_dict_input(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""Direct dict input (single action)."""
|
||||
assert has_non_synchronous_actions({"delay": "1s"}) is True
|
||||
assert has_non_synchronous_actions({"logger.log": "hello"}) is False
|
||||
Reference in New Issue
Block a user