mirror of
https://github.com/esphome/esphome.git
synced 2026-08-27 16:29:29 +00:00
Merge remote-tracking branch 'origin/dev' into esp8266-native-library-backend
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.13.0
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.13.1
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
@@ -499,6 +500,40 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)})
|
||||
|
||||
_ID_CALL_PROG = re.compile(r"\bid\s*\(")
|
||||
|
||||
|
||||
# Remove before 2027.3.0: untagged strings that look like lambda source keep
|
||||
# being compiled as lambdas during the deprecation window
|
||||
def _coerce_implicit_lambda(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if cv.looks_like_returning_lambda(value):
|
||||
_LOGGER.warning(
|
||||
"[api] The 'variables' value '%s' looks like a lambda but is "
|
||||
"missing the !lambda tag. It is compiled as a lambda for now but "
|
||||
"will be sent as literal text from 2027.3.0. Add !lambda to keep "
|
||||
"it evaluated; literal text belongs under 'data:'.",
|
||||
value,
|
||||
)
|
||||
# cv.templatable runs returning_lambda on the coerced Lambda
|
||||
return cv.lambda_(value)
|
||||
if _ID_CALL_PROG.search(value):
|
||||
# lambda source without a return: issue 5394's mistake class
|
||||
_LOGGER.warning(
|
||||
"[api] The 'variables' value '%s' is sent as literal text; wrap "
|
||||
"it in !lambda 'return ...;' to evaluate it instead.",
|
||||
value,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# Static strings or !lambda values. cv.templatable stays introspectable for
|
||||
# schema tooling; removing the shim leaves KEY_VALUE_SCHEMA.
|
||||
VARIABLES_SCHEMA = cv.Schema(
|
||||
{cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))}
|
||||
)
|
||||
|
||||
|
||||
def _validate_response_config(config: ConfigType) -> ConfigType:
|
||||
# Validate dependencies:
|
||||
@@ -535,9 +570,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): cv.Schema(
|
||||
{cv.string: cv.returning_lambda}
|
||||
),
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||
cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string),
|
||||
cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True),
|
||||
@@ -598,6 +631,8 @@ async def homeassistant_service_to_code(
|
||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||
for key, value in config[CONF_VARIABLES].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
if on_error := config.get(CONF_ON_ERROR):
|
||||
@@ -652,7 +687,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
||||
cv.Required(CONF_EVENT): validate_homeassistant_event,
|
||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -698,6 +733,8 @@ async def homeassistant_event_to_code(
|
||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||
for key, value in config[CONF_VARIABLES].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
return var
|
||||
|
||||
@@ -1654,7 +1654,8 @@ message ListEntitiesMediaPlayerResponse {
|
||||
bool disabled_by_default = 6;
|
||||
EntityCategory entity_category = 7;
|
||||
|
||||
bool supports_pause = 8;
|
||||
// Deprecated in ESPHome 2026.9.0; use feature_flags instead.
|
||||
bool supports_pause = 8 [deprecated = true];
|
||||
|
||||
repeated MediaPlayerSupportedFormat supported_formats = 9;
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
#include "api_buffer.h"
|
||||
#include <new>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
void APIBuffer::grow_(size_t n) {
|
||||
auto new_data = make_buffer(n);
|
||||
bool APIBuffer::grow_(size_t n) {
|
||||
// nothrow (no zero-fill) so OOM is reportable; plain new aborts instead
|
||||
// (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF).
|
||||
// RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory.
|
||||
std::unique_ptr<uint8_t[]> new_data(new (std::nothrow) uint8_t[n]);
|
||||
if (new_data == nullptr)
|
||||
return false;
|
||||
if (this->size_)
|
||||
std::memcpy(new_data.get(), this->data_.get(), this->size_);
|
||||
this->data_ = std::move(new_data);
|
||||
this->capacity_ = n;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -9,16 +9,6 @@
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
/// Helper to use make_unique_for_overwrite where available (skips zero-fill),
|
||||
/// falling back to make_unique on older GCC (ESP8266, LibreTiny).
|
||||
inline std::unique_ptr<uint8_t[]> make_buffer(size_t n) {
|
||||
#if defined(USE_ESP8266) || defined(USE_LIBRETINY)
|
||||
return std::make_unique<uint8_t[]>(n);
|
||||
#else
|
||||
return std::make_unique_for_overwrite<uint8_t[]>(n);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Byte buffer that skips zero-initialization on resize().
|
||||
///
|
||||
/// std::vector<uint8_t>::resize() zero-fills new bytes via memset. For the
|
||||
@@ -36,23 +26,23 @@ inline std::unique_ptr<uint8_t[]> make_buffer(size_t n) {
|
||||
class APIBuffer {
|
||||
public:
|
||||
void clear() { this->size_ = 0; }
|
||||
inline void reserve(size_t n) ESPHOME_ALWAYS_INLINE {
|
||||
if (n > this->capacity_)
|
||||
this->grow_(n);
|
||||
}
|
||||
inline void resize(size_t n) ESPHOME_ALWAYS_INLINE {
|
||||
this->reserve(n);
|
||||
this->size_ = n; // no zero-fill
|
||||
}
|
||||
/// Returns false if allocation fails; the buffer is left unchanged.
|
||||
[[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); }
|
||||
/// Returns false if allocation fails; the buffer is left unchanged. No zero-fill.
|
||||
[[nodiscard]] inline bool resize(size_t n) ESPHOME_ALWAYS_INLINE { return this->reserve_and_resize(n, n); }
|
||||
/// Reserve capacity for max(reserve_size, new_size) bytes, then set size to new_size.
|
||||
/// Single grow_ check regardless of argument order.
|
||||
inline void reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
|
||||
this->reserve(std::max(reserve_size, new_size));
|
||||
/// Returns false if allocation fails; the buffer is left unchanged.
|
||||
[[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
|
||||
if (!this->reserve(std::max(reserve_size, new_size)))
|
||||
return false;
|
||||
this->size_ = new_size;
|
||||
return true;
|
||||
}
|
||||
uint8_t *data() { return this->data_.get(); }
|
||||
const uint8_t *data() const { return this->data_.get(); }
|
||||
size_t size() const { return this->size_; }
|
||||
size_t capacity() const { return this->capacity_; }
|
||||
bool empty() const { return this->size_ == 0; }
|
||||
uint8_t &operator[](size_t i) { return this->data_[i]; }
|
||||
const uint8_t &operator[](size_t i) const { return this->data_[i]; }
|
||||
@@ -64,7 +54,7 @@ class APIBuffer {
|
||||
}
|
||||
|
||||
protected:
|
||||
void grow_(size_t n);
|
||||
bool grow_(size_t n);
|
||||
std::unique_ptr<uint8_t[]> data_;
|
||||
size_t size_{0};
|
||||
size_t capacity_{0};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "api_connection.h"
|
||||
#ifdef USE_API
|
||||
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
|
||||
#include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines
|
||||
#ifdef USE_API_NOISE
|
||||
#include "api_frame_helper_noise.h"
|
||||
#endif
|
||||
@@ -1099,7 +1099,6 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec
|
||||
auto *media_player = static_cast<media_player::MediaPlayer *>(entity);
|
||||
ListEntitiesMediaPlayerResponse msg;
|
||||
auto traits = media_player->get_traits();
|
||||
msg.supports_pause = traits.get_supports_pause();
|
||||
msg.feature_flags = traits.get_feature_flags();
|
||||
for (auto &supported_format : traits.get_supported_formats()) {
|
||||
msg.supported_formats.emplace_back();
|
||||
@@ -2240,10 +2239,17 @@ bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type,
|
||||
this->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
||||
}
|
||||
#endif
|
||||
if (!this->prepare_first_message_buffer(payload_size)) [[unlikely]] {
|
||||
this->fatal_out_of_memory_();
|
||||
return false;
|
||||
}
|
||||
auto &shared_buf = this->parent_->get_shared_buffer_ref();
|
||||
this->prepare_first_message_buffer(shared_buf, payload_size);
|
||||
size_t write_start = shared_buf.size();
|
||||
shared_buf.resize(write_start + payload_size);
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(shared_buf.capacity() >= write_start + payload_size);
|
||||
#endif
|
||||
// Capacity reserved above, cannot fail
|
||||
(void) shared_buf.resize(write_start + payload_size);
|
||||
ProtoWriteBuffer buffer{&shared_buf, write_start};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type);
|
||||
@@ -2279,6 +2285,9 @@ void APIConnection::on_no_setup_connection() {
|
||||
this->on_fatal_error();
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_DEBUG, LOG_STR("no connection setup"));
|
||||
}
|
||||
void APIConnection::fatal_out_of_memory_() {
|
||||
this->fatal_error_with_log_(LOG_STR("Out of memory"), APIError::OUT_OF_MEMORY);
|
||||
}
|
||||
void APIConnection::on_fatal_error() {
|
||||
// Don't close socket here - keep it open so getpeername() works for logging
|
||||
// Socket will be closed when client is removed from the list in APIServer::loop()
|
||||
@@ -2293,16 +2302,25 @@ bool APIConnection::schedule_message_front_(EntityBase *entity, uint16_t message
|
||||
bool APIConnection::send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||
uint8_t aux_data_index) {
|
||||
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
|
||||
auto &shared_buf = this->parent_->get_shared_buffer_ref();
|
||||
this->prepare_first_message_buffer(shared_buf, estimated_size);
|
||||
// No local for the shared buffer here: keeping it live across
|
||||
// dispatch_message_ costs a register and spills message_type into the
|
||||
// batching path's dedup loop (measured on x86 GCC -Os)
|
||||
if (!this->prepare_first_message_buffer(estimated_size)) [[unlikely]] {
|
||||
this->fatal_out_of_memory_();
|
||||
return false;
|
||||
}
|
||||
DeferredBatch::BatchItem item{entity, message_type, estimated_size, aux_data_index};
|
||||
if (this->dispatch_message_(item, MAX_BATCH_PACKET_SIZE, true) &&
|
||||
this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type)) {
|
||||
this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_batch_item_(item);
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
// An OOM during the immediate attempt marks the connection for removal;
|
||||
// don't queue more work (schedule_message_'s push_back may allocate again)
|
||||
if (this->flags_.remove) [[unlikely]]
|
||||
return false;
|
||||
}
|
||||
return this->schedule_message_(entity, message_type, estimated_size, aux_data_index);
|
||||
}
|
||||
@@ -2352,7 +2370,11 @@ void APIConnection::process_batch_() {
|
||||
total_estimated_size = MAX_BATCH_PACKET_SIZE;
|
||||
}
|
||||
|
||||
this->prepare_first_message_buffer(shared_buf, header_padding, total_estimated_size);
|
||||
if (!this->prepare_first_message_buffer(header_padding, total_estimated_size)) [[unlikely]] {
|
||||
this->fatal_out_of_memory_();
|
||||
this->clear_batch_();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast path for single message - buffer already allocated above
|
||||
if (num_items == 1) {
|
||||
@@ -2367,8 +2389,10 @@ void APIConnection::process_batch_() {
|
||||
#endif
|
||||
this->clear_batch_();
|
||||
} else if (payload_size == 0) {
|
||||
// Message too large to fit in available space
|
||||
ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
|
||||
// payload_size == 0 with remove set means encoding hit OOM and the
|
||||
// connection is being dropped; warn only for a genuinely oversized message
|
||||
if (!this->flags_.remove)
|
||||
ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
|
||||
this->clear_batch_();
|
||||
}
|
||||
return;
|
||||
@@ -2431,8 +2455,10 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items
|
||||
|
||||
if (items_processed > 0) {
|
||||
// Add footer space for the last message (for Noise protocol MAC)
|
||||
if (footer_size > 0) {
|
||||
shared_buf.resize(shared_buf.size() + footer_size);
|
||||
if (footer_size > 0 && !shared_buf.resize(shared_buf.size() + footer_size)) [[unlikely]] {
|
||||
this->fatal_out_of_memory_();
|
||||
this->clear_batch_();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send all collected messages
|
||||
|
||||
@@ -352,22 +352,13 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
}
|
||||
}
|
||||
|
||||
void prepare_first_message_buffer(APIBuffer &shared_buf, size_t header_padding, size_t total_size) {
|
||||
shared_buf.clear();
|
||||
// Reserve space for header padding + message + footer
|
||||
// - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext)
|
||||
// - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext)
|
||||
// Reserve full size but only set initial size to header padding
|
||||
// so message encoding starts at the correct position
|
||||
shared_buf.reserve_and_resize(total_size, header_padding);
|
||||
}
|
||||
/// Clear the shared write buffer and reserve space for the first message.
|
||||
/// Returns false if the allocation fails (out of memory).
|
||||
/// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||
[[nodiscard]] bool prepare_first_message_buffer(size_t header_padding, size_t total_size);
|
||||
|
||||
// Convenience overload - computes frame overhead internally
|
||||
void prepare_first_message_buffer(APIBuffer &shared_buf, size_t payload_size) {
|
||||
const uint8_t header_padding = this->helper_->frame_header_padding();
|
||||
const uint8_t footer_size = this->helper_->frame_footer_size();
|
||||
this->prepare_first_message_buffer(shared_buf, header_padding, payload_size + header_padding + footer_size);
|
||||
}
|
||||
[[nodiscard]] bool prepare_first_message_buffer(size_t payload_size);
|
||||
|
||||
bool try_to_clear_buffer(bool log_out_of_space) {
|
||||
if (this->flags_.remove)
|
||||
@@ -853,6 +844,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
this->on_fatal_error();
|
||||
this->log_warning_(message, err);
|
||||
}
|
||||
// Shared cold path for buffer allocation failures — noinline keeps the
|
||||
// OOM handling out of the hot send paths
|
||||
void __attribute__((noinline)) fatal_out_of_memory_();
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
|
||||
// Inline APIConnection methods that need APIServer complete. Include this
|
||||
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
|
||||
// Inline APIConnection members that need APIServer complete. Include this
|
||||
// instead of api_connection.h when calling them.
|
||||
|
||||
#include "api_connection.h"
|
||||
#include "api_server.h"
|
||||
@@ -41,7 +41,10 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c
|
||||
return 0;
|
||||
|
||||
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
|
||||
shared_buf.resize(shared_buf.size() + to_add);
|
||||
if (!shared_buf.resize(shared_buf.size() + to_add)) [[unlikely]] {
|
||||
conn->fatal_out_of_memory_();
|
||||
return 0;
|
||||
}
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
|
||||
@@ -50,5 +53,22 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c
|
||||
|
||||
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
||||
|
||||
inline bool APIConnection::prepare_first_message_buffer(size_t header_padding, size_t total_size) {
|
||||
auto &shared_buf = this->parent_->get_shared_buffer_ref();
|
||||
shared_buf.clear();
|
||||
// Reserve space for header padding + message + footer
|
||||
// - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext)
|
||||
// - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext)
|
||||
// Reserve full size but only set initial size to header padding
|
||||
// so message encoding starts at the correct position
|
||||
return shared_buf.reserve_and_resize(total_size, header_padding);
|
||||
}
|
||||
|
||||
inline bool APIConnection::prepare_first_message_buffer(size_t payload_size) {
|
||||
const uint8_t header_padding = this->helper_->frame_header_padding();
|
||||
const uint8_t footer_size = this->helper_->frame_footer_size();
|
||||
return this->prepare_first_message_buffer(header_padding, payload_size + header_padding + footer_size);
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif
|
||||
|
||||
@@ -172,7 +172,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin
|
||||
|
||||
// Queue unsent data into overflow buffer
|
||||
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent))) {
|
||||
HELPER_LOG("Overflow buffer full, dropping connection");
|
||||
HELPER_LOG("Overflow buffer full or out of memory, dropping connection");
|
||||
this->state_ = State::FAILED;
|
||||
return APIError::SOCKET_WRITE_FAILED;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,10 @@ APIError APINoiseFrameHelper::init() {
|
||||
|
||||
// init prologue
|
||||
size_t old_size = prologue_.size();
|
||||
prologue_.resize(old_size + PROLOGUE_INIT_LEN);
|
||||
if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] {
|
||||
state_ = State::FAILED;
|
||||
return APIError::OUT_OF_MEMORY;
|
||||
}
|
||||
#ifdef USE_ESP8266
|
||||
memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
|
||||
#else
|
||||
@@ -202,7 +205,10 @@ APIError APINoiseFrameHelper::try_read_frame_() {
|
||||
// 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);
|
||||
this->rx_buf_.resize(alloc_size);
|
||||
if (!this->rx_buf_.resize(alloc_size)) [[unlikely]] {
|
||||
state_ = State::FAILED;
|
||||
return APIError::OUT_OF_MEMORY;
|
||||
}
|
||||
|
||||
if (rx_buf_len_ < msg_size) {
|
||||
// more data to read
|
||||
@@ -269,7 +275,10 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
// Resize for: existing prologue + 2 size bytes + frame data
|
||||
size_t old_size = this->prologue_.size();
|
||||
size_t rx_size = this->rx_buf_.size();
|
||||
this->prologue_.resize(old_size + 2 + rx_size);
|
||||
if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] {
|
||||
state_ = State::FAILED;
|
||||
return APIError::OUT_OF_MEMORY;
|
||||
}
|
||||
this->prologue_[old_size] = (uint8_t) (rx_size >> 8);
|
||||
this->prologue_[old_size + 1] = (uint8_t) rx_size;
|
||||
if (rx_size > 0) {
|
||||
@@ -477,13 +486,15 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuf
|
||||
assert(this->state_ == State::DATA);
|
||||
#endif
|
||||
|
||||
APIBuffer *buf = buffer.get_buffer();
|
||||
// Resize buffer to include footer space for Noise MAC
|
||||
if (this->frame_footer_size_)
|
||||
buffer.get_buffer()->resize(buffer.get_buffer()->size() + this->frame_footer_size_);
|
||||
if (this->frame_footer_size_ && !buf->resize(buf->size() + this->frame_footer_size_)) [[unlikely]] {
|
||||
state_ = State::FAILED;
|
||||
return APIError::OUT_OF_MEMORY;
|
||||
}
|
||||
|
||||
uint16_t payload_size =
|
||||
static_cast<uint16_t>(buffer.get_buffer()->size() - HEADER_PADDING - this->frame_footer_size_);
|
||||
uint8_t *buf_start = buffer.get_buffer()->data();
|
||||
uint16_t payload_size = static_cast<uint16_t>(buf->size() - HEADER_PADDING - this->frame_footer_size_);
|
||||
uint8_t *buf_start = buf->data();
|
||||
uint16_t encrypted_len;
|
||||
APIError aerr = this->encrypt_noise_message_(buf_start, payload_size, type, encrypted_len);
|
||||
if (aerr != APIError::OK)
|
||||
|
||||
@@ -172,7 +172,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
|
||||
|
||||
// Reserve space for body (+ null terminator so protobuf StringRef fields
|
||||
// can be safely null-terminated in-place after decode)
|
||||
this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR);
|
||||
if (!this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR)) [[unlikely]] {
|
||||
state_ = State::FAILED;
|
||||
return APIError::OUT_OF_MEMORY;
|
||||
}
|
||||
|
||||
if (rx_buf_len_ < rx_header_parsed_len_) {
|
||||
// more data to read
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "api_overflow_buffer.h"
|
||||
#ifdef USE_API
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
@@ -61,9 +62,18 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_
|
||||
return false;
|
||||
|
||||
uint16_t buffer_size = total_len - skip;
|
||||
// nothrow: a failed allocation returns nullptr so the connection is dropped
|
||||
// cleanly instead of plain new's crash or abort on OOM
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
||||
auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0};
|
||||
this->queue_[this->tail_] = entry;
|
||||
auto *data = new (std::nothrow) uint8_t[buffer_size];
|
||||
if (data == nullptr)
|
||||
return false;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
||||
auto *entry = new (std::nothrow) Entry{data, buffer_size, 0};
|
||||
if (entry == nullptr) {
|
||||
delete[] data;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t to_skip = skip;
|
||||
uint16_t write_pos = 0;
|
||||
@@ -80,6 +90,8 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_
|
||||
}
|
||||
}
|
||||
|
||||
// Publish only after the copy completes so a half-built entry is never reachable
|
||||
this->queue_[this->tail_] = entry;
|
||||
this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE;
|
||||
this->count_++;
|
||||
return true;
|
||||
|
||||
@@ -61,7 +61,7 @@ class APIOverflowBuffer {
|
||||
|
||||
/// Enqueue unsent IOV data into the backlog.
|
||||
/// Copies iov data starting at byte offset `skip` into a new entry.
|
||||
/// Returns false if the queue is full (caller should fail the connection).
|
||||
/// Returns false if the queue is full or allocation fails (caller should fail the connection).
|
||||
bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip);
|
||||
|
||||
protected:
|
||||
|
||||
@@ -2323,7 +2323,6 @@ uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_
|
||||
#endif
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast<uint32_t>(this->entity_category));
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_pause);
|
||||
for (auto &it : this->supported_formats) {
|
||||
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it);
|
||||
}
|
||||
@@ -2343,7 +2342,6 @@ uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const {
|
||||
#endif
|
||||
size += ProtoSize::calc_bool(1, this->disabled_by_default);
|
||||
size += this->entity_category ? 2 : 0;
|
||||
size += ProtoSize::calc_bool(1, this->supports_pause);
|
||||
if (!this->supported_formats.empty()) {
|
||||
for (const auto &it : this->supported_formats) {
|
||||
size += ProtoSize::calc_message_force(1, it.calculate_size());
|
||||
|
||||
@@ -1911,11 +1911,10 @@ class MediaPlayerSupportedFormat final : public ProtoMessage {
|
||||
class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 63;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 80;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 78;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("list_entities_media_player_response"); }
|
||||
#endif
|
||||
bool supports_pause{false};
|
||||
std::vector<MediaPlayerSupportedFormat> supported_formats{};
|
||||
uint32_t feature_flags{0};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
|
||||
@@ -1962,7 +1962,6 @@ const char *ListEntitiesMediaPlayerResponse::dump_to(DumpBuffer &out) const {
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default);
|
||||
dump_field(out, ESPHOME_PSTR("entity_category"), static_cast<enums::EntityCategory>(this->entity_category));
|
||||
dump_field(out, ESPHOME_PSTR("supports_pause"), this->supports_pause);
|
||||
for (const auto &it : this->supported_formats) {
|
||||
out.append(4, ' ').append_p(ESPHOME_PSTR("supported_formats")).append(": ");
|
||||
it.dump_to(out);
|
||||
|
||||
@@ -551,7 +551,14 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) {
|
||||
|
||||
void ClimateDeviceRestoreState::apply(Climate *climate) {
|
||||
auto traits = climate->get_traits();
|
||||
climate->mode = this->mode;
|
||||
// A saved mode the device no longer offers cannot be selected again, so skip it and leave the
|
||||
// entity on the mode it already has. The other saved fields are still restored.
|
||||
if (traits.supports_mode(this->mode)) {
|
||||
climate->mode = this->mode;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s' - Saved mode %s is no longer supported, keeping %s", climate->get_name().c_str(),
|
||||
LOG_STR_ARG(climate_mode_to_string(this->mode)), LOG_STR_ARG(climate_mode_to_string(climate->mode)));
|
||||
}
|
||||
if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
|
||||
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
|
||||
climate->target_temperature_low = this->target_temperature_low;
|
||||
|
||||
@@ -13,9 +13,11 @@ CONF_HEADER_LOW = "header_low"
|
||||
CONF_BIT_HIGH = "bit_high"
|
||||
CONF_BIT_ONE_LOW = "bit_one_low"
|
||||
CONF_BIT_ZERO_LOW = "bit_zero_low"
|
||||
CONF_ADVANCED_COMMANDS_SUPPORT = "advanced_commands_support"
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(LgIrClimate).extend(
|
||||
{
|
||||
cv.Optional(CONF_ADVANCED_COMMANDS_SUPPORT, default=False): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_HEADER_HIGH, default="8000us"
|
||||
): cv.positive_time_period_microseconds,
|
||||
@@ -38,6 +40,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(LgIrClimate).extend(
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await climate_ir.new_climate_ir(config)
|
||||
|
||||
cg.add(var.set_advanced_commands_support(config[CONF_ADVANCED_COMMANDS_SUPPORT]))
|
||||
cg.add(var.set_header_high(config[CONF_HEADER_HIGH]))
|
||||
cg.add(var.set_header_low(config[CONF_HEADER_LOW]))
|
||||
cg.add(var.set_bit_high(config[CONF_BIT_HIGH]))
|
||||
|
||||
@@ -5,11 +5,85 @@ namespace esphome::climate_ir_lg {
|
||||
|
||||
static const char *const TAG = "climate.climate_ir_lg";
|
||||
|
||||
// Commands
|
||||
const uint32_t COMMAND_MASK = 0xFF000;
|
||||
const uint32_t COMMAND_OFF = 0xC0000;
|
||||
const uint32_t COMMAND_SWING = 0x10000;
|
||||
// All codes provided here are missing the checksum (last 4 bits)
|
||||
// this checksum needs to be calculated before sending (look at `calc_checksum_()`)
|
||||
|
||||
const uint32_t LG_HEADER = 0x8800000;
|
||||
|
||||
// Commands
|
||||
const uint32_t COMMAND_HEADER_MASK = 0xFF000;
|
||||
const uint32_t COMMAND_DATA_MASK = 0x00FF0;
|
||||
const uint32_t CHECKSUM_MASK = 0xF;
|
||||
|
||||
enum CommandBasic : uint32_t {
|
||||
HEADER_BASIC = 0x10000,
|
||||
BASIC_SWING_TOGGLE = 0x000,
|
||||
|
||||
// JET MODE (only for cooling/drying/heating modes)
|
||||
// For 30 minutes: max airflow (stronger than F5 aka FAN_MAX) + PO (min/min/max temperature respectively)
|
||||
// After 30 minutes: F5 aka FAN_MAX + min/min/max temperature respectively
|
||||
BASIC_JET = 0x080,
|
||||
};
|
||||
|
||||
enum CommandSys : uint32_t {
|
||||
HEADER_SYS = 0xC0000,
|
||||
|
||||
COMMAND_OFF = 0x050,
|
||||
|
||||
// Also known as 'auto-dry'
|
||||
AUTO_CLEAN_ON = 0x0B0,
|
||||
AUTO_CLEAN_OFF = 0x0C0,
|
||||
|
||||
PURIFY_ON = 0x000, // From either OFF or Mode -> Purify
|
||||
PURIFY_OFF = 0x080, // From Mode + Purify -> Mode
|
||||
|
||||
QUIET_OUTDOOR_ON = 0xA60,
|
||||
QUIET_OUTDOOR_OFF = 0xA70,
|
||||
|
||||
// ENERGY CTRL (only in Cooling mode)
|
||||
COOL_ENERG_CTRL_80 = 0x7D0, // 80%
|
||||
COOL_ENERG_CTRL_60 = 0x7E0, // 60%
|
||||
COOL_ENERG_CTRL_40 = 0x800, // 40%
|
||||
COOL_ENERG_CTRL_OFF = 0x7F0, // OFF
|
||||
|
||||
DISPLAY_KW = 0x460,
|
||||
LIGHT_ON_OFF = 0x0A0,
|
||||
|
||||
TEMP_UNIT_F = 0x170,
|
||||
TEMP_UNIT_C = 0x160,
|
||||
};
|
||||
|
||||
enum CommandAdvSwing : uint32_t {
|
||||
HEADER_ADV_SWING = 0x13000,
|
||||
|
||||
// Only 5 bits are relevant, I got 0x13952 once - not sure what is the 8th bit so ignoring that.
|
||||
ADV_SWING_DATA_MASK = 0x1F0,
|
||||
|
||||
// Commands for Advanced Vertical Control: Swing + 6 fixed positions
|
||||
VERT_FIX_1 = 0x040, // Down
|
||||
VERT_FIX_2 = 0x050,
|
||||
VERT_FIX_3 = 0x060,
|
||||
VERT_FIX_4 = 0x070,
|
||||
VERT_FIX_5 = 0x080,
|
||||
VERT_FIX_6 = 0x090, // Up
|
||||
VERT_SWING_ON = 0x140, // Swing between 1 and 6
|
||||
VERT_SWING_OFF = 0x150, // Stops immediately
|
||||
|
||||
// Commands for Advanced Horizontal Control: Swing (3 modes) + 5 fixed positions
|
||||
HORI_FIX_1 = 0x0B0, // Left
|
||||
HORI_FIX_2 = 0x0C0,
|
||||
HORI_FIX_3 = 0x0D0,
|
||||
HORI_FIX_4 = 0x0E0,
|
||||
HORI_FIX_5 = 0x0F0, // Right
|
||||
HORI_SWING_ON_LEFT = 0x100, // Swing between 1 and 3
|
||||
HORI_SWING_ON_RIGHT = 0x110, // Swing between 3 and 5
|
||||
HORI_SWING_ON_FULL = 0x160, // Swing between 1 and 5
|
||||
HORI_SWING_OFF = 0x170, // Stops immediately
|
||||
};
|
||||
|
||||
// Following commands contain mode, fan speed and temperature
|
||||
|
||||
// Modes
|
||||
const uint32_t COMMAND_ON_COOL = 0x00000;
|
||||
const uint32_t COMMAND_ON_DRY = 0x01000;
|
||||
const uint32_t COMMAND_ON_FAN_ONLY = 0x02000;
|
||||
@@ -23,11 +97,13 @@ const uint32_t COMMAND_AI = 0x0B000;
|
||||
const uint32_t COMMAND_HEAT = 0x0C000;
|
||||
|
||||
// Fan speed
|
||||
const uint32_t FAN_MASK = 0xF0;
|
||||
const uint32_t FAN_SPEED_MASK = 0xF0;
|
||||
const uint32_t FAN_AUTO = 0x50;
|
||||
const uint32_t FAN_MIN = 0x00;
|
||||
const uint32_t FAN_MED = 0x20;
|
||||
const uint32_t FAN_MAX = 0x40;
|
||||
const uint32_t FAN_MIN = 0x00; // AKA F1
|
||||
const uint32_t FAN_F2 = 0x90;
|
||||
const uint32_t FAN_MED = 0x20; // AKA F3
|
||||
const uint32_t FAN_F4 = 0xA0;
|
||||
const uint32_t FAN_MAX = 0x40; // AKA F5
|
||||
|
||||
// Temperature
|
||||
const uint8_t TEMP_RANGE = TEMP_MAX - TEMP_MIN + 1;
|
||||
@@ -37,16 +113,37 @@ const uint32_t TEMP_SHIFT = 8;
|
||||
const uint16_t BITS = 28;
|
||||
|
||||
void LgIrClimate::transmit_state() {
|
||||
uint32_t remote_state = 0x8800000;
|
||||
uint32_t remote_state = LG_HEADER;
|
||||
|
||||
// ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", modeBefore_);
|
||||
// ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", this->modeBefore_);
|
||||
|
||||
// Set command
|
||||
if (this->send_swing_cmd_) {
|
||||
this->send_swing_cmd_ = false;
|
||||
remote_state |= COMMAND_SWING;
|
||||
} else {
|
||||
bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF);
|
||||
if (this->advanced_commands_support_) {
|
||||
switch (this->swing_mode) {
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
ESP_LOGD(TAG, "setting swing vertical");
|
||||
remote_state |= CommandAdvSwing::HEADER_ADV_SWING;
|
||||
remote_state |= CommandAdvSwing::VERT_SWING_ON;
|
||||
break;
|
||||
case climate::CLIMATE_SWING_OFF:
|
||||
ESP_LOGD(TAG, "setting swing off");
|
||||
remote_state |= CommandAdvSwing::HEADER_ADV_SWING;
|
||||
remote_state |= CommandAdvSwing::VERT_SWING_OFF;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
this->transmit_(remote_state);
|
||||
this->publish_state();
|
||||
return;
|
||||
} else { // just toggle swing when advanced_commands_support is not set
|
||||
remote_state |= HEADER_BASIC;
|
||||
remote_state |= BASIC_SWING_TOGGLE;
|
||||
}
|
||||
} else { // Mode commands
|
||||
const bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF);
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_COOL:
|
||||
remote_state |= climate_is_off ? COMMAND_ON_COOL : COMMAND_COOL;
|
||||
@@ -65,8 +162,8 @@ void LgIrClimate::transmit_state() {
|
||||
break;
|
||||
case climate::CLIMATE_MODE_OFF:
|
||||
default:
|
||||
remote_state |= COMMAND_OFF;
|
||||
break;
|
||||
remote_state |= CommandSys::HEADER_SYS;
|
||||
remote_state |= CommandSys::COMMAND_OFF;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +172,8 @@ void LgIrClimate::transmit_state() {
|
||||
ESP_LOGD(TAG, "climate_lg_ir mode code: 0x%02X", this->mode);
|
||||
|
||||
// Set fan speed
|
||||
if (this->mode == climate::CLIMATE_MODE_OFF) {
|
||||
remote_state |= FAN_AUTO;
|
||||
} else {
|
||||
if (this->mode !=
|
||||
climate::CLIMATE_MODE_OFF) { // https://github.com/esphome/esphome/pull/10875#issuecomment-5042765948
|
||||
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
|
||||
case climate::CLIMATE_FAN_HIGH:
|
||||
remote_state |= FAN_MAX;
|
||||
@@ -95,10 +191,20 @@ void LgIrClimate::transmit_state() {
|
||||
}
|
||||
}
|
||||
|
||||
// Set temperature
|
||||
if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT) {
|
||||
auto temp = (uint8_t) roundf(clamp<float>(this->target_temperature, TEMP_MIN, TEMP_MAX));
|
||||
remote_state |= ((temp - 15) << TEMP_SHIFT);
|
||||
uint8_t temp;
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_HEAT_COOL:
|
||||
if (!this->advanced_commands_support_) { // Keep previous behavior
|
||||
break;
|
||||
}
|
||||
[[fallthrough]];
|
||||
case climate::CLIMATE_MODE_COOL:
|
||||
case climate::CLIMATE_MODE_HEAT:
|
||||
temp = static_cast<uint8_t>(roundf(clamp<float>(this->target_temperature, TEMP_MIN, TEMP_MAX)));
|
||||
remote_state |= (temp - 15) << TEMP_SHIFT;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
this->transmit_(remote_state);
|
||||
@@ -124,62 +230,134 @@ bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) {
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Decoded 0x%02" PRIX32, remote_state);
|
||||
if ((remote_state & 0xFF00000) != 0x8800000)
|
||||
ESP_LOGD(TAG, "Received 0x%02" PRIX32, remote_state);
|
||||
if ((remote_state & 0xFF00000) != LG_HEADER)
|
||||
return false;
|
||||
|
||||
// Get command
|
||||
if ((remote_state & COMMAND_MASK) == COMMAND_OFF) {
|
||||
this->mode = climate::CLIMATE_MODE_OFF;
|
||||
} else if ((remote_state & COMMAND_MASK) == COMMAND_SWING) {
|
||||
this->swing_mode =
|
||||
this->swing_mode == climate::CLIMATE_SWING_OFF ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF;
|
||||
} else {
|
||||
switch (remote_state & COMMAND_MASK) {
|
||||
case COMMAND_DRY:
|
||||
case COMMAND_ON_DRY:
|
||||
this->mode = climate::CLIMATE_MODE_DRY;
|
||||
break;
|
||||
case COMMAND_FAN_ONLY:
|
||||
case COMMAND_ON_FAN_ONLY:
|
||||
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
|
||||
break;
|
||||
case COMMAND_AI:
|
||||
case COMMAND_ON_AI:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT_COOL;
|
||||
break;
|
||||
case COMMAND_HEAT:
|
||||
case COMMAND_ON_HEAT:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT;
|
||||
break;
|
||||
case COMMAND_COOL:
|
||||
case COMMAND_ON_COOL:
|
||||
default:
|
||||
this->mode = climate::CLIMATE_MODE_COOL;
|
||||
break;
|
||||
}
|
||||
|
||||
// Get fan speed
|
||||
if (this->mode == climate::CLIMATE_MODE_HEAT_COOL) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
} else if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_DRY ||
|
||||
this->mode == climate::CLIMATE_MODE_FAN_ONLY || this->mode == climate::CLIMATE_MODE_HEAT) {
|
||||
if ((remote_state & FAN_MASK) == FAN_AUTO) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
} else if ((remote_state & FAN_MASK) == FAN_MIN) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_LOW;
|
||||
} else if ((remote_state & FAN_MASK) == FAN_MED) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_MEDIUM;
|
||||
} else if ((remote_state & FAN_MASK) == FAN_MAX) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_HIGH;
|
||||
// Decode commands
|
||||
switch (remote_state & COMMAND_HEADER_MASK) {
|
||||
case CommandSys::HEADER_SYS:
|
||||
ESP_LOGD(TAG, "Got system command! With data: 0x%02" PRIX32, remote_state & COMMAND_DATA_MASK);
|
||||
if ((remote_state & COMMAND_DATA_MASK) == CommandSys::COMMAND_OFF) {
|
||||
this->mode = climate::CLIMATE_MODE_OFF;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case CommandAdvSwing::HEADER_ADV_SWING:
|
||||
ESP_LOGD(TAG, "Got advanced swing command! With data: 0x%02" PRIX32,
|
||||
remote_state & CommandAdvSwing::ADV_SWING_DATA_MASK);
|
||||
switch (remote_state & CommandAdvSwing::ADV_SWING_DATA_MASK) {
|
||||
case CommandAdvSwing::VERT_SWING_ON:
|
||||
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
|
||||
break;
|
||||
case CommandAdvSwing::VERT_SWING_OFF:
|
||||
case CommandAdvSwing::VERT_FIX_1:
|
||||
case CommandAdvSwing::VERT_FIX_2:
|
||||
case CommandAdvSwing::VERT_FIX_3:
|
||||
case CommandAdvSwing::VERT_FIX_4:
|
||||
case CommandAdvSwing::VERT_FIX_5:
|
||||
case CommandAdvSwing::VERT_FIX_6:
|
||||
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
||||
break;
|
||||
default:
|
||||
return false; // Ignore all other (horizontal) swing commands
|
||||
}
|
||||
}
|
||||
|
||||
// Get temperature
|
||||
if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT) {
|
||||
this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15;
|
||||
}
|
||||
this->publish_state();
|
||||
return true;
|
||||
|
||||
case HEADER_BASIC:
|
||||
if ((remote_state & COMMAND_DATA_MASK) == BASIC_JET) {
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_COOL:
|
||||
case climate::CLIMATE_MODE_HEAT:
|
||||
case climate::CLIMATE_MODE_DRY:
|
||||
this->target_temperature =
|
||||
this->mode == climate::CLIMATE_MODE_HEAT ? this->maximum_temperature_ : this->minimum_temperature_;
|
||||
this->fan_mode = climate::CLIMATE_FAN_HIGH;
|
||||
// When enabling PO(WER) also known as JET mode, swing is set to VERT_3, but after 30 mins it will switch
|
||||
// back to what it was before, so let's just not change it here it at all
|
||||
this->publish_state();
|
||||
return true;
|
||||
default:
|
||||
ESP_LOGD(TAG, "Got jet command, but current mode does not support it! Ignoring.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep previous behavior in case of other BASIC command
|
||||
if (this->swing_mode == climate::CLIMATE_SWING_OFF) { // Just flip between vertical and off
|
||||
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
|
||||
} else {
|
||||
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
||||
}
|
||||
this->publish_state();
|
||||
return true;
|
||||
// Following commands also contain fan speed and temperature, so no 'return' in these cases
|
||||
case COMMAND_DRY:
|
||||
case COMMAND_ON_DRY:
|
||||
this->mode = climate::CLIMATE_MODE_DRY;
|
||||
break;
|
||||
case COMMAND_FAN_ONLY:
|
||||
case COMMAND_ON_FAN_ONLY:
|
||||
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
|
||||
break;
|
||||
case COMMAND_AI:
|
||||
case COMMAND_ON_AI:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT_COOL;
|
||||
break;
|
||||
case COMMAND_HEAT:
|
||||
case COMMAND_ON_HEAT:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT;
|
||||
break;
|
||||
case COMMAND_COOL:
|
||||
case COMMAND_ON_COOL:
|
||||
this->mode = climate::CLIMATE_MODE_COOL;
|
||||
break;
|
||||
default:
|
||||
ESP_LOGD(TAG, "Got unknown command! Ignoring!");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode fan speed
|
||||
switch (remote_state & FAN_SPEED_MASK) {
|
||||
case FAN_AUTO:
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
break;
|
||||
case FAN_MIN:
|
||||
case FAN_F2:
|
||||
this->fan_mode = climate::CLIMATE_FAN_LOW;
|
||||
break;
|
||||
case FAN_MED:
|
||||
case FAN_F4:
|
||||
this->fan_mode = climate::CLIMATE_FAN_MEDIUM;
|
||||
break;
|
||||
case FAN_MAX:
|
||||
this->fan_mode = climate::CLIMATE_FAN_HIGH;
|
||||
break;
|
||||
default:
|
||||
ESP_LOGD(TAG, "Got unknown fan speed! Ignoring!");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep previous behavior
|
||||
if (this->mode == climate::CLIMATE_MODE_HEAT_COOL && !(this->advanced_commands_support_)) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
}
|
||||
|
||||
// Decode temperature for modes that support it
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_HEAT_COOL:
|
||||
case climate::CLIMATE_MODE_COOL:
|
||||
case climate::CLIMATE_MODE_HEAT:
|
||||
this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
this->mode_before_ = this->mode;
|
||||
this->publish_state();
|
||||
|
||||
return true;
|
||||
@@ -207,14 +385,14 @@ void LgIrClimate::transmit_(uint32_t value) {
|
||||
data->mark(this->bit_high_);
|
||||
transmit.perform();
|
||||
}
|
||||
|
||||
void LgIrClimate::calc_checksum_(uint32_t &value) {
|
||||
uint32_t mask = 0xF;
|
||||
uint32_t sum = 0;
|
||||
for (uint8_t i = 1; i < 8; i++) {
|
||||
sum += (value & (mask << (i * 4))) >> (i * 4);
|
||||
sum += (value & (CHECKSUM_MASK << (i * 4))) >> (i * 4);
|
||||
}
|
||||
|
||||
value |= (sum & mask);
|
||||
value |= (sum & CHECKSUM_MASK);
|
||||
}
|
||||
|
||||
} // namespace esphome::climate_ir_lg
|
||||
|
||||
@@ -21,12 +21,13 @@ class LgIrClimate final : public climate_ir::ClimateIR {
|
||||
/// Override control to change settings of the climate device.
|
||||
void control(const climate::ClimateCall &call) override {
|
||||
this->send_swing_cmd_ = call.get_swing_mode().has_value();
|
||||
// swing resets after unit powered off
|
||||
// swing resets after unit powered off, except when advanced_commands_support_ is set
|
||||
auto mode = call.get_mode();
|
||||
if (mode.has_value() && *mode == climate::CLIMATE_MODE_OFF)
|
||||
if (mode.has_value() && *mode == climate::CLIMATE_MODE_OFF && !(this->advanced_commands_support_))
|
||||
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
||||
climate_ir::ClimateIR::control(call);
|
||||
}
|
||||
void set_advanced_commands_support(bool value) { this->advanced_commands_support_ = value; }
|
||||
void set_header_high(uint32_t header_high) { this->header_high_ = header_high; }
|
||||
void set_header_low(uint32_t header_low) { this->header_low_ = header_low; }
|
||||
void set_bit_high(uint32_t bit_high) { this->bit_high_ = bit_high; }
|
||||
@@ -44,6 +45,7 @@ class LgIrClimate final : public climate_ir::ClimateIR {
|
||||
void calc_checksum_(uint32_t &value);
|
||||
void transmit_(uint32_t value);
|
||||
|
||||
bool advanced_commands_support_{false};
|
||||
uint32_t header_high_;
|
||||
uint32_t header_low_;
|
||||
uint32_t bit_high_;
|
||||
|
||||
@@ -116,14 +116,16 @@ _CALLBACK_AUTOMATIONS = (
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
|
||||
# Initialize sensor storage with count from final_validate
|
||||
# Initialize sensor storage with count from final_validate before any
|
||||
# await, so platform to_code() calls always see it initialized
|
||||
# regardless of YAML key order.
|
||||
sensor_count = _get_data().sensor_counts.get(str(config[CONF_ID]), 0)
|
||||
if sensor_count > 0:
|
||||
cg.add(var.init_sensors(sensor_count))
|
||||
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "preferences.h"
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
@@ -29,6 +30,13 @@ void loop_task(void *pv_params) {
|
||||
}
|
||||
|
||||
extern "C" void app_main() {
|
||||
// Apply the custom eFuse MAC (if burned and valid) as the base MAC before any
|
||||
// interface (Wi-Fi, Ethernet, Bluetooth, 802.15.4) derives its address from it.
|
||||
// The logger does not exist yet, so only log-free helpers may be used here.
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
if (get_custom_mac_address(mac)) {
|
||||
set_mac_address(mac);
|
||||
}
|
||||
initArduino();
|
||||
esp32::setup_preferences();
|
||||
#if CONFIG_FREERTOS_UNICORE
|
||||
|
||||
@@ -71,23 +71,32 @@ static bool read_valid_mac(uint8_t *mac, esp_err_t err) { return err == ESP_OK &
|
||||
|
||||
static constexpr size_t MAC_ADDRESS_SIZE_BITS = MAC_ADDRESS_SIZE * 8; // 48 bits
|
||||
|
||||
// Must not use the ESPHome logger (may run before it exists, e.g. from app_main()).
|
||||
bool get_custom_mac_address(uint8_t *mac) {
|
||||
// has_custom_mac_address() checks the raw eFuse field, while the reads below select their
|
||||
// method differently and may still fail (CRC), so the result must be validated again.
|
||||
if (!has_custom_mac_address())
|
||||
return false;
|
||||
#if defined(CONFIG_SOC_IEEE802154_SUPPORTED)
|
||||
return read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS));
|
||||
#else
|
||||
return read_valid_mac(mac, esp_efuse_mac_get_custom(mac));
|
||||
#endif
|
||||
}
|
||||
|
||||
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
|
||||
if (get_custom_mac_address(mac)) {
|
||||
return;
|
||||
}
|
||||
#if defined(CONFIG_SOC_IEEE802154_SUPPORTED)
|
||||
// When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default
|
||||
// returns the 802.15.4 EUI-64 address, so we read directly from eFuse instead.
|
||||
// Both paths already read raw eFuse bytes, so there is no CRC-bypass fallback
|
||||
// This already reads raw eFuse bytes, so there is no CRC-bypass fallback
|
||||
// (unlike the non-IEEE802154 path where esp_efuse_mac_get_default does CRC checks).
|
||||
if (has_custom_mac_address() &&
|
||||
read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS))) {
|
||||
return;
|
||||
}
|
||||
if (read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_FACTORY, mac, MAC_ADDRESS_SIZE_BITS))) {
|
||||
return;
|
||||
}
|
||||
#else
|
||||
if (has_custom_mac_address() && read_valid_mac(mac, esp_efuse_mac_get_custom(mac))) {
|
||||
return;
|
||||
}
|
||||
if (read_valid_mac(mac, esp_efuse_mac_get_default(mac))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,33 @@ def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> P
|
||||
return components_dir
|
||||
|
||||
|
||||
def _log_overridden_components(
|
||||
conf: dict[str, Any], component_names: list[str]
|
||||
) -> None:
|
||||
overridden = [
|
||||
name
|
||||
for name in component_names
|
||||
if (loader.CORE_COMPONENTS_PATH / name / "__init__.py").is_file()
|
||||
]
|
||||
if not overridden:
|
||||
return
|
||||
if conf[CONF_TYPE] == TYPE_GIT:
|
||||
source = conf[CONF_URL]
|
||||
if ref := conf.get(CONF_REF):
|
||||
source = f"{source}@{ref}"
|
||||
if path := conf.get(CONF_PATH):
|
||||
source = f"{source} ({path})"
|
||||
else:
|
||||
source = conf[CONF_PATH]
|
||||
_LOGGER.info(
|
||||
"External components are overriding built-in components:\n"
|
||||
" source: %s\n"
|
||||
" components: %s",
|
||||
source,
|
||||
", ".join(sorted(overridden)),
|
||||
)
|
||||
|
||||
|
||||
def _process_single_config(config: dict[str, Any]) -> None:
|
||||
conf = config[CONF_SOURCE]
|
||||
if conf[CONF_TYPE] == TYPE_GIT:
|
||||
@@ -84,8 +111,8 @@ def _process_single_config(config: dict[str, Any]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
if config[CONF_COMPONENTS] == "all":
|
||||
num_components = len(list(components_dir.glob("*/__init__.py")))
|
||||
if num_components > 100:
|
||||
component_names = [p.parent.name for p in components_dir.glob("*/__init__.py")]
|
||||
if len(component_names) > 100:
|
||||
# Prevent accidentally including all components from an esphome fork/branch
|
||||
# In this case force the user to manually specify which components they want to include
|
||||
raise cv.Invalid(
|
||||
@@ -102,6 +129,9 @@ def _process_single_config(config: dict[str, Any]) -> None:
|
||||
[CONF_COMPONENTS, i],
|
||||
)
|
||||
allowed_components = config[CONF_COMPONENTS]
|
||||
component_names = allowed_components
|
||||
|
||||
_log_overridden_components(conf, component_names)
|
||||
|
||||
loader.install_meta_finder(components_dir, allowed_components=allowed_components)
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import ICON_AIR_FILTER
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
|
||||
|
||||
DEPENDENCIES = ["hoermann_hcp"]
|
||||
|
||||
CONF_HALF_OPEN = "half_open"
|
||||
CONF_VENT = "vent"
|
||||
|
||||
ICON_GARAGE_OPEN_VARIANT = "mdi:garage-open-variant"
|
||||
|
||||
HoermannHcpVentButton = hoermann_hcp_ns.class_("HoermannHcpVentButton", button.Button)
|
||||
HoermannHcpHalfOpenButton = hoermann_hcp_ns.class_(
|
||||
"HoermannHcpHalfOpenButton", button.Button
|
||||
)
|
||||
|
||||
BUTTON_KEYS = (CONF_VENT, CONF_HALF_OPEN)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp),
|
||||
cv.Optional(CONF_VENT): button.button_schema(
|
||||
HoermannHcpVentButton, icon=ICON_AIR_FILTER
|
||||
),
|
||||
cv.Optional(CONF_HALF_OPEN): button.button_schema(
|
||||
HoermannHcpHalfOpenButton, icon=ICON_GARAGE_OPEN_VARIANT
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(*BUTTON_KEYS),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
|
||||
for key in BUTTON_KEYS:
|
||||
if (conf := config.get(key)) is not None:
|
||||
await button.new_button(conf, parent)
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/button/button.h"
|
||||
#include "../hoermann_hcp.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
// The door commands the cover has no equivalent for. A refused command is already reported by the hub and
|
||||
// leaves nothing to correct here, because a button carries no state of its own.
|
||||
class HoermannHcpButton : public button::Button {
|
||||
public:
|
||||
explicit HoermannHcpButton(HoermannHcp *parent) : parent_(parent) {}
|
||||
|
||||
protected:
|
||||
HoermannHcp *const parent_;
|
||||
};
|
||||
|
||||
class HoermannHcpVentButton final : public HoermannHcpButton {
|
||||
public:
|
||||
using HoermannHcpButton::HoermannHcpButton;
|
||||
|
||||
protected:
|
||||
void press_action() override { this->parent_->vent_door(); }
|
||||
};
|
||||
|
||||
class HoermannHcpHalfOpenButton final : public HoermannHcpButton {
|
||||
public:
|
||||
using HoermannHcpButton::HoermannHcpButton;
|
||||
|
||||
protected:
|
||||
void press_action() override { this->parent_->half_open_door(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -22,6 +22,9 @@ static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4;
|
||||
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
|
||||
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
|
||||
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
|
||||
// The intermediate positions are named in the second register, so the first only carries the phase.
|
||||
static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0200, 0x0100, 0x4000, 0x4000};
|
||||
static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0200, 0x0100, 0x0400, 0x0400};
|
||||
// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share.
|
||||
static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false};
|
||||
|
||||
@@ -286,6 +289,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
|
||||
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
|
||||
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
|
||||
bool HoermannHcp::vent_door() { return this->queue_command_(COMMAND_VENT); }
|
||||
bool HoermannHcp::half_open_door() { return this->queue_command_(COMMAND_HALF_OPEN); }
|
||||
bool HoermannHcp::toggle_light() {
|
||||
if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) {
|
||||
ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one");
|
||||
|
||||
@@ -22,7 +22,8 @@ enum class DoorState : uint8_t {
|
||||
};
|
||||
|
||||
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
|
||||
// short delay the released value. Each half also carries a second register, which only the lamp command uses.
|
||||
// short delay the released value. Each half also carries a second register, which names the buttons that do
|
||||
// not fit into the first.
|
||||
struct HoermannHcpCommand {
|
||||
const char *name;
|
||||
uint16_t pressed_value;
|
||||
@@ -54,6 +55,9 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
bool open_door();
|
||||
bool close_door();
|
||||
bool impulse_door();
|
||||
// The door drives to these intermediate positions on its own, so neither takes a target to be stopped at.
|
||||
bool vent_door();
|
||||
bool half_open_door();
|
||||
bool stop_door();
|
||||
bool set_position(float position);
|
||||
bool toggle_light();
|
||||
|
||||
@@ -50,6 +50,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_ESPHOME_HOST_MAC_ADDRESS", config[CONF_MAC_ADDRESS].parts)
|
||||
cg.add_build_flag("-std=gnu++20")
|
||||
cg.add_define("ESPHOME_BOARD", "host")
|
||||
cg.add_define("ESPHOME_VARIANT", "HOST")
|
||||
cg.add_define(ThreadModel.MULTI_ATOMICS)
|
||||
cg.add_platformio_option("platform", "platformio/native")
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
|
||||
@@ -17,12 +17,14 @@ from esphome.const import (
|
||||
CONF_TIMEOUT,
|
||||
CONF_URL,
|
||||
CONF_WATCHDOG_TIMEOUT,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_HOST,
|
||||
PlatformFramework,
|
||||
__version__,
|
||||
)
|
||||
from esphome.core import CORE, ID, Lambda
|
||||
from esphome.core import CORE, ID, Lambda, TimePeriodMilliseconds
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import IS_MACOS
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -94,6 +96,34 @@ def validate_ssl_verification(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
# esp_http_client_open() runs DNS, TCP connect and the TLS handshake with no
|
||||
# watchdog feed in between; each can take up to `timeout` on ESP-IDF.
|
||||
WATCHDOG_TIMEOUT_MULTIPLIER = 3
|
||||
# Headroom over the exact worst case so a fully stalled open does not land on
|
||||
# the watchdog deadline.
|
||||
WATCHDOG_TIMEOUT_MARGIN_MS = 1000
|
||||
|
||||
|
||||
def default_watchdog_timeout(config: ConfigType) -> None:
|
||||
"""Arm the request watchdog on ESP32 when the user did not set it.
|
||||
|
||||
The default never goes below the platform task watchdog, so a user who
|
||||
widened `esp32.watchdog_timeout` keeps that window during requests.
|
||||
"""
|
||||
if not CORE.is_esp32 or CONF_WATCHDOG_TIMEOUT in config:
|
||||
return
|
||||
derived_ms = (
|
||||
config[CONF_TIMEOUT].total_milliseconds * WATCHDOG_TIMEOUT_MULTIPLIER
|
||||
+ WATCHDOG_TIMEOUT_MARGIN_MS
|
||||
)
|
||||
platform_ms = fv.full_config.get()[PLATFORM_ESP32][
|
||||
CONF_WATCHDOG_TIMEOUT
|
||||
].total_milliseconds
|
||||
config[CONF_WATCHDOG_TIMEOUT] = TimePeriodMilliseconds(
|
||||
milliseconds=max(derived_ms, platform_ms)
|
||||
)
|
||||
|
||||
|
||||
def _declare_request_class(value: Any) -> ID:
|
||||
if CORE.is_host:
|
||||
return cv.declare_id(HttpRequestHost)(value)
|
||||
@@ -153,6 +183,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_ssl_verification,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = default_watchdog_timeout
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -142,12 +142,13 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
|
||||
const char *buf = body.c_str();
|
||||
while (write_left > 0) {
|
||||
int written = esp_http_client_write(client, buf + write_index, write_left);
|
||||
if (written < 0) {
|
||||
if (written <= 0) {
|
||||
err = ESP_FAIL;
|
||||
break;
|
||||
}
|
||||
write_left -= written;
|
||||
write_index += written;
|
||||
container->feed_wdt();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,4 +43,4 @@ async def setup_improv_core(var: MockObj, config: ConfigType, component: str) ->
|
||||
cg.add(var.set_next_url(_process_next_url(next_url)))
|
||||
cg.add_define(f"USE_{component.upper()}_NEXT_URL")
|
||||
|
||||
cg.add_library("improv/Improv", "1.2.6")
|
||||
cg.add_library("improv/Improv", "1.2.7")
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import improv_base
|
||||
from esphome.components import improv_base, uart
|
||||
from esphome.components.esp32 import VARIANT_ESP32S3, get_esp32_variant
|
||||
from esphome.components.logger import USB_CDC
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BAUD_RATE, CONF_HARDWARE_UART, CONF_ID, CONF_LOGGER
|
||||
from esphome.const import (
|
||||
CONF_BAUD_RATE,
|
||||
CONF_HARDWARE_UART,
|
||||
CONF_ID,
|
||||
CONF_LOGGER,
|
||||
CONF_UART_ID,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
@@ -17,13 +23,35 @@ improv_serial_ns = cg.esphome_ns.namespace("improv_serial")
|
||||
ImprovSerialComponent = improv_serial_ns.class_("ImprovSerialComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema({cv.GenerateID(): cv.declare_id(ImprovSerialComponent)})
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ImprovSerialComponent),
|
||||
# YAML only: rewiring Improv onto another UART is not a knob for a
|
||||
# visual editor and the device builder must not expose it
|
||||
cv.Optional(CONF_UART_ID, visibility=cv.Visibility.YAML_ONLY): cv.use_id(
|
||||
uart.UARTComponent
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(improv_base.IMPROV_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
def validate_logger(config: ConfigType) -> None:
|
||||
_UART_FINAL_VALIDATE = uart.final_validate_device_schema(
|
||||
"improv_serial", require_tx=True, require_rx=True
|
||||
)
|
||||
|
||||
|
||||
def validate_transport(config: ConfigType) -> None:
|
||||
if CONF_UART_ID in config:
|
||||
# A dedicated UART bus is used; the logger's serial settings are irrelevant,
|
||||
# but the bus itself must be bidirectional and not claimed by another device
|
||||
_UART_FINAL_VALIDATE(config)
|
||||
return
|
||||
# The host logger has no serial port for Improv to share
|
||||
if CORE.is_host:
|
||||
raise cv.Invalid("improv_serial on the host platform requires uart_id")
|
||||
logger_conf = fv.full_config.get()[CONF_LOGGER]
|
||||
if logger_conf[CONF_BAUD_RATE] == 0:
|
||||
raise cv.Invalid("improv_serial requires the logger baud_rate to be not 0")
|
||||
@@ -36,7 +64,7 @@ def validate_logger(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_logger
|
||||
FINAL_VALIDATE_SCHEMA = validate_transport
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
@@ -44,3 +72,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await improv_base.setup_improv_core(var, config, "improv_serial")
|
||||
cg.add_define("USE_IMPROV_SERIAL")
|
||||
if (uart_id := config.get(CONF_UART_ID)) is not None:
|
||||
cg.add(var.set_uart(await cg.get_variable(uart_id)))
|
||||
cg.add_define("USE_IMPROV_SERIAL_UART")
|
||||
|
||||
@@ -15,7 +15,9 @@ static const char *const TAG = "improv_serial";
|
||||
|
||||
void ImprovSerialComponent::setup() {
|
||||
global_improv_serial_component = this;
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_IMPROV_SERIAL_UART
|
||||
// Transport is a dedicated UART bus set via set_uart() in generated code
|
||||
#elif defined(USE_ESP32)
|
||||
this->uart_num_ = logger::global_logger->get_uart_num();
|
||||
this->uart_selection_ = logger::global_logger->get_uart();
|
||||
#elif defined(USE_ARDUINO)
|
||||
@@ -89,7 +91,13 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size)
|
||||
}
|
||||
this->tx_header_[TX_CHECKSUM_IDX] = checksum;
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_IMPROV_SERIAL_UART
|
||||
this->uart_->write_array(this->tx_header_, header_tx_len);
|
||||
if (there_is_data) {
|
||||
this->uart_->write_array(data, size);
|
||||
this->uart_->write_array(&this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline
|
||||
}
|
||||
#elif defined(USE_ESP32)
|
||||
switch (this->uart_selection_) {
|
||||
case logger::UART_SELECTION_UART0:
|
||||
case logger::UART_SELECTION_UART1:
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
#include <improv.h>
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_IMPROV_SERIAL_UART
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
#elif defined(USE_ESP32)
|
||||
#include <driver/uart.h>
|
||||
#ifdef USE_LOGGER_USB_SERIAL_JTAG
|
||||
#include <driver/usb_serial_jtag.h>
|
||||
@@ -53,6 +55,10 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
|
||||
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
|
||||
|
||||
#ifdef USE_IMPROV_SERIAL_UART
|
||||
void set_uart(uart::UARTComponent *uart) { this->uart_ = uart; }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool parse_improv_serial_byte_(uint8_t byte);
|
||||
bool parse_improv_payload_(improv::ImprovCommand &command);
|
||||
@@ -69,7 +75,11 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
|
||||
ESPHOME_ALWAYS_INLINE optional<uint8_t> read_byte_() {
|
||||
optional<uint8_t> byte;
|
||||
uint8_t data = 0;
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_IMPROV_SERIAL_UART
|
||||
if (this->uart_->available() && this->uart_->read_byte(&data)) {
|
||||
byte = data;
|
||||
}
|
||||
#elif defined(USE_ESP32)
|
||||
switch (this->uart_selection_) {
|
||||
case logger::UART_SELECTION_UART0:
|
||||
case logger::UART_SELECTION_UART1:
|
||||
@@ -129,7 +139,9 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
|
||||
'\n',
|
||||
};
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_IMPROV_SERIAL_UART
|
||||
uart::UARTComponent *uart_{nullptr};
|
||||
#elif defined(USE_ESP32)
|
||||
uart_port_t uart_num_;
|
||||
logger::UARTSelection uart_selection_{logger::UART_SELECTION_UART0};
|
||||
#elif defined(USE_ARDUINO)
|
||||
|
||||
@@ -597,21 +597,21 @@ std::string LvSelectable::get_selected_text() {
|
||||
return this->options_[selected];
|
||||
}
|
||||
|
||||
static std::string join_string(std::vector<std::string> options) {
|
||||
static std::string join_string(const FixedVector<const char *> &options) {
|
||||
return std::accumulate(
|
||||
options.begin(), options.end(), std::string(),
|
||||
[](const std::string &a, const std::string &b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; });
|
||||
[](const std::string &a, const char *b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; });
|
||||
}
|
||||
|
||||
void LvSelectable::set_selected_text(const std::string &text, lv_anim_enable_t anim) {
|
||||
auto index = std::find(this->options_.begin(), this->options_.end(), text);
|
||||
auto *index = std::find(this->options_.begin(), this->options_.end(), text);
|
||||
if (index != this->options_.end()) {
|
||||
this->set_selected_index(index - this->options_.begin(), anim);
|
||||
lv_obj_send_event(this->obj, lv_update_event, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
void LvSelectable::set_options(std::vector<std::string> options) {
|
||||
void LvSelectable::set_options(FixedVector<const char *> options) {
|
||||
auto index = this->get_selected_index();
|
||||
if (index >= options.size())
|
||||
index = options.size() - 1;
|
||||
|
||||
@@ -543,12 +543,12 @@ class LvSelectable : public LvCompound {
|
||||
virtual void set_selected_index(size_t index, lv_anim_enable_t anim) = 0;
|
||||
void set_selected_text(const std::string &text, lv_anim_enable_t anim);
|
||||
std::string get_selected_text();
|
||||
const std::vector<std::string> &get_options() { return this->options_; }
|
||||
void set_options(std::vector<std::string> options);
|
||||
const FixedVector<const char *> &get_options() { return this->options_; }
|
||||
void set_options(FixedVector<const char *> options);
|
||||
|
||||
protected:
|
||||
virtual void set_option_string(const char *options) = 0;
|
||||
std::vector<std::string> options_{};
|
||||
FixedVector<const char *> options_{};
|
||||
};
|
||||
|
||||
#ifdef USE_LVGL_DROPDOWN
|
||||
|
||||
@@ -50,19 +50,10 @@ class LVGLSelect final : public select::Select, public Component {
|
||||
protected:
|
||||
void control(size_t index) override {
|
||||
this->widget_->set_selected_index(index, this->anim_);
|
||||
this->publish();
|
||||
}
|
||||
void set_options_() {
|
||||
// Widget uses std::vector<std::string>, SelectTraits uses FixedVector<const char*>
|
||||
// Convert by extracting c_str() pointers
|
||||
const auto &opts = this->widget_->get_options();
|
||||
FixedVector<const char *> opt_ptrs;
|
||||
opt_ptrs.init(opts.size());
|
||||
for (const auto &opt : opts) {
|
||||
opt_ptrs.push_back(opt.c_str());
|
||||
}
|
||||
this->traits.set_options(opt_ptrs);
|
||||
// The update event fires the widget's on_value/on_update triggers
|
||||
lv_obj_send_event(this->widget_->obj, lv_update_event, nullptr);
|
||||
}
|
||||
void set_options_() { this->traits.set_options(this->widget_->get_options()); }
|
||||
|
||||
LvSelectable *widget_;
|
||||
lv_anim_enable_t anim_;
|
||||
|
||||
@@ -3,6 +3,8 @@ from esphome.const import CONF_TEXT, CONF_VALUE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_types import Component, esphome_ns
|
||||
|
||||
from .defines import CONF_SELECTED_INDEX
|
||||
|
||||
|
||||
class LvType(cg.MockObjClass):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -112,3 +114,4 @@ class LvSelect(LvType):
|
||||
parents=parens,
|
||||
**kwargs,
|
||||
)
|
||||
self.value_property = CONF_SELECTED_INDEX
|
||||
|
||||
@@ -41,7 +41,19 @@ static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SER
|
||||
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
|
||||
void MDNSComponent::start_polling_window_() {
|
||||
// uint32_t-ID set_interval/set_timeout already does atomic cancel-and-add.
|
||||
this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); });
|
||||
this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() {
|
||||
#ifdef USE_MDNS_WIFI_LISTENER
|
||||
// MDNS.update() can suspend the loop in UdpContext::sendTimeout() while a send is
|
||||
// failing (radio off-channel during a roam scan, or mid reconnect); an incoming
|
||||
// packet then re-enters LEAmDNS from lwIP and corrupts shared UdpContext state.
|
||||
// Skip the tick while the radio cannot transmit (#18760), but keep polling while
|
||||
// the AP is serving clients (AP-only or fallback AP with the STA down).
|
||||
auto *wifi = wifi::global_wifi_component;
|
||||
if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active()))
|
||||
return;
|
||||
#endif
|
||||
MDNS.update();
|
||||
});
|
||||
this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); });
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -266,8 +266,6 @@ DriverChip(
|
||||
"JC3636W518V2",
|
||||
height=360,
|
||||
width=360,
|
||||
offset_height=1,
|
||||
draw_rounding=1,
|
||||
cs_pin=10,
|
||||
reset_pin=47,
|
||||
invert_colors=True,
|
||||
|
||||
@@ -3,6 +3,9 @@ import esphome.codegen as cg
|
||||
modbus_ns = cg.esphome_ns.namespace("modbus")
|
||||
modbus_helpers_ns = modbus_ns.namespace("helpers")
|
||||
|
||||
RegisterValues = modbus_ns.class_("RegisterValues")
|
||||
PduBuffer = modbus_helpers_ns.class_("PduBuffer")
|
||||
|
||||
FunctionCode_ns = modbus_ns.namespace("FunctionCode")
|
||||
FunctionCode = FunctionCode_ns.enum("FunctionCode")
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ ModbusItemBaseSchema = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def validate_modbus_register(config):
|
||||
def validate_modbus_register(config: ConfigType) -> ConfigType:
|
||||
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate); treat
|
||||
# either as "a custom frame is configured" so the address/register_type rules match.
|
||||
has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config
|
||||
@@ -278,7 +278,7 @@ def _final_validate(config: ConfigType) -> None:
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def modbus_calc_properties(config):
|
||||
def modbus_calc_properties(config: ConfigType) -> tuple[int, int]:
|
||||
byte_offset = 0
|
||||
reg_count = 0
|
||||
if CONF_OFFSET in config:
|
||||
@@ -307,8 +307,12 @@ def modbus_calc_properties(config):
|
||||
|
||||
|
||||
async def add_modbus_base_properties(
|
||||
var, config, sensor_type, lambda_param_type=cg.float_, lambda_return_type=float
|
||||
):
|
||||
var: cg.MockObj,
|
||||
config: ConfigType,
|
||||
sensor_type: cg.MockObjClass,
|
||||
lambda_param_type: cg.MockObj = cg.float_,
|
||||
lambda_return_type: Any = float,
|
||||
) -> None:
|
||||
if CONF_CUSTOM_PDU in config:
|
||||
cg.add(var.set_custom_pdu(config[CONF_CUSTOM_PDU]))
|
||||
|
||||
@@ -347,8 +351,11 @@ _CALLBACK_AUTOMATIONS = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# Await the hub first, so no entity can bind to a controller that doesn't have one yet.
|
||||
hub = await cg.get_variable(config[modbus.CONF_MODBUS_ID])
|
||||
var = cg.new_Pvariable(config[CONF_ID], hub, config[CONF_ADDRESS])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
|
||||
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
|
||||
cg.add(
|
||||
@@ -356,17 +363,22 @@ async def to_code(config):
|
||||
modbus.command_options_expression(config, direction="read")
|
||||
)
|
||||
)
|
||||
await register_modbus_device(var, config)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
async def register_modbus_device(var, config):
|
||||
async def register_modbus_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
|
||||
# Remove before 2027.3.0
|
||||
_LOGGER.warning(
|
||||
"'modbus_controller.register_modbus_device' is deprecated, use "
|
||||
"'modbus.register_modbus_client_device' and set the address on your own "
|
||||
"class instead. Will be removed in 2027.3.0"
|
||||
)
|
||||
cg.add(var.set_address(config[CONF_ADDRESS]))
|
||||
await cg.register_component(var, config)
|
||||
return await modbus.register_modbus_client_device(var, config)
|
||||
|
||||
|
||||
def function_code_to_register(function_code):
|
||||
def function_code_to_register(function_code: str) -> cg.MockObj:
|
||||
FUNCTION_CODE_TYPE_MAP = {
|
||||
"read_coils": EntityType.COIL,
|
||||
"read_discrete_inputs": EntityType.DISCRETE_INPUT,
|
||||
|
||||
@@ -10,6 +10,73 @@ static const char *const TAG = "modbus_controller";
|
||||
|
||||
void ModbusController::setup() { this->create_polling_commands_(); }
|
||||
|
||||
void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint16_t address) {
|
||||
if (this->write_buffer_deprecated_warned_)
|
||||
return;
|
||||
this->write_buffer_deprecated_warned_ = true;
|
||||
ESP_LOGW(TAG,
|
||||
"Modbus %s (address 0x%X): filling the write_lambda buffer parameter is deprecated; call a write helper / "
|
||||
"queue_pdu() on the entity (item) instead. The buffer parameter is removed in 2027.3.0",
|
||||
LOG_STR_ARG(platform), address);
|
||||
}
|
||||
|
||||
bool WriterDevice::send_raw_frame_deprecated(std::span<const uint8_t> frame) {
|
||||
if (frame.empty())
|
||||
return false;
|
||||
this->dispatched_ = true;
|
||||
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this);
|
||||
}
|
||||
|
||||
void WriterDevice::set_controller(ModbusController *controller) {
|
||||
this->controller_ = controller;
|
||||
this->set_parent(controller->hub());
|
||||
this->set_address(controller->device_address());
|
||||
}
|
||||
|
||||
void WriterDevice::notify_online_(std::span<const uint8_t> request_pdu) {
|
||||
if (this->controller_ != nullptr)
|
||||
this->controller_->set_online(true, fc_of(request_pdu), addr_of(request_pdu));
|
||||
}
|
||||
|
||||
void WriterDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
|
||||
this->notify_online_(request_pdu);
|
||||
this->dispatch_response_(request_pdu, response_pdu, std::nullopt);
|
||||
}
|
||||
|
||||
void WriterDevice::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
|
||||
ESP_LOGW(TAG, "Modbus error function code: 0x%X register 0x%X exception: %d", fc_of(request_pdu),
|
||||
addr_of(request_pdu), static_cast<uint8_t>(exception_code));
|
||||
this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online
|
||||
this->dispatch_response_(request_pdu, {}, exception_code);
|
||||
}
|
||||
|
||||
// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent trigger
|
||||
// reflects when the frame actually went out, not when it was queued.
|
||||
void WriterDevice::on_sent(std::span<const uint8_t> request_pdu) {
|
||||
if (this->controller_ != nullptr)
|
||||
this->controller_->command_sent(fc_of(request_pdu), addr_of(request_pdu));
|
||||
}
|
||||
|
||||
void WriterDevice::on_not_sent(std::span<const uint8_t> request_pdu) {
|
||||
// Only the offline teardown reaches this (a supersede retires silently), so the frame is genuinely
|
||||
// lost; a dropped write was already published optimistically, so surface it.
|
||||
if (modbus::helpers::is_function_code_write(fc_of(request_pdu))) {
|
||||
ESP_LOGW(TAG, "Write not sent: function 0x%X register 0x%X", fc_of(request_pdu), addr_of(request_pdu));
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Request not sent: function 0x%X register 0x%X", fc_of(request_pdu), addr_of(request_pdu));
|
||||
}
|
||||
}
|
||||
|
||||
bool WriterDevice::on_no_response(std::span<const uint8_t> request_pdu) {
|
||||
if (this->controller_ == nullptr)
|
||||
return false;
|
||||
this->controller_->increment_non_response_count();
|
||||
if (this->controller_->can_send())
|
||||
return true; // the hub re-queues the frame it is holding; on_sent fires again on the retry
|
||||
this->controller_->set_online(false, fc_of(request_pdu), addr_of(request_pdu));
|
||||
return false;
|
||||
}
|
||||
|
||||
ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
|
||||
RegisterRange &&range)
|
||||
: modbus::ModbusClientDevice(parent, address),
|
||||
|
||||
@@ -232,6 +232,115 @@ struct RegisterRange {
|
||||
SensorSet sensors; // all sensors of this range
|
||||
};
|
||||
|
||||
/// A hub device owned by a writer entity (switch/number/select/output) through WriterEntity.
|
||||
/// Centralises the feedback to the controller - online/offline tracking, retry counting and the
|
||||
/// on_command_sent trigger - and records every dispatch, so a write lambda can tell "I sent it myself"
|
||||
/// from "use the default write". The hub base is inherited protected, so the public members below are
|
||||
/// the entity's whole request API and nothing can bypass the recording or re-target the device.
|
||||
class WriterDevice final : protected modbus::ModbusClientDevice {
|
||||
protected:
|
||||
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
|
||||
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override;
|
||||
void on_sent(std::span<const uint8_t> request_pdu) override;
|
||||
void on_not_sent(std::span<const uint8_t> request_pdu) override;
|
||||
bool on_no_response(std::span<const uint8_t> request_pdu) override;
|
||||
|
||||
void notify_online_(std::span<const uint8_t> request_pdu);
|
||||
/// Function code / register address decoded from a request PDU ([fc, addr_hi, addr_lo, ...]).
|
||||
static int fc_of(std::span<const uint8_t> pdu) { return pdu.empty() ? 0 : (pdu[0] & modbus::FUNCTION_CODE_MASK); }
|
||||
static int addr_of(std::span<const uint8_t> pdu) {
|
||||
return pdu.size() >= 3 ? modbus::helpers::get_data<uint16_t>(pdu.data(), 1) : 0;
|
||||
}
|
||||
|
||||
/// Declared before controller_ so they land in the padding after ModbusClientDevice::custom_response_warned_
|
||||
/// instead of adding a word to every entity that owns a device.
|
||||
/// dispatched_: a frame was queued since the last clear_dispatched_().
|
||||
/// write_buffer_deprecated_warned_: warn-once for the legacy write_lambda buffer parameter.
|
||||
bool dispatched_{false};
|
||||
bool write_buffer_deprecated_warned_{false};
|
||||
ModbusController *controller_{nullptr};
|
||||
|
||||
public:
|
||||
/// Whether a frame was queued to the hub since the last clear_dispatched_().
|
||||
bool dispatched() const { return this->dispatched_; }
|
||||
|
||||
bool write_single_register(uint16_t address, uint16_t value) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::write_single_register(address, value);
|
||||
}
|
||||
bool write_single_coil(uint16_t address, bool value) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::write_single_coil(address, value);
|
||||
}
|
||||
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::write_multiple_registers(address, values);
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::write_multiple_coils(address, values);
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::write_multiple_coils(address, bits);
|
||||
}
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::queue_pdu(pdu, options);
|
||||
}
|
||||
/// Send a legacy raw frame (address + function code + data) to the frame's own address.
|
||||
/// Serves only the deprecated write_lambda buffer path. Remove before 2027.3.0.
|
||||
bool send_raw_frame_deprecated(std::span<const uint8_t> frame);
|
||||
|
||||
void clear_tx_queue_for_device() { modbus::ModbusClientDevice::clear_tx_queue_for_device(); }
|
||||
|
||||
// Entity plumbing, public because the owning WriterEntity holds the only reachable instance (device_ is
|
||||
// protected there and the hub sees just the masked base) - reachability is the access gate, not a friend.
|
||||
void set_controller(ModbusController *controller);
|
||||
void clear_dispatched() { this->dispatched_ = false; }
|
||||
/// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the
|
||||
/// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0.
|
||||
void warn_write_buffer_deprecated(const LogString *platform, uint16_t address);
|
||||
};
|
||||
|
||||
/// Gives a writer entity the write API of the WriterDevice it owns. The device is a member, not a base:
|
||||
/// the mixin declares no virtual function, so an entity mixing it in gains no second vtable and all the
|
||||
/// writer platforms share the single WriterDevice vtable instead of each emitting its own copy.
|
||||
/// The forwarders keep `item->write_*()` working unchanged inside a write_lambda.
|
||||
class WriterEntity {
|
||||
public:
|
||||
bool dispatched() const { return this->device_.dispatched(); }
|
||||
bool write_single_register(uint16_t address, uint16_t value) {
|
||||
return this->device_.write_single_register(address, value);
|
||||
}
|
||||
bool write_single_coil(uint16_t address, bool value) { return this->device_.write_single_coil(address, value); }
|
||||
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
|
||||
return this->device_.write_multiple_registers(address, values);
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
|
||||
return this->device_.write_multiple_coils(address, values);
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
|
||||
return this->device_.write_multiple_coils(address, bits);
|
||||
}
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
|
||||
return this->device_.queue_pdu(pdu, options);
|
||||
}
|
||||
void clear_tx_queue_for_device() { this->device_.clear_tx_queue_for_device(); }
|
||||
|
||||
protected:
|
||||
bool send_raw_frame_deprecated_(std::span<const uint8_t> frame) {
|
||||
return this->device_.send_raw_frame_deprecated(frame);
|
||||
}
|
||||
void set_controller_(ModbusController *controller) { this->device_.set_controller(controller); }
|
||||
void clear_dispatched_() { this->device_.clear_dispatched(); }
|
||||
void warn_write_buffer_deprecated_(const LogString *platform, uint16_t address) {
|
||||
this->device_.warn_write_buffer_deprecated(platform, address);
|
||||
}
|
||||
|
||||
WriterDevice device_;
|
||||
};
|
||||
|
||||
/// A single modbus command. Each command is its own ModbusClientDevice: it sends its frame to the hub
|
||||
/// and the hub routes the response back to this object's on_modbus_* callbacks, so the controller no
|
||||
/// longer has to match responses to a FIFO queue.
|
||||
@@ -398,17 +507,16 @@ inline bool offline_retry_due(uint16_t update_counter, uint16_t module_offline_a
|
||||
|
||||
class ModbusController final : public PollingComponent {
|
||||
public:
|
||||
// The controller is not itself a modbus device - its commands and writer entities send as their own
|
||||
// devices, built against this hub + address.
|
||||
ModbusController(modbus::ModbusClientHub *hub, uint8_t address) : hub_(hub), address_(address) {}
|
||||
|
||||
void dump_config() override;
|
||||
// No loop() override: the hub owns transmit/receive timing and each command routes its own
|
||||
// response, so the controller never joins the looping components at all.
|
||||
void setup() override;
|
||||
void update() override;
|
||||
|
||||
// The controller is not itself a modbus device - its commands and writer entities send as their own
|
||||
// devices. It only owns the hub + address so those senders can be built against them.
|
||||
void set_parent(modbus::ModbusClientHub *hub) { this->hub_ = hub; }
|
||||
void set_address(uint8_t address) { this->address_ = address; }
|
||||
|
||||
/// The hub and modbus address this controller talks to. Used to build commands/entities that send as
|
||||
/// their own device.
|
||||
modbus::ModbusClientHub *hub() const { return this->hub_; }
|
||||
|
||||
@@ -3,6 +3,7 @@ from esphome.components import number
|
||||
from esphome.components.modbus.helpers import (
|
||||
MODBUS_WRITE_REGISTER_TYPE,
|
||||
SENSOR_VALUE_TYPE,
|
||||
RegisterValues,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -13,6 +14,7 @@ from esphome.const import (
|
||||
CONF_MULTIPLY,
|
||||
CONF_STEP,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
@@ -43,7 +45,7 @@ ModbusNumber = modbus_controller_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
def validate_min_max(config):
|
||||
def validate_min_max(config: ConfigType) -> ConfigType:
|
||||
if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]:
|
||||
raise cv.Invalid("max_value must be greater than min_value")
|
||||
if config[CONF_MIN_VALUE] < -16777215:
|
||||
@@ -53,7 +55,7 @@ def validate_min_max(config):
|
||||
return config
|
||||
|
||||
|
||||
def validate_modbus_number(config):
|
||||
def validate_modbus_number(config: ConfigType) -> ConfigType:
|
||||
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate).
|
||||
has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config
|
||||
if not has_custom and CONF_ADDRESS not in config:
|
||||
@@ -89,7 +91,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
byte_offset, reg_count = modbus_calc_properties(config)
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
@@ -124,7 +126,7 @@ async def to_code(config):
|
||||
[
|
||||
(ModbusNumber.operator("ptr"), "item"),
|
||||
(cg.float_, "x"),
|
||||
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
|
||||
(RegisterValues.operator("ref"), "payload"),
|
||||
],
|
||||
return_type=cg.optional.template(float),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#include <vector>
|
||||
#include "modbus_number.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -29,62 +28,73 @@ void ModbusNumber::parse_and_publish(std::span<const uint8_t> data) {
|
||||
}
|
||||
|
||||
void ModbusNumber::control(float value) {
|
||||
optional<ModbusCommandItem> write_cmd;
|
||||
std::vector<uint16_t> data;
|
||||
this->clear_dispatched_();
|
||||
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::RegisterValues data;
|
||||
float write_value = value;
|
||||
// Is there are lambda configured?
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// data is passed by reference
|
||||
// the lambda can fill the empty vector directly
|
||||
// in that case the return value is ignored
|
||||
// The lambda may drive the write itself via item->write_*(), override the value (return a value), or
|
||||
// (deprecated) fill `data` with the register words to write.
|
||||
auto val = (*this->write_transform_func_)(this, value, data);
|
||||
if (val.has_value()) {
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
write_value = val.value();
|
||||
} else {
|
||||
if (this->dispatched()) {
|
||||
this->publish_state(value);
|
||||
return;
|
||||
}
|
||||
if (!data.empty()) {
|
||||
// Deprecated buffer path (frozen): the lambda filled a legacy raw frame as words; pack it big-endian.
|
||||
this->warn_write_buffer_deprecated_(LOG_STR("number"), this->start_address);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_uint16_size(MODBUS_NUMBER_MAX_LOG_REGISTERS)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus Number write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
// Sized to hold RegisterValues at capacity, so a full buffer can never truncate into a valid frame.
|
||||
StaticVector<uint8_t, modbus::MAX_NUM_OF_REGISTERS_TO_READ * 2> bytes;
|
||||
for (uint16_t word : data) {
|
||||
const auto word_bytes = decode_value(word);
|
||||
bytes.push_back(word_bytes[0]);
|
||||
bytes.push_back(word_bytes[1]);
|
||||
}
|
||||
if (!this->send_raw_frame_deprecated_(std::span<const uint8_t>(bytes.data(), bytes.size()))) {
|
||||
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
this->publish_state(value);
|
||||
return;
|
||||
}
|
||||
if (!val.has_value()) {
|
||||
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
write_value = val.value();
|
||||
} else {
|
||||
write_value = this->multiply_by_ * write_value;
|
||||
}
|
||||
|
||||
if (!data.empty()) {
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_uint16_size(MODBUS_NUMBER_MAX_LOG_REGISTERS)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus Number write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
write_cmd.emplace(ModbusCommandItem::create_custom_command(
|
||||
this->parent_, data,
|
||||
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
|
||||
this->parent_->on_write_register_response(register_type, this->start_address, data);
|
||||
}));
|
||||
} else {
|
||||
std::vector<uint16_t> payload;
|
||||
modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type);
|
||||
modbus::helpers::float_to_payload(data, write_value, this->sensor_value_type);
|
||||
// float_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0] below.
|
||||
if (data.empty()) {
|
||||
ESP_LOGW(TAG, "No payload was created for updating number");
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG,
|
||||
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
|
||||
this->get_name().c_str(), this->start_address, this->register_count, value, write_value);
|
||||
ESP_LOGD(TAG,
|
||||
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
|
||||
this->get_name().c_str(), this->start_address, this->register_count, value, write_value);
|
||||
|
||||
// Create and send the write command
|
||||
if (this->register_count == 1 && !this->use_write_multiple_) {
|
||||
write_cmd.emplace(
|
||||
ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0]));
|
||||
} else {
|
||||
write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(),
|
||||
this->register_count, payload));
|
||||
}
|
||||
// publish new value
|
||||
write_cmd->on_data_func = [this, value](modbus::EntityType register_type, uint16_t start_address,
|
||||
std::span<const uint8_t> data) {
|
||||
// gets called when the write command is ack'd from the device
|
||||
this->parent_->on_write_register_response(register_type, start_address, data);
|
||||
this->publish_state(value);
|
||||
};
|
||||
bool queued;
|
||||
if (this->register_count == 1 && !this->use_write_multiple_) {
|
||||
queued = this->write_single_register(this->write_address(), data[0]);
|
||||
} else {
|
||||
queued = this->write_multiple_registers(this->write_address(), data);
|
||||
}
|
||||
if (!queued) {
|
||||
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
this->parent_->queue_command(std::move(*write_cmd));
|
||||
this->publish_state(value);
|
||||
}
|
||||
void ModbusNumber::dump_config() { LOG_NUMBER(TAG, "Modbus Number", this); }
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace esphome::modbus_controller {
|
||||
|
||||
using value_to_data_t = std::function<float>(float);
|
||||
|
||||
class ModbusNumber final : public number::Number, public Component, public SensorItem {
|
||||
class ModbusNumber final : public number::Number, public Component, public SensorItem, public WriterEntity {
|
||||
public:
|
||||
ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
SensorValueType value_type, int register_count, bool force_new_range) {
|
||||
@@ -26,11 +26,11 @@ class ModbusNumber final : public number::Number, public Component, public Senso
|
||||
void dump_config() override;
|
||||
void parse_and_publish(std::span<const uint8_t> data) override;
|
||||
float get_setup_priority() const override { return setup_priority::HARDWARE; }
|
||||
void set_parent(ModbusController *parent) { this->parent_ = parent; }
|
||||
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
|
||||
void set_write_multiply(float factor) { this->multiply_by_ = factor; }
|
||||
|
||||
using transform_func_t = optional<float> (*)(ModbusNumber *, float, std::span<const uint8_t>);
|
||||
using write_transform_func_t = optional<float> (*)(ModbusNumber *, float, std::vector<uint16_t> &);
|
||||
using write_transform_func_t = optional<float> (*)(ModbusNumber *, float, modbus::RegisterValues &);
|
||||
void set_template(transform_func_t f) { this->transform_func_ = f; }
|
||||
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
|
||||
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
|
||||
@@ -39,7 +39,6 @@ class ModbusNumber final : public number::Number, public Component, public Senso
|
||||
void control(float value) override;
|
||||
optional<transform_func_t> transform_func_{nullopt};
|
||||
optional<write_transform_func_t> write_transform_func_{nullopt};
|
||||
ModbusController *parent_{nullptr};
|
||||
float multiply_by_{1.0};
|
||||
bool use_write_multiple_{false};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import output
|
||||
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE
|
||||
from esphome.components.modbus.helpers import (
|
||||
SENSOR_VALUE_TYPE,
|
||||
PduBuffer,
|
||||
RegisterValues,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_MULTIPLY
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
@@ -73,7 +78,7 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
byte_offset, reg_count = modbus_calc_properties(config)
|
||||
# Binary Output
|
||||
write_template = None
|
||||
@@ -89,7 +94,7 @@ async def to_code(config):
|
||||
[
|
||||
(ModbusBinaryOutput.operator("ptr"), "item"),
|
||||
(cg.bool_, "x"),
|
||||
(cg.std_vector.template(cg.uint8).operator("ref"), "payload"),
|
||||
(PduBuffer.operator("ref"), "payload"),
|
||||
],
|
||||
return_type=cg.optional.template(bool),
|
||||
)
|
||||
@@ -109,7 +114,7 @@ async def to_code(config):
|
||||
[
|
||||
(ModbusFloatOutput.operator("ptr"), "item"),
|
||||
(cg.float_, "x"),
|
||||
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
|
||||
(RegisterValues.operator("ref"), "payload"),
|
||||
],
|
||||
return_type=cg.optional.template(float),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::modbus_controller {
|
||||
|
||||
static const char *const TAG = "modbus_controller.output";
|
||||
@@ -13,25 +15,33 @@ static constexpr size_t MODBUS_OUTPUT_MAX_LOG_BYTES = 64;
|
||||
*
|
||||
*/
|
||||
void ModbusFloatOutput::write_state(float value) {
|
||||
std::vector<uint16_t> data;
|
||||
this->clear_dispatched_();
|
||||
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::RegisterValues data;
|
||||
auto original_value = value;
|
||||
// Is there are lambda configured?
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// data is passed by reference
|
||||
// the lambda can fill the empty vector directly
|
||||
// in that case the return value is ignored
|
||||
// The lambda may drive the write itself via item->write_*(), override the value (return a value), or
|
||||
// (deprecated) fill `data` with the register words to write.
|
||||
auto val = (*this->write_transform_func_)(this, value, data);
|
||||
if (val.has_value()) {
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
value = val.value();
|
||||
} else {
|
||||
if (this->dispatched()) {
|
||||
return;
|
||||
}
|
||||
if (!data.empty()) {
|
||||
// Deprecated buffer path (frozen): the lambda supplied the register words for the shared write below.
|
||||
this->warn_write_buffer_deprecated_(LOG_STR("float output"), this->start_address);
|
||||
} else if (!val.has_value()) {
|
||||
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
|
||||
return;
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
value = val.value();
|
||||
}
|
||||
} else {
|
||||
value = this->multiply_by_ * value;
|
||||
}
|
||||
// lambda didn't set payload
|
||||
|
||||
if (data.empty()) {
|
||||
modbus::helpers::float_to_payload(data, value, this->sensor_value_type);
|
||||
}
|
||||
@@ -57,16 +67,15 @@ void ModbusFloatOutput::write_state(float value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and send the write command
|
||||
optional<ModbusCommandItem> write_cmd;
|
||||
bool queued;
|
||||
if (this->register_count == 1 && !this->use_write_multiple_) {
|
||||
write_cmd.emplace(
|
||||
ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]));
|
||||
queued = this->write_single_register(this->write_address(), data[0]);
|
||||
} else {
|
||||
write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(
|
||||
this->parent_, this->start_address + this->offset, data.size(), data));
|
||||
queued = this->write_multiple_registers(this->write_address(), data);
|
||||
}
|
||||
if (!queued) {
|
||||
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
|
||||
}
|
||||
this->parent_->queue_command(std::move(*write_cmd));
|
||||
}
|
||||
|
||||
void ModbusFloatOutput::dump_config() {
|
||||
@@ -81,50 +90,52 @@ void ModbusFloatOutput::dump_config() {
|
||||
|
||||
// ModbusBinaryOutput
|
||||
void ModbusBinaryOutput::write_state(bool state) {
|
||||
// This will be called every time the user requests a state change.
|
||||
optional<ModbusCommandItem> cmd;
|
||||
std::vector<uint8_t> data;
|
||||
this->clear_dispatched_();
|
||||
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::helpers::PduBuffer data;
|
||||
|
||||
// Is there are lambda configured?
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// data is passed by reference
|
||||
// the lambda can fill the empty vector directly
|
||||
// in that case the return value is ignored
|
||||
// The lambda may drive the write itself via item->write_*/queue_pdu(), override the value (return a value),
|
||||
// or (deprecated) fill `data` with a custom PDU.
|
||||
auto val = (*this->write_transform_func_)(this, state, data);
|
||||
if (val.has_value()) {
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
state = val.value();
|
||||
} else {
|
||||
if (this->dispatched()) {
|
||||
return;
|
||||
}
|
||||
if (!data.empty()) {
|
||||
this->warn_write_buffer_deprecated_(LOG_STR("binary output"), this->start_address);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus binary output write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
// The lambda filled a legacy raw frame (device address + function code + data).
|
||||
if (!this->send_raw_frame_deprecated_(data)) {
|
||||
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!val.has_value()) {
|
||||
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
state = val.value();
|
||||
}
|
||||
if (!data.empty()) {
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus binary output write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
cmd.emplace(ModbusCommandItem::create_custom_command(
|
||||
this->parent_, data,
|
||||
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
|
||||
this->parent_->on_write_register_response(register_type, this->start_address, data);
|
||||
}));
|
||||
ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state),
|
||||
(int) this->register_type, this->start_address, this->offset);
|
||||
// offset for coil and discrete inputs is the coil/register number not bytes
|
||||
bool queued;
|
||||
if (this->use_write_multiple_) {
|
||||
std::array<bool, 1> states{state};
|
||||
queued = this->write_multiple_coils(this->write_address(), states);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state),
|
||||
(int) this->register_type, this->start_address, this->offset);
|
||||
|
||||
// offset for coil and discrete inputs is the coil/register number not bytes
|
||||
if (this->use_write_multiple_) {
|
||||
std::vector<bool> states{state};
|
||||
cmd.emplace(
|
||||
ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states));
|
||||
} else {
|
||||
cmd.emplace(
|
||||
ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state));
|
||||
}
|
||||
queued = this->write_single_coil(this->write_address(), state);
|
||||
}
|
||||
if (!queued) {
|
||||
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
|
||||
}
|
||||
this->parent_->queue_command(std::move(*cmd));
|
||||
}
|
||||
|
||||
void ModbusBinaryOutput::dump_config() {
|
||||
|
||||
@@ -8,26 +8,24 @@
|
||||
|
||||
namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem {
|
||||
class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem, public WriterEntity {
|
||||
public:
|
||||
ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) {
|
||||
this->register_type = modbus::EntityType::HOLDING;
|
||||
this->set_address(start_address);
|
||||
this->set_offset_from_start_address(offset);
|
||||
this->set_address(start_address + offset);
|
||||
this->set_offset_from_start_address(0);
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
this->register_count = register_count;
|
||||
this->sensor_value_type = value_type;
|
||||
this->set_address(this->start_address + offset);
|
||||
this->set_offset_from_start_address(0);
|
||||
}
|
||||
void dump_config() override;
|
||||
|
||||
void set_parent(ModbusController *parent) { this->parent_ = parent; }
|
||||
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
|
||||
void set_write_multiply(float factor) { this->multiply_by_ = factor; }
|
||||
// Do nothing
|
||||
void parse_and_publish(std::span<const uint8_t> data) override{};
|
||||
|
||||
using write_transform_func_t = optional<float> (*)(ModbusFloatOutput *, float, std::vector<uint16_t> &);
|
||||
using write_transform_func_t = optional<float> (*)(ModbusFloatOutput *, float, modbus::RegisterValues &);
|
||||
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
|
||||
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
|
||||
|
||||
@@ -35,29 +33,28 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu
|
||||
void write_state(float value) override;
|
||||
optional<write_transform_func_t> write_transform_func_{nullopt};
|
||||
|
||||
ModbusController *parent_{nullptr};
|
||||
float multiply_by_{1.0};
|
||||
bool use_write_multiple_{false};
|
||||
};
|
||||
|
||||
class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem {
|
||||
class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem, public WriterEntity {
|
||||
public:
|
||||
ModbusBinaryOutput(uint16_t start_address, uint8_t offset) {
|
||||
this->register_type = modbus::EntityType::COIL;
|
||||
this->set_address(start_address);
|
||||
// A coil offset is a coil count; fold it into the address.
|
||||
this->set_address(start_address + offset);
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
this->sensor_value_type = SensorValueType::BIT;
|
||||
this->register_count = 1;
|
||||
this->set_address(this->start_address + offset);
|
||||
this->set_offset_from_start_address(0);
|
||||
}
|
||||
void dump_config() override;
|
||||
|
||||
void set_parent(ModbusController *parent) { this->parent_ = parent; }
|
||||
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
|
||||
// Do nothing
|
||||
void parse_and_publish(std::span<const uint8_t> data) override{};
|
||||
|
||||
using write_transform_func_t = optional<bool> (*)(ModbusBinaryOutput *, bool, std::vector<uint8_t> &);
|
||||
using write_transform_func_t = optional<bool> (*)(ModbusBinaryOutput *, bool, modbus::helpers::PduBuffer &);
|
||||
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
|
||||
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
|
||||
|
||||
@@ -65,7 +62,6 @@ class ModbusBinaryOutput final : public output::BinaryOutput, public Component,
|
||||
void write_state(bool state) override;
|
||||
optional<write_transform_func_t> write_transform_func_{nullopt};
|
||||
|
||||
ModbusController *parent_{nullptr};
|
||||
bool use_write_multiple_{false};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, TYPE_REGISTER_MAP
|
||||
from esphome.components.modbus.helpers import (
|
||||
SENSOR_VALUE_TYPE,
|
||||
TYPE_REGISTER_MAP,
|
||||
RegisterValues,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
ModbusController,
|
||||
@@ -29,8 +37,8 @@ ModbusSelect = modbus_controller_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
def ensure_option_map():
|
||||
def validator(value):
|
||||
def ensure_option_map() -> Callable[[Any], dict[str, int]]:
|
||||
def validator(value: Any) -> dict[str, int]:
|
||||
cv.check_not_templatable(value)
|
||||
option = cv.All(cv.string_strict)
|
||||
mapping = cv.All(cv.int_range(-(2**63), 2**63 - 1))
|
||||
@@ -47,7 +55,7 @@ def ensure_option_map():
|
||||
return validator
|
||||
|
||||
|
||||
def register_count_value_type_min(value):
|
||||
def register_count_value_type_min(value: ConfigType) -> ConfigType:
|
||||
reg_count = value.get(CONF_REGISTER_COUNT)
|
||||
if reg_count is not None:
|
||||
value_type = value[CONF_VALUE_TYPE]
|
||||
@@ -87,7 +95,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
value_type = config[CONF_VALUE_TYPE]
|
||||
reg_count = config.get(CONF_REGISTER_COUNT)
|
||||
if reg_count is None:
|
||||
@@ -132,7 +140,7 @@ async def to_code(config):
|
||||
(ModbusSelect.operator("const_ptr"), "item"),
|
||||
(cg.std_string.operator("const").operator("ref"), "x"),
|
||||
(cg.int64, "value"),
|
||||
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
|
||||
(RegisterValues.operator("ref"), "payload"),
|
||||
],
|
||||
return_type=cg.optional.template(cg.int64),
|
||||
)
|
||||
|
||||
@@ -46,35 +46,43 @@ void ModbusSelect::control(size_t index) {
|
||||
const char *option = this->option_at(index);
|
||||
ESP_LOGD(TAG, "Found value %lld for option '%s'", *mapval, option);
|
||||
|
||||
std::vector<uint16_t> data;
|
||||
this->clear_dispatched_();
|
||||
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::RegisterValues data;
|
||||
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// Transform func requires string parameter for backward compatibility
|
||||
// The lambda may drive the write itself via item->write_*(), override the mapping value (return a value),
|
||||
// or (deprecated) fill `data` with the register words to write. Transform func requires string parameter
|
||||
// for backward compatibility.
|
||||
auto val = (*this->write_transform_func_)(this, std::string(option), *mapval, data);
|
||||
if (val.has_value()) {
|
||||
mapval = val;
|
||||
ESP_LOGV(TAG, "write_lambda returned mapping value %lld", *mapval);
|
||||
} else {
|
||||
if (this->dispatched()) {
|
||||
if (this->optimistic_)
|
||||
this->publish_state(index);
|
||||
return;
|
||||
}
|
||||
if (!data.empty()) {
|
||||
// Deprecated buffer path (frozen): the lambda supplied the register words for the shared write below.
|
||||
this->warn_write_buffer_deprecated_(LOG_STR("select"), this->start_address);
|
||||
} else if (!val.has_value()) {
|
||||
ESP_LOGD(TAG, "Communication handled by write_lambda - exiting control");
|
||||
return;
|
||||
} else {
|
||||
mapval = val;
|
||||
ESP_LOGV(TAG, "write_lambda returned mapping value %lld", *mapval);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.empty()) {
|
||||
modbus::helpers::number_to_payload(data, *mapval, this->sensor_value_type);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Using payload from write lambda");
|
||||
// number_to_payload() appends nothing for RAW.
|
||||
if (data.empty()) {
|
||||
ESP_LOGW(TAG, "No payload was created for updating select");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.empty()) {
|
||||
ESP_LOGW(TAG, "No payload was created for updating select");
|
||||
return;
|
||||
}
|
||||
|
||||
// The command declares register_count registers, so the payload must be exactly that many words:
|
||||
// a value type narrower than the declared width is zero-padded (the config deliberately allows
|
||||
// register_count larger than the value type). Anything else would put a byte count on the wire
|
||||
// that disagrees with the quantity field, which conformant devices reject.
|
||||
// register_count declares the READ range width - it may pull neighboring registers into one poll -
|
||||
// so a write covers exactly the registers the value occupies: the quantity comes from the payload,
|
||||
// never from register_count (padding to it would zero registers the user only declared for reading).
|
||||
@@ -86,16 +94,17 @@ void ModbusSelect::control(size_t index) {
|
||||
}
|
||||
|
||||
const uint16_t write_address = this->write_address();
|
||||
optional<ModbusCommandItem> write_cmd;
|
||||
bool queued;
|
||||
if ((this->register_count == 1) && (!this->use_write_multiple_)) {
|
||||
write_cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]));
|
||||
queued = this->write_single_register(write_address, data[0]);
|
||||
} else {
|
||||
write_cmd.emplace(
|
||||
ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data));
|
||||
queued = this->write_multiple_registers(write_address, data);
|
||||
}
|
||||
|
||||
this->parent_->queue_command(std::move(*write_cmd));
|
||||
|
||||
if (!queued) {
|
||||
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
if (this->optimistic_)
|
||||
this->publish_state(index);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusSelect final : public Component, public select::Select, public SensorItem {
|
||||
class ModbusSelect final : public Component, public select::Select, public SensorItem, public WriterEntity {
|
||||
public:
|
||||
ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, bool force_new_range,
|
||||
std::vector<int64_t> mapping) {
|
||||
@@ -26,9 +26,9 @@ class ModbusSelect final : public Component, public select::Select, public Senso
|
||||
|
||||
using transform_func_t = optional<std::string> (*)(ModbusSelect *const, int64_t, std::span<const uint8_t>);
|
||||
using write_transform_func_t = optional<int64_t> (*)(ModbusSelect *const, const std::string &, int64_t,
|
||||
std::vector<uint16_t> &);
|
||||
modbus::RegisterValues &);
|
||||
|
||||
void set_parent(ModbusController *const parent) { this->parent_ = parent; }
|
||||
void set_parent(ModbusController *const parent) { this->set_controller_(parent); }
|
||||
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
|
||||
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
|
||||
void set_template(transform_func_t f) { this->transform_func_ = f; }
|
||||
@@ -40,7 +40,6 @@ class ModbusSelect final : public Component, public select::Select, public Senso
|
||||
|
||||
protected:
|
||||
std::vector<int64_t> mapping_{};
|
||||
ModbusController *parent_{nullptr};
|
||||
bool use_write_multiple_{false};
|
||||
bool optimistic_{false};
|
||||
optional<transform_func_t> transform_func_{nullopt};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import switch
|
||||
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE
|
||||
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
@@ -48,7 +49,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
byte_offset, _ = modbus_calc_properties(config)
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
@@ -74,7 +75,7 @@ async def to_code(config):
|
||||
[
|
||||
(ModbusSwitch.operator("ptr"), "item"),
|
||||
(cg.bool_, "x"),
|
||||
(cg.std_vector.template(cg.uint8).operator("ref"), "payload"),
|
||||
(PduBuffer.operator("ref"), "payload"),
|
||||
],
|
||||
return_type=cg.optional.template(bool),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::modbus_controller {
|
||||
|
||||
static const char *const TAG = "modbus_controller.switch";
|
||||
@@ -58,57 +60,64 @@ void ModbusSwitch::parse_and_publish(std::span<const uint8_t> data) {
|
||||
}
|
||||
|
||||
void ModbusSwitch::write_state(bool state) {
|
||||
// This will be called every time the user requests a state change.
|
||||
optional<ModbusCommandItem> cmd;
|
||||
std::vector<uint8_t> data;
|
||||
// Is there are lambda configured?
|
||||
this->clear_dispatched_();
|
||||
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::helpers::PduBuffer data;
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// data is passed by reference
|
||||
// the lambda can fill the empty vector directly
|
||||
// in that case the return value is ignored
|
||||
// The lambda may drive the write itself via item->write_*/queue_pdu(), override the written value (return a
|
||||
// value), or (deprecated) fill `data` with a custom PDU.
|
||||
auto val = (*this->write_transform_func_)(this, state, data);
|
||||
if (val.has_value()) {
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
state = val.value();
|
||||
} else {
|
||||
if (this->dispatched()) {
|
||||
this->publish_state(state);
|
||||
return;
|
||||
}
|
||||
if (!data.empty()) {
|
||||
this->warn_write_buffer_deprecated_(LOG_STR("switch"), this->start_address);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus Switch write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
// The lambda filled a legacy raw frame (device address + function code + data).
|
||||
if (!this->send_raw_frame_deprecated_(data)) {
|
||||
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
this->publish_state(state);
|
||||
return;
|
||||
}
|
||||
if (!val.has_value()) {
|
||||
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
state = val.value();
|
||||
}
|
||||
if (!data.empty()) {
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus Switch write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
cmd.emplace(ModbusCommandItem::create_custom_command(
|
||||
this->parent_, data,
|
||||
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
|
||||
this->parent_->on_write_register_response(register_type, this->start_address, data);
|
||||
}));
|
||||
} else {
|
||||
ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(),
|
||||
ONOFF(state), (int) this->register_type, this->start_address, this->offset);
|
||||
if (this->register_type == modbus::EntityType::COIL) {
|
||||
// offset for coil and discrete inputs is the coil/register number not bytes
|
||||
if (this->use_write_multiple_) {
|
||||
std::vector<bool> states{state};
|
||||
cmd.emplace(ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states));
|
||||
} else {
|
||||
cmd.emplace(ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state));
|
||||
}
|
||||
ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(),
|
||||
ONOFF(state), (int) this->register_type, this->start_address, this->offset);
|
||||
bool queued;
|
||||
if (this->register_type == EntityType::COIL) {
|
||||
// offset for coil and discrete inputs is the coil/register number not bytes
|
||||
if (this->use_write_multiple_) {
|
||||
std::array<bool, 1> states{state};
|
||||
queued = this->write_multiple_coils(this->write_address(), states);
|
||||
} else {
|
||||
if (this->use_write_multiple_) {
|
||||
std::vector<uint16_t> bool_states(1, state ? (0xFFFF & this->bitmask) : 0);
|
||||
cmd.emplace(
|
||||
ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states));
|
||||
} else {
|
||||
cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(),
|
||||
state ? 0xFFFF & this->bitmask : 0u));
|
||||
}
|
||||
queued = this->write_single_coil(this->write_address(), state);
|
||||
}
|
||||
} else {
|
||||
if (this->use_write_multiple_) {
|
||||
std::array<uint16_t, 1> states{static_cast<uint16_t>(state ? (0xFFFF & this->bitmask) : 0)};
|
||||
queued = this->write_multiple_registers(this->write_address(), states);
|
||||
} else {
|
||||
queued = this->write_single_register(this->write_address(), state ? 0xFFFF & this->bitmask : 0u);
|
||||
}
|
||||
}
|
||||
this->parent_->queue_command(std::move(*cmd));
|
||||
if (!queued) {
|
||||
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
this->publish_state(state);
|
||||
}
|
||||
// ModbusSwitch end
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem {
|
||||
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem, public WriterEntity {
|
||||
public:
|
||||
ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
bool force_new_range) {
|
||||
@@ -30,17 +30,16 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens
|
||||
void set_assumed_state(bool assumed_state);
|
||||
void set_state(bool state) { this->state = state; }
|
||||
void parse_and_publish(std::span<const uint8_t> data) override;
|
||||
void set_parent(ModbusController *parent) { this->parent_ = parent; }
|
||||
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
|
||||
|
||||
using transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, std::span<const uint8_t>);
|
||||
using write_transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, std::vector<uint8_t> &);
|
||||
using write_transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, modbus::helpers::PduBuffer &);
|
||||
void set_template(transform_func_t f) { this->publish_transform_func_ = f; }
|
||||
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
|
||||
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
|
||||
|
||||
protected:
|
||||
bool assumed_state() override;
|
||||
ModbusController *parent_{nullptr};
|
||||
bool use_write_multiple_{false};
|
||||
optional<transform_func_t> publish_transform_func_{nullopt};
|
||||
optional<write_transform_func_t> write_transform_func_{nullopt};
|
||||
|
||||
@@ -3,6 +3,12 @@ import logging
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, esp32_rmt, remote_base
|
||||
from esphome.components.libretiny import get_libretiny_family
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7238,
|
||||
FAMILY_RTL8720C,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -43,6 +49,21 @@ DigitalWriteAction = remote_transmitter_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7231N, FAMILY_BK7238)
|
||||
|
||||
|
||||
def _validate_non_blocking_platform(value: bool) -> bool:
|
||||
# non_blocking requires hardware transmission: RMT on ESP32, a hardware timer
|
||||
# envelope chain on the listed LibreTiny families. Reject elsewhere at config time.
|
||||
if CORE.is_esp32:
|
||||
return cv.boolean(value)
|
||||
if CORE.is_libretiny and get_libretiny_family() in _NON_BLOCKING_LIBRETINY_FAMILIES:
|
||||
return cv.boolean(value)
|
||||
raise cv.Invalid(
|
||||
"non_blocking is only supported on ESP32, RTL8720C, BK7231N and BK7238"
|
||||
)
|
||||
|
||||
|
||||
MULTI_CONF = True
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
@@ -76,7 +97,7 @@ CONFIG_SCHEMA = (
|
||||
esp32_s2=64,
|
||||
esp32_s3=48,
|
||||
): cv.All(cv.only_on_esp32, cv.int_range(min=2)),
|
||||
cv.Optional(CONF_NON_BLOCKING): cv.All(cv.only_on_esp32, cv.boolean),
|
||||
cv.Optional(CONF_NON_BLOCKING): _validate_non_blocking_platform,
|
||||
cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_ON_COMPLETE): automation.validate_automation(single=True),
|
||||
}
|
||||
@@ -164,6 +185,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
else:
|
||||
var = cg.new_Pvariable(config[CONF_ID], pin)
|
||||
if (non_blocking := config.get(CONF_NON_BLOCKING)) is not None:
|
||||
cg.add(var.set_non_blocking(non_blocking))
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_carrier_duty_percent(config[CONF_CARRIER_DUTY_PERCENT]))
|
||||
@@ -188,6 +211,13 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
"remote_transmitter_rtl87xx.cpp": {
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
},
|
||||
"remote_transmitter_bk72xx.cpp": {
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
},
|
||||
"remote_transmitter_libretiny_isr.cpp": {
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
},
|
||||
"remote_transmitter.cpp": {
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_ESP8266) || defined(USE_RP2) || \
|
||||
(defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \
|
||||
defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
#endif // SOC_RMT_SUPPORTED
|
||||
#endif // USE_ESP32
|
||||
|
||||
// The BK7231N-style PWM block (hardware shadow-load duty updates) enables the ISR-driven
|
||||
// transmitter on these families; family-level proxy for the SDK's CFG_SOC_NAME gate.
|
||||
// See remote_transmitter_bk72xx.cpp.
|
||||
#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7238)
|
||||
#define REMOTE_TRANSMITTER_BK_PWM
|
||||
#endif
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
|
||||
@@ -56,19 +63,32 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
|
||||
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
|
||||
void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; }
|
||||
void set_eot_level(bool eot_level) { this->eot_level_ = eot_level; }
|
||||
#endif
|
||||
#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || \
|
||||
defined(REMOTE_TRANSMITTER_BK_PWM)
|
||||
void set_non_blocking(bool non_blocking) { this->non_blocking_ = non_blocking; }
|
||||
#endif
|
||||
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
|
||||
void loop() override;
|
||||
// called from the envelope timer ISR trampoline; not part of the public API
|
||||
void advance_envelope_isr();
|
||||
// same, for trampolines whose SDK callback carries no user argument
|
||||
static void advance_active_isr();
|
||||
#endif
|
||||
|
||||
Trigger<> *get_transmit_trigger() { return &this->transmit_trigger_; }
|
||||
Trigger<> *get_complete_trigger() { return &this->complete_trigger_; }
|
||||
|
||||
protected:
|
||||
void send_internal(uint32_t send_times, uint32_t send_wait) override;
|
||||
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
#if defined(USE_ESP8266) || \
|
||||
(defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \
|
||||
defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
void await_target_time_();
|
||||
uint32_t target_time_{0};
|
||||
#endif
|
||||
#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_RP2) || \
|
||||
#if defined(USE_ESP8266) || \
|
||||
(defined(USE_LIBRETINY) && !defined(USE_RTL87XX) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || defined(USE_RP2) || \
|
||||
(defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period);
|
||||
|
||||
@@ -81,6 +101,43 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
|
||||
uint32_t current_carrier_frequency_{0};
|
||||
void *pwm_{nullptr}; // pwmout_t*, opaque here to keep the SDK header out of this shared header
|
||||
#endif
|
||||
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
|
||||
// Envelope chain, shared by every family that paces transmission from a hardware timer
|
||||
// (remote_transmitter_libretiny_isr.cpp)
|
||||
void start_isr_item_(size_t index);
|
||||
void arm_envelope_timer_(uint32_t duration_us);
|
||||
void abort_stalled_chain_();
|
||||
void deliver_completion_();
|
||||
void wait_until_idle_();
|
||||
void arm_chain_(uint32_t send_times, uint32_t send_wait);
|
||||
// Hooks implemented per family: everything the chain needs from the hardware
|
||||
bool envelope_ready_() const; // PWM claimed successfully in setup()
|
||||
void prepare_carrier_(uint32_t carrier_frequency); // retune period, stage mark/space levels
|
||||
void write_envelope_level_(bool mark); // drive carrier (mark) or idle (space)
|
||||
void arm_one_shot_(uint32_t duration_us); // fire advance_envelope_isr after duration_us
|
||||
void stop_envelope_timer_();
|
||||
std::vector<int32_t> isr_data_; // owned copy of the frame; temp_ may be re-encoded mid-flight
|
||||
volatile size_t isr_index_{0};
|
||||
volatile uint32_t isr_repeats_left_{0};
|
||||
uint32_t isr_send_wait_{0};
|
||||
volatile uint32_t isr_wait_remaining_{0}; // remainder of a duration chained across one-shots
|
||||
volatile bool isr_in_gap_{false};
|
||||
volatile bool transmitting_{false};
|
||||
bool non_blocking_{false};
|
||||
bool complete_pending_{false};
|
||||
bool stall_aborted_{false}; // this transmission ended via abort; blocks warning clear
|
||||
#endif
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
float isr_mark_duty_{0.0f};
|
||||
float isr_space_duty_{0.0f};
|
||||
#endif
|
||||
#ifdef REMOTE_TRANSMITTER_BK_PWM
|
||||
void write_pwm_t1_(uint32_t t1_counts);
|
||||
uint32_t isr_mark_t1_{0};
|
||||
uint32_t isr_space_t1_{0};
|
||||
uint32_t isr_period_t4_{684}; // 26MHz counts; ~38kHz default until a send sets the real carrier
|
||||
int8_t pwm_channel_{-1};
|
||||
#endif
|
||||
|
||||
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
|
||||
void configure_rmt_();
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
#include "remote_transmitter.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// clang-tidy cannot parse the Beken SDK headers pulled in via ArduinoPrivate.h
|
||||
#if defined(USE_BK72XX) && !defined(CLANG_TIDY)
|
||||
|
||||
// ArduinoPrivate.h = Arduino.h + the BDK SDK headers (pwm_pub.h, bk_timer_pub.h, icu_pub.h)
|
||||
// with the core's fixes for type-name collisions between the two
|
||||
#include <ArduinoPrivate.h>
|
||||
|
||||
// Only the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit)
|
||||
// supports glitch-free per-edge duty updates; older SoCs compile the generic bit-bang
|
||||
// implementation (remote_transmitter.cpp) instead, and this file compiles to nothing.
|
||||
// REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h.
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
static const char *const TAG = "remote_transmitter";
|
||||
|
||||
#ifdef REMOTE_TRANSMITTER_BK_PWM
|
||||
|
||||
// PWM peripheral carrier (26MHz block), envelope paced by a BKTIMER1 interrupt chain: each
|
||||
// interrupt writes the next duty through the shadow registers (T1..T4 + CFG_UPDATA hardware
|
||||
// load, glitch-free at the next carrier period). Direct register writes beat the driver's
|
||||
// pwm_update_param() (~19us vs ~26us edge error) and have no shared state to race against.
|
||||
// BKTIMER1 is the only free channel: TIMER0 = FreeRTOS tick, TIMER2 = SDK cal, TIMER4 = wdt.
|
||||
|
||||
static constexpr uint32_t REG_PWM_BASE = 0x00802B00UL;
|
||||
static constexpr uint32_t REG_PWM_GROUP_STRIDE = 0x40; // one register group per channel pair
|
||||
static constexpr uint32_t REG_PWM_T_REGS[2] = {0x04, 0x14}; // T1..T4 offsets within a group
|
||||
static constexpr uint32_t PWM_INT_STATUS_MASK = 3UL << 30; // write-1-clear -- always write as zero
|
||||
static constexpr uint8_t ENVELOPE_TIMER = BKTIMER1;
|
||||
|
||||
// The bk_timer handler receives only the channel number, so the chain resolves the instance
|
||||
// that owns the timer. No IRAM_ATTR: hal.h makes it a no-op on BK72xx (the SDK masks IRQs
|
||||
// around flash writes).
|
||||
static void envelope_timer_isr(UINT8 channel) { RemoteTransmitterComponent::advance_active_isr(); }
|
||||
|
||||
// Channel <-> pin comes from the board variant's own PIN_PWMn defines rather than a
|
||||
// family-wide assumption, so an unusual pinout maps correctly instead of silently
|
||||
// driving another pad
|
||||
struct PwmPinChannel {
|
||||
uint8_t pin;
|
||||
int8_t channel;
|
||||
};
|
||||
static constexpr PwmPinChannel PWM_PIN_CHANNELS[] = {
|
||||
#ifdef PIN_PWM0
|
||||
{PIN_PWM0, 0},
|
||||
#endif
|
||||
#ifdef PIN_PWM1
|
||||
{PIN_PWM1, 1},
|
||||
#endif
|
||||
#ifdef PIN_PWM2
|
||||
{PIN_PWM2, 2},
|
||||
#endif
|
||||
#ifdef PIN_PWM3
|
||||
{PIN_PWM3, 3},
|
||||
#endif
|
||||
#ifdef PIN_PWM4
|
||||
{PIN_PWM4, 4},
|
||||
#endif
|
||||
#ifdef PIN_PWM5
|
||||
{PIN_PWM5, 5},
|
||||
#endif
|
||||
};
|
||||
|
||||
static int8_t pwm_channel_for_pin(uint8_t pin) {
|
||||
for (const auto &entry : PWM_PIN_CHANNELS) {
|
||||
if (entry.pin == pin)
|
||||
return entry.channel;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::setup() {
|
||||
// Deliberately no pin_->setup(): the pin must belong to the PWM function, not GPIO
|
||||
const int8_t channel = pwm_channel_for_pin(this->pin_->get_pin());
|
||||
if (channel < 0) {
|
||||
ESP_LOGE(TAG, "Pin %u is not PWM-capable", this->pin_->get_pin());
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
this->pwm_channel_ = channel;
|
||||
const uint32_t idle_t1 = this->pin_->is_inverted() ? this->isr_period_t4_ : 0;
|
||||
pwm_param_st param{};
|
||||
param.chan = channel;
|
||||
param.t1 = idle_t1;
|
||||
param.t4 = this->isr_period_t4_;
|
||||
param.init_level = idle_t1 ? 1 : 0;
|
||||
if (pwm_init_param(¶m) != 0 || pwm_start(channel) != 0) {
|
||||
ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin());
|
||||
this->pwm_channel_ = -1;
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
this->disable_loop(); // loop() is only needed while a non-blocking completion is pending
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Remote Transmitter:\n"
|
||||
" Carrier Duty: %u%%\n"
|
||||
" Non-blocking: %s",
|
||||
this->carrier_duty_percent_, YESNO(this->non_blocking_));
|
||||
LOG_PIN(" Pin: ", this->pin_);
|
||||
}
|
||||
|
||||
// Writes the duty compare registers and sets the hardware CFG_UPDATA shadow-load bit;
|
||||
// the new duty latches glitch-free at the next carrier period. ISR-safe: registers only.
|
||||
// The group control word is shared with the paired channel, but every SDK write to it runs
|
||||
// under GLOBAL_INT_DISABLE (bk_pwm), so it cannot be torn by this interrupt.
|
||||
void RemoteTransmitterComponent::write_pwm_t1_(uint32_t t1_counts) {
|
||||
const uint32_t group = this->pwm_channel_ / 2;
|
||||
const uint32_t post = this->pwm_channel_ % 2;
|
||||
const uint32_t group_base = REG_PWM_BASE + REG_PWM_GROUP_STRIDE * group;
|
||||
auto *t_regs = (volatile uint32_t *) (group_base + REG_PWM_T_REGS[post]);
|
||||
auto *ctrl = (volatile uint32_t *) group_base;
|
||||
const uint32_t init_level_bit = 1UL << (8 * post + 6); // output level while the counter is stopped
|
||||
const uint32_t cfg_updata_bit = 1UL << (8 * post + 7); // 0->1 latches T1..T4 at the next period
|
||||
t_regs[0] = t1_counts; // T1: high time
|
||||
t_regs[1] = 0; // T2
|
||||
t_regs[2] = 0; // T3
|
||||
t_regs[3] = this->isr_period_t4_; // T4: period
|
||||
uint32_t cfg = *ctrl;
|
||||
cfg &= ~(PWM_INT_STATUS_MASK | init_level_bit | cfg_updata_bit);
|
||||
if (t1_counts != 0)
|
||||
cfg |= init_level_bit;
|
||||
*ctrl = cfg;
|
||||
*ctrl = cfg | cfg_updata_bit;
|
||||
}
|
||||
|
||||
// --- envelope chain hooks (see remote_transmitter_libretiny_isr.cpp) ---
|
||||
|
||||
bool RemoteTransmitterComponent::envelope_ready_() const { return this->pwm_channel_ >= 0; }
|
||||
|
||||
// Recomputes the carrier period in 26MHz counts and stages the per-item duties;
|
||||
// unmodulated protocols drive the pin constantly during marks
|
||||
void RemoteTransmitterComponent::prepare_carrier_(uint32_t carrier_frequency) {
|
||||
if (carrier_frequency > 0) {
|
||||
this->isr_period_t4_ = std::max(uint32_t(2), (26000000UL + carrier_frequency / 2) / carrier_frequency);
|
||||
}
|
||||
uint32_t mark_t1 = (carrier_frequency > 0 && this->carrier_duty_percent_ < 100)
|
||||
? std::max(uint32_t(1), this->isr_period_t4_ * this->carrier_duty_percent_ / 100)
|
||||
: this->isr_period_t4_;
|
||||
uint32_t space_t1 = 0;
|
||||
if (this->pin_->is_inverted()) {
|
||||
mark_t1 = this->isr_period_t4_ - mark_t1;
|
||||
space_t1 = this->isr_period_t4_;
|
||||
}
|
||||
this->isr_mark_t1_ = mark_t1;
|
||||
this->isr_space_t1_ = space_t1;
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::write_envelope_level_(bool mark) {
|
||||
this->write_pwm_t1_(mark ? this->isr_mark_t1_ : this->isr_space_t1_);
|
||||
}
|
||||
|
||||
// The driver's microsecond init path is register writes under a nested interrupt guard,
|
||||
// so it is safe to call from the chain's own interrupt
|
||||
void RemoteTransmitterComponent::arm_one_shot_(uint32_t duration_us) {
|
||||
timer_param_t param{};
|
||||
param.channel = ENVELOPE_TIMER;
|
||||
param.div = 1;
|
||||
param.period = duration_us;
|
||||
param.t_Int_Handler = envelope_timer_isr;
|
||||
sddev_control((char *) TIMER_DEV_NAME, CMD_TIMER_INIT_PARAM_US, ¶m);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::stop_envelope_timer_() {
|
||||
UINT32 channel = ENVELOPE_TIMER;
|
||||
sddev_control((char *) TIMER_DEV_NAME, CMD_TIMER_UNIT_DISABLE, &channel);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::digital_write(bool value) {
|
||||
if (this->pwm_channel_ < 0)
|
||||
return;
|
||||
// serialize behind an in-flight chain, matching the ESP32/RMT non-blocking behavior
|
||||
this->wait_until_idle_();
|
||||
this->write_pwm_t1_((value != this->pin_->is_inverted()) ? this->isr_period_t4_ : 0);
|
||||
}
|
||||
|
||||
#endif // REMOTE_TRANSMITTER_BK_PWM
|
||||
|
||||
} // namespace esphome::remote_transmitter
|
||||
|
||||
#endif // USE_BK72XX && !CLANG_TIDY
|
||||
@@ -0,0 +1,224 @@
|
||||
#include "remote_transmitter.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Envelope chain shared by the LibreTiny families that pace transmission from a hardware
|
||||
// timer interrupt: RTL8720C (gtimer) and the BK7231N-style PWM block (BKTIMER1). Everything
|
||||
// platform-specific sits behind five hooks implemented in the per-family files -- carrier
|
||||
// setup, duty writes, one-shot arming and timer stop. Families without a usable timer keep
|
||||
// the generic bit-bang implementation and compile none of this.
|
||||
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
static const char *const TAG = "remote_transmitter";
|
||||
|
||||
// Margin past a transmission's expected duration before the chain is declared stalled
|
||||
static constexpr uint32_t STALL_MARGIN_MS = 1000;
|
||||
// Longest single one-shot armed; longer durations are chained. Both families need the cap:
|
||||
// the Beken driver computes period_us * 26 in 32 bits (overflows past ~165s) and the Realtek
|
||||
// us->tick conversion lives in mask ROM with unverified headroom.
|
||||
static constexpr uint32_t MAX_ONE_SHOT_US = 50000;
|
||||
|
||||
// One hardware timer is shared by all instances (MULTI_CONF), so they serialize on this
|
||||
// token; the deadline always describes whichever chain currently owns it.
|
||||
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static RemoteTransmitterComponent *volatile s_active_transmitter = nullptr;
|
||||
static uint32_t s_expected_end_ms = 0;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
// Entry point for trampolines whose SDK callback carries no user argument
|
||||
void IRAM_ATTR RemoteTransmitterComponent::advance_active_isr() {
|
||||
auto *transmitter = s_active_transmitter;
|
||||
if (transmitter != nullptr)
|
||||
transmitter->advance_envelope_isr();
|
||||
}
|
||||
|
||||
// Arms the envelope timer, chaining durations longer than MAX_ONE_SHOT_US. ISR-safe.
|
||||
void IRAM_ATTR RemoteTransmitterComponent::arm_envelope_timer_(uint32_t duration_us) {
|
||||
// clamp to 1us (a zero-length one-shot never fires); the remainder must not underflow
|
||||
const uint32_t chunk = std::max(uint32_t(1), std::min(duration_us, MAX_ONE_SHOT_US));
|
||||
this->isr_wait_remaining_ = duration_us > chunk ? duration_us - chunk : 0;
|
||||
this->arm_one_shot_(chunk);
|
||||
}
|
||||
|
||||
// Writes the level for one envelope item and arms the timer for its duration.
|
||||
// Runs in ISR context (and once from arm_chain_ to kick the chain): no logging, no allocation.
|
||||
void IRAM_ATTR RemoteTransmitterComponent::start_isr_item_(size_t index) {
|
||||
const int32_t item = this->isr_data_[index];
|
||||
this->write_envelope_level_(item > 0);
|
||||
this->arm_envelope_timer_(uint32_t(item > 0 ? item : -item));
|
||||
}
|
||||
|
||||
void IRAM_ATTR RemoteTransmitterComponent::advance_envelope_isr() {
|
||||
if (!this->transmitting_)
|
||||
return; // chain was aborted; this is a stale one-shot that was already latched
|
||||
if (this->isr_wait_remaining_ > 0) {
|
||||
// continue a duration longer than one hardware one-shot
|
||||
this->arm_envelope_timer_(this->isr_wait_remaining_);
|
||||
return;
|
||||
}
|
||||
if (this->isr_in_gap_) {
|
||||
// inter-repeat gap elapsed; restart the item chain
|
||||
this->isr_in_gap_ = false;
|
||||
this->isr_index_ = 0;
|
||||
this->start_isr_item_(0);
|
||||
return;
|
||||
}
|
||||
this->isr_index_ = this->isr_index_ + 1;
|
||||
if (this->isr_index_ < this->isr_data_.size()) {
|
||||
this->start_isr_item_(this->isr_index_);
|
||||
return;
|
||||
}
|
||||
// end of one repetition
|
||||
this->write_envelope_level_(false);
|
||||
if (this->isr_repeats_left_ > 1) {
|
||||
this->isr_repeats_left_ = this->isr_repeats_left_ - 1;
|
||||
this->isr_index_ = 0;
|
||||
if (this->isr_send_wait_ > 0) {
|
||||
this->isr_in_gap_ = true;
|
||||
this->arm_envelope_timer_(this->isr_send_wait_);
|
||||
} else {
|
||||
this->start_isr_item_(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// required on Beken (its timer reloads); on Realtek this only clears the enable bit of a
|
||||
// one-shot that has already fired
|
||||
this->stop_envelope_timer_();
|
||||
this->transmitting_ = false;
|
||||
s_active_transmitter = nullptr;
|
||||
}
|
||||
|
||||
// Aborts a chain that stopped advancing: stop the timer, idle the pin, release the token.
|
||||
// Every step is a no-op if the chain completed meanwhile. Task context only.
|
||||
void RemoteTransmitterComponent::abort_stalled_chain_() {
|
||||
// cleared first so a straggler one-shot bails at the ISR entry check
|
||||
this->transmitting_ = false;
|
||||
this->stop_envelope_timer_();
|
||||
this->write_envelope_level_(false);
|
||||
s_active_transmitter = nullptr;
|
||||
this->stall_aborted_ = true;
|
||||
this->status_set_warning("envelope timer stalled");
|
||||
ESP_LOGE(TAG, "Envelope timer stalled; transmission aborted");
|
||||
delay(1); // let any already-latched interrupt land while the chain state is safe
|
||||
}
|
||||
|
||||
// Delivers one deferred completion with its status bookkeeping
|
||||
void RemoteTransmitterComponent::deliver_completion_() {
|
||||
if (!this->stall_aborted_)
|
||||
this->status_clear_warning();
|
||||
this->complete_pending_ = false;
|
||||
this->complete_trigger_.trigger();
|
||||
}
|
||||
|
||||
// Waits until no chain is in flight, delivering any deferred completions; a completion
|
||||
// automation may start a new send, so repeat until truly idle. Bounded by the stall deadline.
|
||||
void RemoteTransmitterComponent::wait_until_idle_() {
|
||||
while (true) {
|
||||
while (true) {
|
||||
// snapshot: the final ISR can clear the volatile pointer between a check and a use
|
||||
auto *active = s_active_transmitter;
|
||||
if (active == nullptr)
|
||||
break;
|
||||
if ((int32_t) (millis() - s_expected_end_ms) > 0) {
|
||||
active->abort_stalled_chain_();
|
||||
break;
|
||||
}
|
||||
App.feed_wdt();
|
||||
delay(1);
|
||||
}
|
||||
if (!this->complete_pending_)
|
||||
break;
|
||||
this->deliver_completion_();
|
||||
}
|
||||
}
|
||||
|
||||
// Stages the repeat schedule and stall deadline, then starts the interrupt chain
|
||||
void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_wait) {
|
||||
this->isr_repeats_left_ = send_times;
|
||||
this->isr_send_wait_ = send_wait;
|
||||
this->isr_index_ = 0;
|
||||
this->isr_in_gap_ = false;
|
||||
this->stall_aborted_ = false;
|
||||
uint64_t frame_us = 0;
|
||||
for (int32_t item : this->isr_data_)
|
||||
frame_us += uint32_t(item > 0 ? item : -item);
|
||||
const uint64_t total_us = frame_us * send_times + uint64_t(send_wait) * (send_times - 1);
|
||||
s_expected_end_ms = millis() + uint32_t(total_us / 1000) + STALL_MARGIN_MS;
|
||||
this->transmitting_ = true;
|
||||
s_active_transmitter = this;
|
||||
this->start_isr_item_(0);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
|
||||
if (!this->envelope_ready_()) {
|
||||
// both triggers still fire, so an on_complete-sequenced automation does not stall
|
||||
ESP_LOGW(TAG, "Cannot send: PWM not initialized");
|
||||
this->transmit_trigger_.trigger();
|
||||
this->deliver_completion_();
|
||||
return;
|
||||
}
|
||||
this->wait_until_idle_();
|
||||
if (send_times == 0) {
|
||||
// parity with the loop-based implementations: transmit nothing, but both triggers
|
||||
// still fire so an on_complete-sequenced automation does not stall
|
||||
this->transmit_trigger_.trigger();
|
||||
this->deliver_completion_();
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Sending remote code");
|
||||
this->prepare_carrier_(this->temp_.get_carrier_frequency());
|
||||
// own copy: with non_blocking the caller may re-encode temp_ while this frame is in flight
|
||||
this->isr_data_.assign(this->temp_.get_data().begin(), this->temp_.get_data().end());
|
||||
if (this->isr_data_.empty()) {
|
||||
ESP_LOGW(TAG, "Empty data");
|
||||
this->transmit_trigger_.trigger();
|
||||
this->deliver_completion_();
|
||||
return;
|
||||
}
|
||||
// trigger first: the deadline computed in arm_chain_ must not be charged for user code
|
||||
this->transmit_trigger_.trigger();
|
||||
// the automation may have started a send on another instance; let it finish before
|
||||
// claiming the shared timer (a same-instance send remains unsupported here)
|
||||
this->wait_until_idle_();
|
||||
this->arm_chain_(send_times, send_wait);
|
||||
if (this->non_blocking_) {
|
||||
this->complete_pending_ = true;
|
||||
this->enable_loop();
|
||||
return;
|
||||
}
|
||||
// blocking mode: wait out the chain, bounded by the stall deadline
|
||||
while (this->transmitting_) {
|
||||
if ((int32_t) (millis() - s_expected_end_ms) > 0) {
|
||||
this->abort_stalled_chain_();
|
||||
break;
|
||||
}
|
||||
App.feed_wdt();
|
||||
delay(1);
|
||||
}
|
||||
this->deliver_completion_();
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::loop() {
|
||||
if (!this->complete_pending_) {
|
||||
this->disable_loop();
|
||||
return;
|
||||
}
|
||||
if (this->transmitting_) {
|
||||
// non-blocking stall recovery: without this, a dead chain would leave the carrier
|
||||
// driven and on_complete unfired until the next send happened to abort it
|
||||
if ((int32_t) (millis() - s_expected_end_ms) <= 0)
|
||||
return;
|
||||
this->abort_stalled_chain_();
|
||||
}
|
||||
// release the loop before user code runs: the automation may start a new non-blocking
|
||||
// send, and its enable_loop() must be the last writer or its completion would strand
|
||||
this->disable_loop();
|
||||
this->deliver_completion_();
|
||||
}
|
||||
|
||||
} // namespace esphome::remote_transmitter
|
||||
|
||||
#endif // USE_LIBRETINY_VARIANT_RTL8720C || REMOTE_TRANSMITTER_BK_PWM
|
||||
@@ -5,31 +5,42 @@
|
||||
// clang-tidy cannot parse the Realtek SDK headers pulled in via ArduinoPrivate.h
|
||||
#if defined(USE_RTL87XX) && !defined(CLANG_TIDY)
|
||||
|
||||
// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout etc.) with the core's fixes for
|
||||
// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout, gtimer) with the core's fixes for
|
||||
// type-name collisions between the two (e.g. PinMode)
|
||||
#include <ArduinoPrivate.h>
|
||||
#ifndef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
#include <FreeRTOS.h>
|
||||
#include <task.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
static const char *const TAG = "remote_transmitter";
|
||||
|
||||
// The carrier is generated by the PWM peripheral instead of bit-banging the pin: software carrier
|
||||
// generation requires disabling interrupts for the whole frame, but this core's micros() is derived
|
||||
// from the FreeRTOS tick and freezes while interrupts are off, so the timing loop never advances and
|
||||
// the watchdog resets the chip. With hardware PWM, software only times the mark/space envelope and
|
||||
// interrupts can stay enabled.
|
||||
//
|
||||
// The PWM is driven through the SDK's pwmout HAL directly rather than the Arduino wiring layer:
|
||||
// changing the carrier frequency via the wiring requires a GPIO/PWM pin mode round-trip, which
|
||||
// use-after-frees the core's per-pin state (pinRemoveMode() frees without nulling) and corrupts the
|
||||
// heap. pwmout_period_us() changes the frequency with no mode transitions.
|
||||
// PWM peripheral carrier, envelope paced by a gtimer interrupt chain. Bit-banging would need
|
||||
// interrupts disabled for the whole frame, but this core's micros() derives from the FreeRTOS
|
||||
// tick and freezes then. The SDK pwmout HAL is driven directly: the Arduino wiring layer's
|
||||
// GPIO/PWM mode round-trip use-after-frees LibreTiny's per-pin state.
|
||||
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
static constexpr uint32_t ENVELOPE_TIMER_ID = TIMER6; // GTimer7
|
||||
|
||||
// One envelope timer for all instances: a second gtimer_init on the same id fails silently,
|
||||
// so the chain serializes them (remote_transmitter_libretiny_isr.cpp)
|
||||
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static uint8_t s_pwm_tick_sources[] = {GTimer1, GTimer2, GTimer3, GTimer4, GTimer5, GTimer6, 0xff};
|
||||
static gtimer_t s_envelope_timer;
|
||||
static bool s_envelope_timer_ready = false;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static void IRAM_ATTR envelope_timer_isr(uint32_t arg) {
|
||||
reinterpret_cast<RemoteTransmitterComponent *>(arg)->advance_envelope_isr();
|
||||
}
|
||||
#endif // USE_LIBRETINY_VARIANT_RTL8720C
|
||||
|
||||
void RemoteTransmitterComponent::setup() {
|
||||
// Deliberately no pin_->setup(): registering the pin as GPIO claims it in the SDK's pin
|
||||
// management, and the pad is then never handed over to the PWM peripheral -- pwmout_init()
|
||||
// must own the pin from the start.
|
||||
// no pin_->setup(): a GPIO claim in the SDK's pin management blocks pwmout_init from
|
||||
// owning the pad
|
||||
PinInfo *info = pinInfo(this->pin_->get_pin());
|
||||
if (info == nullptr || !pinSupported(info, PIN_PWM)) {
|
||||
// checked here because the AmebaZ (RTL8710B) SDK does not report PWM init failure
|
||||
@@ -40,7 +51,7 @@ void RemoteTransmitterComponent::setup() {
|
||||
auto *pwm = new pwmout_t();
|
||||
this->pwm_ = pwm;
|
||||
pwmout_init(pwm, static_cast<PinName>(info->gpio));
|
||||
#if LT_RTL8720C
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
// only the AmebaZ2 SDK's pwmout_s reports init success
|
||||
if (!pwm->is_init) {
|
||||
ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin());
|
||||
@@ -49,9 +60,19 @@ void RemoteTransmitterComponent::setup() {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
// Shrink the PWM tick-source pool before the period claim below so GTimer7 stays free
|
||||
// for the envelope; pwmout_init just registered the full pool.
|
||||
hal_pwm_comm_tick_source_list(s_pwm_tick_sources);
|
||||
#endif
|
||||
pwmout_period_us(pwm, 26); // placeholder; the real carrier period is set per transmission
|
||||
pwmout_write(pwm, this->pin_->is_inverted() ? 1.0f : 0.0f);
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
if (!s_envelope_timer_ready) {
|
||||
gtimer_init(&s_envelope_timer, ENVELOPE_TIMER_ID);
|
||||
s_envelope_timer_ready = true;
|
||||
}
|
||||
this->disable_loop(); // loop() is only needed while a non-blocking completion is pending
|
||||
#endif
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::dump_config() {
|
||||
@@ -59,9 +80,59 @@ void RemoteTransmitterComponent::dump_config() {
|
||||
"Remote Transmitter:\n"
|
||||
" Carrier Duty: %u%%",
|
||||
this->carrier_duty_percent_);
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
ESP_LOGCONFIG(TAG, " Non-blocking: %s", YESNO(this->non_blocking_));
|
||||
#endif
|
||||
LOG_PIN(" Pin: ", this->pin_);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::digital_write(bool value) {
|
||||
if (this->pwm_ == nullptr)
|
||||
return;
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
// serialize behind an in-flight chain, matching the ESP32/RMT non-blocking behavior
|
||||
this->wait_until_idle_();
|
||||
#endif
|
||||
pwmout_write(static_cast<pwmout_t *>(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f);
|
||||
}
|
||||
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
// --- envelope chain hooks (see remote_transmitter_libretiny_isr.cpp) ---
|
||||
|
||||
bool RemoteTransmitterComponent::envelope_ready_() const { return this->pwm_ != nullptr; }
|
||||
|
||||
// Retunes the PWM period when the carrier changes and stages the per-item duties;
|
||||
// unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks
|
||||
void RemoteTransmitterComponent::prepare_carrier_(uint32_t carrier_frequency) {
|
||||
float mark_duty =
|
||||
(carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f;
|
||||
float space_duty = 0.0f;
|
||||
if (this->pin_->is_inverted()) {
|
||||
mark_duty = 1.0f - mark_duty;
|
||||
space_duty = 1.0f;
|
||||
}
|
||||
this->isr_mark_duty_ = mark_duty;
|
||||
this->isr_space_duty_ = space_duty;
|
||||
if (carrier_frequency == 0 || carrier_frequency == this->current_carrier_frequency_)
|
||||
return;
|
||||
// round(1000000/freq), clamped so a bad lambda can't hand the SDK a zero period
|
||||
const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency);
|
||||
pwmout_period_us(static_cast<pwmout_t *>(this->pwm_), period);
|
||||
this->current_carrier_frequency_ = carrier_frequency;
|
||||
}
|
||||
|
||||
void IRAM_ATTR RemoteTransmitterComponent::write_envelope_level_(bool mark) {
|
||||
pwmout_write(static_cast<pwmout_t *>(this->pwm_), mark ? this->isr_mark_duty_ : this->isr_space_duty_);
|
||||
}
|
||||
|
||||
void IRAM_ATTR RemoteTransmitterComponent::arm_one_shot_(uint32_t duration_us) {
|
||||
gtimer_start_one_shout(&s_envelope_timer, duration_us, (void *) envelope_timer_isr, (uint32_t) this);
|
||||
}
|
||||
|
||||
void IRAM_ATTR RemoteTransmitterComponent::stop_envelope_timer_() { gtimer_stop(&s_envelope_timer); }
|
||||
|
||||
#else // !USE_LIBRETINY_VARIANT_RTL8720C -- AmebaZ (RTL8710B): spin-based envelope, per-frame priority boost
|
||||
|
||||
void RemoteTransmitterComponent::await_target_time_() {
|
||||
const uint32_t current_time = micros();
|
||||
if (this->target_time_ == 0) {
|
||||
@@ -72,15 +143,8 @@ void RemoteTransmitterComponent::await_target_time_() {
|
||||
}
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::digital_write(bool value) {
|
||||
if (this->pwm_ == nullptr)
|
||||
return;
|
||||
pwmout_write(static_cast<pwmout_t *>(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
|
||||
auto *pwm = static_cast<pwmout_t *>(this->pwm_);
|
||||
if (pwm == nullptr) {
|
||||
if (this->pwm_ == nullptr) {
|
||||
ESP_LOGW(TAG, "Cannot send: PWM not initialized");
|
||||
return;
|
||||
}
|
||||
@@ -94,6 +158,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
mark_duty = 1.0f - mark_duty;
|
||||
space_duty = 1.0f;
|
||||
}
|
||||
auto *pwm = static_cast<pwmout_t *>(this->pwm_);
|
||||
if (carrier_frequency > 0 && carrier_frequency != this->current_carrier_frequency_) {
|
||||
// round(1000000/freq), clamped like the bit-bang path so a bad lambda can't hand the SDK a zero period
|
||||
const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency);
|
||||
@@ -132,6 +197,8 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
this->complete_trigger_.trigger();
|
||||
}
|
||||
|
||||
#endif // USE_LIBRETINY_VARIANT_RTL8720C
|
||||
|
||||
} // namespace esphome::remote_transmitter
|
||||
|
||||
#endif // USE_RTL87XX && !CLANG_TIDY
|
||||
|
||||
@@ -10,6 +10,7 @@ from esphome.components.image import (
|
||||
validate_transparency,
|
||||
validate_type,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_FORMAT, CONF_ID, CONF_RESIZE, CONF_TYPE
|
||||
from esphome.core import CORE
|
||||
@@ -124,6 +125,15 @@ IMAGE_FORMATS = {
|
||||
"PNG": PNGFormat(),
|
||||
}
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{
|
||||
"bmp_decoder.cpp": "USE_RUNTIME_IMAGE_BMP",
|
||||
"jpeg_decoder.cpp": "USE_RUNTIME_IMAGE_JPEG",
|
||||
"png_decoder.cpp": "USE_RUNTIME_IMAGE_PNG",
|
||||
"qoi_decoder.cpp": "USE_RUNTIME_IMAGE_QOI",
|
||||
}
|
||||
)
|
||||
|
||||
AUTO_FORMAT = AUTOFormat()
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
|
||||
this->width_ = encode_uint32(buffer[21], buffer[20], buffer[19], buffer[18]);
|
||||
this->height_ = encode_uint32(buffer[25], buffer[24], buffer[23], buffer[22]);
|
||||
if (this->width_ <= 0 || this->height_ <= 0) {
|
||||
ESP_LOGE(TAG, "Invalid image dimensions: (%zdx%zd)", this->width_, this->height_);
|
||||
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
||||
}
|
||||
this->bits_per_pixel_ = encode_uint16(buffer[29], buffer[28]);
|
||||
this->compression_method_ = encode_uint32(buffer[33], buffer[32], buffer[31], buffer[30]);
|
||||
this->image_data_size_ = encode_uint32(buffer[37], buffer[36], buffer[35], buffer[34]);
|
||||
|
||||
@@ -15,6 +15,7 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
only_on_variant,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
@@ -126,6 +127,7 @@ CONF_FORCE_SW = "force_sw"
|
||||
CONF_INTERFACE = "interface"
|
||||
CONF_INTERFACE_INDEX = "interface_index"
|
||||
CONF_RELEASE_DEVICE = "release_device"
|
||||
CONF_PSRAM_DMA = "psram_dma"
|
||||
TYPE_SINGLE = "single"
|
||||
TYPE_QUAD = "quad"
|
||||
TYPE_OCTAL = "octal"
|
||||
@@ -136,6 +138,29 @@ TYPE_CLASS = {
|
||||
TYPE_OCTAL: OctalSPIComponent,
|
||||
}
|
||||
|
||||
|
||||
def _validate_psram_dma(value: Any) -> bool:
|
||||
value = cv.boolean(value)
|
||||
if not value:
|
||||
return value
|
||||
return cv.All(
|
||||
cv.only_on_esp32,
|
||||
cv.only_with_framework("esp-idf"),
|
||||
only_on_variant(
|
||||
supported=[
|
||||
VARIANT_ESP32C5,
|
||||
VARIANT_ESP32C61,
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S31,
|
||||
VARIANT_ESP32S3,
|
||||
],
|
||||
msg_prefix="PSRAM DMA",
|
||||
),
|
||||
cv.require_framework_version(esp_idf=cv.Version(5, 5, 3)),
|
||||
cv.requires_component("psram"),
|
||||
)(value)
|
||||
|
||||
|
||||
# RP2040 SPI pin assignments are complicated;
|
||||
# refer to GPIO function select table in https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf
|
||||
|
||||
@@ -450,6 +475,7 @@ def spi_device_schema(
|
||||
SPI_MODE_OPTIONS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_RELEASE_DEVICE): cv.All(cv.boolean, cv.only_on_esp32),
|
||||
cv.Optional(CONF_PSRAM_DMA): _validate_psram_dma,
|
||||
cs_pin_option(CONF_CS_PIN): pins.gpio_output_pin_schema,
|
||||
}
|
||||
)
|
||||
@@ -471,6 +497,9 @@ async def register_spi_device(
|
||||
cg.add(var.set_mode(spi_mode))
|
||||
if release_device := config.get(CONF_RELEASE_DEVICE):
|
||||
cg.add(var.set_release_device(release_device))
|
||||
if psram_dma := config.get(CONF_PSRAM_DMA):
|
||||
cg.add_define("USE_SPI_PSRAM_DMA")
|
||||
cg.add(var.set_psram_dma(psram_dma))
|
||||
|
||||
|
||||
def final_validate_device_schema(
|
||||
@@ -498,6 +527,36 @@ def final_validate_device_schema(
|
||||
)
|
||||
|
||||
|
||||
def _walk_config(value: Any, path: tuple[Any, ...] = ()):
|
||||
if isinstance(value, dict):
|
||||
yield value, path
|
||||
for key, child in value.items():
|
||||
yield from _walk_config(child, (*path, key))
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
yield from _walk_config(child, (*path, index))
|
||||
|
||||
|
||||
def _final_validate(config: Any) -> Any:
|
||||
buses = config if isinstance(config, list) else [config]
|
||||
software_bus_ids = {
|
||||
bus[CONF_ID] for bus in buses if CONF_INTERFACE_INDEX not in bus
|
||||
}
|
||||
if not software_bus_ids:
|
||||
return config
|
||||
for candidate, path in _walk_config(fv.full_config.get()):
|
||||
if (
|
||||
candidate.get(CONF_PSRAM_DMA)
|
||||
and candidate.get(CONF_SPI_ID) in software_bus_ids
|
||||
):
|
||||
with cv.prepend_path([cv.ROOT_CONFIG_PATH, *path, CONF_PSRAM_DMA]):
|
||||
raise cv.Invalid("psram_dma requires a hardware SPI interface")
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
{
|
||||
"spi_arduino.cpp": {
|
||||
|
||||
@@ -253,11 +253,18 @@ class SPIDelegate {
|
||||
// check if device is ready
|
||||
virtual bool is_ready();
|
||||
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
void set_psram_dma(bool enable) { this->psram_dma_ = enable; }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
SPIBitOrder bit_order_{BIT_ORDER_MSB_FIRST};
|
||||
uint32_t data_rate_{1000000};
|
||||
SPIMode mode_{MODE0};
|
||||
GPIOPin *cs_pin_{NullPin::NULL_PIN};
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
bool psram_dma_{false};
|
||||
#endif
|
||||
static SPIDelegate *const NULL_DELEGATE; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
};
|
||||
|
||||
@@ -397,6 +404,11 @@ class SPIClient {
|
||||
esph_log_d("spi_device", "mode %u, data_rate %ukHz", (unsigned) this->mode_, (unsigned) (this->data_rate_ / 1000));
|
||||
this->delegate_ = this->parent_->register_device(this, this->mode_, this->bit_order_, this->data_rate_, this->cs_,
|
||||
this->release_device_, this->write_only_);
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
this->delegate_->set_psram_dma(this->psram_dma_);
|
||||
if (this->psram_dma_)
|
||||
esph_log_config("spi_device", "PSRAM DMA: enabled");
|
||||
#endif
|
||||
}
|
||||
|
||||
virtual void spi_teardown() {
|
||||
@@ -407,6 +419,9 @@ class SPIClient {
|
||||
bool spi_is_ready() { return this->delegate_->is_ready(); }
|
||||
void set_release_device(bool release) { this->release_device_ = release; }
|
||||
void set_write_only(bool write_only) { this->write_only_ = write_only; }
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
void set_psram_dma(bool enable) { this->psram_dma_ = enable; }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
SPIBitOrder bit_order_{BIT_ORDER_MSB_FIRST};
|
||||
@@ -416,6 +431,9 @@ class SPIClient {
|
||||
GPIOPin *cs_{nullptr};
|
||||
bool release_device_{false};
|
||||
bool write_only_{false};
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
bool psram_dma_{false};
|
||||
#endif
|
||||
SPIDelegate *delegate_{SPIDelegate::NULL_DELEGATE};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
#include "spi.h"
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
#include <esp_memory_utils.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::spi {
|
||||
|
||||
#ifdef USE_ESP32
|
||||
static const char *const TAG = "spi";
|
||||
static const size_t MAX_TRANSFER_SIZE = 4092; // dictated by ESP-IDF API.
|
||||
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
static uint32_t get_psram_dma_flags(bool enabled, const void *tx_buffer) {
|
||||
if (enabled && tx_buffer != nullptr && esp_ptr_dma_ext_capable(tx_buffer))
|
||||
return SPI_TRANS_DMA_USE_PSRAM;
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
class SPIDelegateHw : public SPIDelegate {
|
||||
public:
|
||||
SPIDelegateHw(SPIInterface channel, uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin,
|
||||
@@ -65,8 +77,13 @@ class SPIDelegateHw : public SPIDelegate {
|
||||
return;
|
||||
}
|
||||
spi_transaction_t desc = {};
|
||||
desc.flags = 0;
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
const uint32_t psram_flags = rxbuf == nullptr ? get_psram_dma_flags(this->psram_dma_, txbuf) : 0;
|
||||
#endif
|
||||
while (length != 0) {
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
desc.flags = psram_flags;
|
||||
#endif
|
||||
size_t const partial = std::min(length, MAX_TRANSFER_SIZE);
|
||||
desc.length = partial * 8;
|
||||
desc.rxlength = this->write_only_ ? 0 : partial * 8;
|
||||
@@ -81,6 +98,12 @@ class SPIDelegateHw : public SPIDelegate {
|
||||
ESP_LOGE(TAG, "Transmit failed - err %X", err);
|
||||
break;
|
||||
}
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
if ((desc.flags & SPI_TRANS_DMA_TX_FAIL) != 0) {
|
||||
ESP_LOGE(TAG, "PSRAM DMA TX underflow");
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
length -= partial;
|
||||
if (txbuf != nullptr)
|
||||
txbuf += partial;
|
||||
@@ -133,7 +156,13 @@ class SPIDelegateHw : public SPIDelegate {
|
||||
desc.base.rxlength = 0;
|
||||
desc.base.cmd = cmd;
|
||||
desc.base.addr = address;
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
const uint32_t transaction_flags = desc.base.flags | get_psram_dma_flags(this->psram_dma_, data);
|
||||
#endif
|
||||
do {
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
desc.base.flags = transaction_flags;
|
||||
#endif
|
||||
size_t chunk_size = std::min(length, MAX_TRANSFER_SIZE);
|
||||
if (data != nullptr && chunk_size != 0) {
|
||||
desc.base.length = chunk_size * 8;
|
||||
@@ -152,6 +181,12 @@ class SPIDelegateHw : public SPIDelegate {
|
||||
ESP_LOGE(TAG, "Transmit failed - err %X", err);
|
||||
return;
|
||||
}
|
||||
#ifdef USE_SPI_PSRAM_DMA
|
||||
if ((desc.base.flags & SPI_TRANS_DMA_TX_FAIL) != 0) {
|
||||
ESP_LOGE(TAG, "PSRAM DMA TX underflow");
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// if more data is to be sent, skip the command and address phases.
|
||||
desc.command_bits = 0;
|
||||
desc.address_bits = 0;
|
||||
|
||||
@@ -178,7 +178,8 @@ static int __attribute__((noinline)) days_from_year_start(int year, int month, i
|
||||
}
|
||||
|
||||
time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) {
|
||||
int month, day;
|
||||
int month = 1;
|
||||
int day = 1;
|
||||
|
||||
switch (rule.type) {
|
||||
case DSTRuleType::MONTH_WEEK_DAY: {
|
||||
|
||||
@@ -530,7 +530,7 @@ void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
// Skip logging during roaming scans to avoid log buffer overflow
|
||||
// (roaming scans typically find many networks but only care about same-SSID APs)
|
||||
if (this->roaming_state_ == RoamingState::SCANNING) {
|
||||
if (this->is_roaming_scan_active()) {
|
||||
return;
|
||||
}
|
||||
char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
@@ -833,7 +833,7 @@ void WiFiComponent::loop() {
|
||||
|
||||
// Post-connect roaming: check for better AP
|
||||
if (this->post_connect_roaming_) {
|
||||
if (this->roaming_state_ == RoamingState::SCANNING) {
|
||||
if (this->is_roaming_scan_active()) {
|
||||
if (this->scan_done_) {
|
||||
this->process_roaming_scan_();
|
||||
}
|
||||
@@ -2144,7 +2144,7 @@ void WiFiComponent::retry_connect() {
|
||||
// Roam connection failed - transition to reconnecting
|
||||
ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
|
||||
this->roaming_state_ = RoamingState::RECONNECTING;
|
||||
} else if (this->roaming_state_ == RoamingState::SCANNING) {
|
||||
} else if (this->is_roaming_scan_active()) {
|
||||
// Disconnected during roam scan - transition to RECONNECTING so the attempts
|
||||
// counter is preserved when reconnection succeeds (IDLE would reset it)
|
||||
ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
|
||||
|
||||
@@ -478,6 +478,13 @@ class WiFiComponent final : public Component {
|
||||
|
||||
bool is_connected() const { return this->connected_; }
|
||||
|
||||
/// True while a post-connect roaming scan holds the radio off-channel.
|
||||
bool is_roaming_scan_active() const { return this->roaming_state_ == RoamingState::SCANNING; }
|
||||
|
||||
/// True while a post-connect roam is in progress (scanning off-channel, reassociating,
|
||||
/// or recovering from a failed roam).
|
||||
bool is_roaming() const { return this->roaming_state_ != RoamingState::IDLE; }
|
||||
|
||||
#ifdef USE_ESP32
|
||||
/// esp_netif handle of the station interface, used by network for default-route
|
||||
/// arbitration. nullptr until wifi_lazy_init_() has run.
|
||||
|
||||
@@ -717,7 +717,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
static constexpr uint32_t SCAN_ACTIVE_MAX_DEFAULT_MS = 500;
|
||||
static constexpr uint32_t SCAN_ACTIVE_MIN_ROAMING_MS = 100;
|
||||
static constexpr uint32_t SCAN_ACTIVE_MAX_ROAMING_MS = 300;
|
||||
bool roaming = this->roaming_state_ == RoamingState::SCANNING;
|
||||
bool roaming = this->is_roaming_scan_active();
|
||||
if (passive) {
|
||||
config.scan_time.passive = roaming ? SCAN_PASSIVE_ROAMING_MS : SCAN_PASSIVE_DEFAULT_MS;
|
||||
} else {
|
||||
|
||||
@@ -140,11 +140,6 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi
|
||||
}
|
||||
|
||||
void WiFiComponent::wifi_pre_setup_() {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
if (has_custom_mac_address()) {
|
||||
get_mac_address_raw(mac);
|
||||
set_mac_address(mac);
|
||||
}
|
||||
// Network interface setup handled by network component
|
||||
s_wifi_event_group = xEventGroupCreate();
|
||||
if (s_wifi_event_group == nullptr) {
|
||||
@@ -1064,7 +1059,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
// When scanning while connected (roaming), return to home channel between
|
||||
// each scanned channel to maintain the connection (helps with BLE/WiFi coexistence)
|
||||
#ifdef CONFIG_SOC_WIFI_SUPPORTED
|
||||
if (this->roaming_state_ == RoamingState::SCANNING) {
|
||||
if (this->is_roaming_scan_active()) {
|
||||
config.coex_background_scan = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1882,13 +1882,46 @@ def lambda_(value):
|
||||
return value
|
||||
|
||||
|
||||
# 'return' at a statement boundary; only consulted when the source has no
|
||||
# semicolon, so ';' is not a boundary. Migration use only, see
|
||||
# looks_like_returning_lambda.
|
||||
LAMBDA_RETURN_STATEMENT_PROG = re.compile(r"(?:^|[:{})\n])\s*return\b")
|
||||
LAMBDA_RETURN_KEYWORD_PROG = re.compile(r"\breturn\b")
|
||||
# RESERVED_IDS subset that can begin a return expression; 'this'/'true' would
|
||||
# promote prose and infix 'and'/'or' cannot start an expression.
|
||||
_CPP_LEADING_WORD_OPERATORS = "not|new|sizeof|delete"
|
||||
# Two or more plain words: prose, not C++. A single word is indistinguishable
|
||||
# from 'return x'. Migration use only, see looks_like_returning_lambda.
|
||||
LAMBDA_PROSE_TAIL_PROG = re.compile(
|
||||
rf"(?!(?:{_CPP_LEADING_WORD_OPERATORS})\b)[A-Za-z']+(?:,?\s+[A-Za-z']+)+[.!?]?"
|
||||
)
|
||||
|
||||
|
||||
def looks_like_returning_lambda(value: str) -> bool:
|
||||
"""Check whether a string looks like C++ lambda source: a semicolon means
|
||||
code, so any return keyword counts; without one, a boundary return whose
|
||||
tail does not read as prose is a return statement missing its semicolon.
|
||||
|
||||
For migrating deprecated implicit lambdas only; new validators must
|
||||
require an explicit !lambda tag instead of guessing.
|
||||
"""
|
||||
src = Lambda.comment_remover(value)
|
||||
if ";" in src:
|
||||
return LAMBDA_RETURN_KEYWORD_PROG.search(src) is not None
|
||||
for match in LAMBDA_RETURN_STATEMENT_PROG.finditer(src):
|
||||
tail = src[match.end() :].split("\n", 1)[0].strip()
|
||||
if not LAMBDA_PROSE_TAIL_PROG.fullmatch(tail):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def returning_lambda(value):
|
||||
"""Coerce this configuration option to a lambda.
|
||||
|
||||
Additionally, make sure the lambda returns something.
|
||||
"""
|
||||
value = lambda_(value)
|
||||
if "return" not in value.value:
|
||||
if LAMBDA_RETURN_KEYWORD_PROG.search(Lambda.comment_remover(value.value)) is None:
|
||||
raise Invalid(
|
||||
"Lambda doesn't contain a 'return' statement, but the lambda "
|
||||
"is expected to return a value. \n"
|
||||
|
||||
@@ -339,7 +339,8 @@ class Lambda:
|
||||
self._requires_ids = None
|
||||
|
||||
# https://stackoverflow.com/a/241506/229052
|
||||
def comment_remover(self, text):
|
||||
@staticmethod
|
||||
def comment_remover(text):
|
||||
def replacer(match):
|
||||
s = match.group(0)
|
||||
if s.startswith("/"):
|
||||
|
||||
+1
-10
@@ -55,6 +55,7 @@ from esphome.helpers import (
|
||||
cpp_string_escape,
|
||||
fnv1a_32bit_hash,
|
||||
get_str_env,
|
||||
get_usable_cpu_count,
|
||||
walk_files,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
@@ -205,16 +206,6 @@ def valid_project_name(value: str):
|
||||
return value
|
||||
|
||||
|
||||
def get_usable_cpu_count() -> int:
|
||||
"""Return the number of CPUs that can be used for processes.
|
||||
On Python 3.13+ this is the number of CPUs that can be used for processes.
|
||||
On older Python versions this is the number of CPUs.
|
||||
"""
|
||||
return (
|
||||
os.process_cpu_count() if hasattr(os, "process_cpu_count") else os.cpu_count()
|
||||
)
|
||||
|
||||
|
||||
if "ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT" in os.environ:
|
||||
_compile_process_limit_default = min(
|
||||
int(os.environ["ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT"]), get_usable_cpu_count()
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
#define USE_SPEAKER
|
||||
#define USE_SPEAKER_MEDIA_PLAYER_ON_OFF
|
||||
#define USE_SPI
|
||||
#define USE_SPI_PSRAM_DMA
|
||||
#define USE_VOICE_ASSISTANT
|
||||
#define USE_WEBSERVER
|
||||
#define USE_WEBSERVER_AUTH
|
||||
@@ -536,6 +537,8 @@
|
||||
|
||||
#ifdef USE_HOST
|
||||
#define USE_HTTP_REQUEST_RESPONSE
|
||||
// Host only: the uart arm would shadow the native logger UART arms in other envs
|
||||
#define USE_IMPROV_SERIAL_UART
|
||||
#define USE_SOCKET_IMPL_BSD_SOCKETS
|
||||
#define USE_ESPHOME_TASK_LOG_BUFFER
|
||||
#define ESPHOME_TASK_LOG_BUFFER_SIZE 64
|
||||
|
||||
@@ -2089,6 +2089,11 @@ const char *get_mac_address_pretty_into_buffer(std::span<char, MAC_ADDRESS_PRETT
|
||||
#ifdef USE_ESP32
|
||||
/// Set the MAC address to use from the provided byte array (6 bytes).
|
||||
void set_mac_address(uint8_t *mac);
|
||||
|
||||
/// Read the custom MAC address from eFuse into the provided byte array (6 bytes).
|
||||
/// Must not use the ESPHome logger (may run before it is initialized); IDF itself may still log.
|
||||
/// @return True if a valid custom MAC address was read; on false, the contents of mac are undefined.
|
||||
bool get_custom_mac_address(uint8_t *mac);
|
||||
#endif
|
||||
|
||||
/// Check if a custom MAC address is set (ESP32 & variants)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from ctypes.util import find_library
|
||||
from functools import partial
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -24,16 +23,17 @@ from esphome.framework_helpers import (
|
||||
create_venv,
|
||||
download_and_extract,
|
||||
download_from_mirrors,
|
||||
download_with_resume,
|
||||
failure_reason,
|
||||
get_python_env_executable_path,
|
||||
get_system_python_path,
|
||||
resume_fetch_job,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
run_command,
|
||||
run_command_ok,
|
||||
str_to_lst_of_str,
|
||||
tool_version_runs,
|
||||
warn_prefetch_failures,
|
||||
)
|
||||
from esphome.helpers import write_file_if_changed
|
||||
|
||||
@@ -686,18 +686,6 @@ def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _download_tool(
|
||||
dist_path: Path, entry: dict, tracker: Callable[[int], None]
|
||||
) -> None:
|
||||
download_with_resume(
|
||||
entry["url"],
|
||||
dist_path / entry["dest"],
|
||||
sha256=entry["sha256"],
|
||||
size=entry["size"],
|
||||
progress=tracker,
|
||||
)
|
||||
|
||||
|
||||
def _prefetch_idf_tool_archives(
|
||||
framework_path: Path,
|
||||
targets_str: str,
|
||||
@@ -775,15 +763,17 @@ def _prefetch_idf_tool_archives(
|
||||
(
|
||||
entry["name"],
|
||||
entry["size"],
|
||||
partial(_download_tool, dist_path, entry),
|
||||
resume_fetch_job(
|
||||
entry["url"],
|
||||
dist_path / entry["dest"],
|
||||
sha256=entry["sha256"],
|
||||
size=entry["size"],
|
||||
),
|
||||
)
|
||||
for entry in entries
|
||||
],
|
||||
)
|
||||
for name, e in failures:
|
||||
# failure_reason: a message-less exception must not log blank
|
||||
_LOGGER.warning("Could not prefetch %s: %s", name, failure_reason(e))
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=e)
|
||||
warn_prefetch_failures(failures)
|
||||
if len(failures) == len(entries):
|
||||
# A systematic fault, not one flaky mirror: the resume
|
||||
# workaround (#17703) is off for this whole install
|
||||
|
||||
@@ -701,7 +701,7 @@ def _write_download_meta(
|
||||
_LOGGER.debug("Could not update download metadata %s: %s", meta, e)
|
||||
|
||||
|
||||
def _content_length(resp: "requests.Response") -> int:
|
||||
def content_length(resp: "requests.Response") -> int:
|
||||
"""Return the response's Content-Length, or 0 when absent or malformed.
|
||||
|
||||
0 means "unknown", which downstream disables the progress bar and the
|
||||
@@ -744,7 +744,7 @@ def _stream_response_to_file(
|
||||
"""
|
||||
f.seek(offset)
|
||||
f.truncate(offset)
|
||||
total_size = size or offset + _content_length(resp)
|
||||
total_size = size or offset + content_length(resp)
|
||||
downloaded = offset
|
||||
own_bar: ProgressBar | None = None
|
||||
if progress is None:
|
||||
@@ -909,6 +909,19 @@ def _part_path(dest: Path) -> Path:
|
||||
return dest.with_name(dest.name + ".part")
|
||||
|
||||
|
||||
def discard_partial_download(dest: Path) -> None:
|
||||
"""Remove ``dest`` and the resume sidecars of an abandoned download."""
|
||||
part = _part_path(dest)
|
||||
for stale in (dest, part, part.with_name(part.name + ".meta")):
|
||||
try:
|
||||
stale.unlink()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as err:
|
||||
# The caller's cache is never pruned; leave a trace
|
||||
_LOGGER.debug("Could not remove %s: %s", stale, err)
|
||||
|
||||
|
||||
def _cancellable_sleep(
|
||||
delay: float, progress: Callable[[int], None] | None, done: int
|
||||
) -> None:
|
||||
@@ -922,6 +935,31 @@ def _cancellable_sleep(
|
||||
time.sleep(min(0.5, remaining))
|
||||
|
||||
|
||||
def resume_fetch_job(
|
||||
url: str, dest: PathType, **kwargs
|
||||
) -> Callable[[Callable[[int], None]], None]:
|
||||
"""A ``run_batch_downloads`` job callable wrapping ``download_with_resume``.
|
||||
|
||||
Forwards the runner's positional tracker as the ``progress`` keyword.
|
||||
"""
|
||||
|
||||
def fetch(tracker: Callable[[int], None]) -> None:
|
||||
download_with_resume(url, dest, progress=tracker, **kwargs)
|
||||
|
||||
return fetch
|
||||
|
||||
|
||||
def warn_prefetch_failures(
|
||||
failures: list[tuple[str, BaseException]],
|
||||
message: str = "Could not prefetch %s: %s",
|
||||
) -> None:
|
||||
"""Warn per failed batch-prefetch job; the caller's installer retries them."""
|
||||
for name, err in failures:
|
||||
# failure_reason: a message-less exception must not log blank
|
||||
_LOGGER.warning(message, name, failure_reason(err))
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=err)
|
||||
|
||||
|
||||
def download_with_resume(
|
||||
url: str,
|
||||
dest: PathType,
|
||||
@@ -1022,7 +1060,7 @@ def download_with_resume(
|
||||
streamed = True
|
||||
if offset == 0:
|
||||
validator = _response_validator(resp)
|
||||
expected_total = _content_length(resp)
|
||||
expected_total = content_length(resp)
|
||||
# Recorded so a later run can prove an If-Range
|
||||
# resume of this part file safe.
|
||||
_write_download_meta(meta, url, validator, expected_total)
|
||||
|
||||
@@ -402,6 +402,15 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]:
|
||||
return [socket.getnameinfo(r[4], socket.NI_NUMERICHOST)[0] for r in res]
|
||||
|
||||
|
||||
def get_usable_cpu_count() -> int:
|
||||
"""Return the number of CPUs usable by this process (affinity-aware
|
||||
on Python 3.13+); 1 when the count is undeterminable."""
|
||||
count = (
|
||||
os.process_cpu_count() if hasattr(os, "process_cpu_count") else os.cpu_count()
|
||||
)
|
||||
return count or 1
|
||||
|
||||
|
||||
def get_bool_env(var, default=False):
|
||||
"""Read a boolean env var: the ``cv.boolean`` spellings plus ``1``/``0``;
|
||||
anything else falls through to ``bool(value)``."""
|
||||
|
||||
@@ -35,6 +35,7 @@ from esphome.framework_helpers import (
|
||||
failure_reason,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
warn_prefetch_failures,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -1038,14 +1039,10 @@ def _prefetch_wave(
|
||||
for c in components
|
||||
],
|
||||
)
|
||||
for name, err in failures:
|
||||
# The sequential call below retries and raises the real error
|
||||
_LOGGER.warning(
|
||||
"Prefetch of %s failed (retrying sequentially): %s",
|
||||
name,
|
||||
failure_reason(err),
|
||||
)
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=err)
|
||||
# The sequential call below retries and raises the real error
|
||||
warn_prefetch_failures(
|
||||
failures, "Prefetch of %s failed (retrying sequentially): %s"
|
||||
)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# Same policy as the ESP-IDF twin: the prefetch must never become a
|
||||
# new way for the build to fail
|
||||
|
||||
@@ -0,0 +1,968 @@
|
||||
"""Parallel prefetch and install of the packages a PlatformIO run needs.
|
||||
|
||||
Downloads the archives concurrently into PlatformIO's own download cache
|
||||
(identical ``compute_download_path`` keys), then installs them through
|
||||
PlatformIO's own ``_install`` with one worker per usable core, so
|
||||
extraction (the serial, single-core half of a cold install) parallelizes
|
||||
too and ``pio run`` finds every package already installed. Runs in a
|
||||
subprocess like all PlatformIO execution: loading a platform executes
|
||||
its code (pioarduino's penv setup rewrites ``sys.path``). A sentinel in
|
||||
the build dir lets warm builds skip the spawn. Best-effort: any failure
|
||||
logs and PlatformIO downloads and installs as before. Across processes
|
||||
sharing a core dir every download destination is serialized by a file
|
||||
lock; checksum-less URL downloads additionally stage under a stable
|
||||
name and promote with an atomic rename.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import suppress
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from queue import SimpleQueue
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from esphome.framework_helpers import (
|
||||
content_length,
|
||||
discard_partial_download,
|
||||
failure_reason,
|
||||
resume_fetch_job,
|
||||
run_batch_downloads,
|
||||
warn_prefetch_failures,
|
||||
)
|
||||
from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Concurrent registry resolutions / HEAD probes (each is network-bound)
|
||||
_RESOLVE_WORKERS = 8
|
||||
|
||||
# A hung child must not block the build; downloads resume on the next run
|
||||
_PREFETCH_TIMEOUT = 20 * 60
|
||||
|
||||
# Waiting on another process's URL download; past this, leave it to pio
|
||||
_DOWNLOAD_LOCK_TIMEOUT = 60
|
||||
|
||||
# Child exit for a handled, already-warned failure; 1 would collide with
|
||||
# the interpreter's own import-failure exit
|
||||
_EXIT_HANDLED = 3
|
||||
|
||||
# Short lock-acquire slices so a waiting worker still observes Ctrl-C
|
||||
_URI_LOCK_POLL = 1
|
||||
|
||||
# Resolution errored (vs a clean skip); suppresses the warm sentinel
|
||||
_RESOLVE_FAILED = object()
|
||||
|
||||
|
||||
def _sweep_stale_sidecars(download_dir: Path, expire_seconds: int) -> None:
|
||||
"""Prune resume sidecars pio's usage.db pruner cannot see.
|
||||
|
||||
A version bump strands an aborted archive's sidecars forever. Lock
|
||||
files stay: a held lock can carry an ancient mtime (O_TRUNC keeps
|
||||
it), and unlinking one reopens the single-writer hole it guards.
|
||||
"""
|
||||
cutoff = time.time() - expire_seconds
|
||||
try:
|
||||
for f in download_dir.iterdir():
|
||||
if f.suffix not in (".part", ".meta", ".prefetch"):
|
||||
continue
|
||||
try:
|
||||
if f.stat().st_mtime < cutoff:
|
||||
f.unlink()
|
||||
except OSError as err:
|
||||
_LOGGER.debug("Could not remove %s: %s", f, err)
|
||||
except OSError:
|
||||
_LOGGER.debug("Could not sweep %s", download_dir, exc_info=True)
|
||||
|
||||
|
||||
class _Resolved(NamedTuple):
|
||||
"""A registry spec resolved to its archive; ``cached`` skips the download."""
|
||||
|
||||
spec: Any
|
||||
name: str
|
||||
size: int
|
||||
url: str
|
||||
dl_path: Path
|
||||
checksum: str
|
||||
cached: bool
|
||||
|
||||
|
||||
# Child records a no-work run; the parent skips the next spawn while valid
|
||||
_SENTINEL_NAME = ".esphome_prefetch.json"
|
||||
_SENTINEL_SCHEMA = 1
|
||||
|
||||
|
||||
def _ini_sha256(build_dir: Path) -> str:
|
||||
return hashlib.sha256((build_dir / "platformio.ini").read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _sentinel_state(build_dir: Path) -> dict[str, Any]:
|
||||
"""The environment fingerprint a sentinel must match to stay valid."""
|
||||
# Same fingerprint as the heal stamp: the sentinel's dirs die with its wipe
|
||||
from esphome.platformio.toolchain import current_python_minor
|
||||
|
||||
return {
|
||||
"schema": _SENTINEL_SCHEMA,
|
||||
"ini_sha256": _ini_sha256(build_dir),
|
||||
"python": current_python_minor(),
|
||||
"core_dir_env": os.environ.get("PLATFORMIO_CORE_DIR", ""),
|
||||
}
|
||||
|
||||
|
||||
def _prefetch_is_warm(build_dir: Path) -> bool:
|
||||
"""Whether the last prefetch found nothing to do and nothing changed since."""
|
||||
try:
|
||||
data = json.loads((build_dir / _SENTINEL_NAME).read_text(encoding="utf-8"))
|
||||
dirs = data.pop("dirs")
|
||||
return (
|
||||
data == _sentinel_state(build_dir)
|
||||
and bool(dirs)
|
||||
and all(Path(d).is_dir() for d in dirs)
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except (OSError, ValueError, KeyError, AttributeError, TypeError):
|
||||
_LOGGER.debug("Ignoring invalid prefetch sentinel", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def prefetch_platformio_packages() -> None:
|
||||
"""Warm PlatformIO's download cache for the current project, in parallel."""
|
||||
from esphome.core import CORE
|
||||
from esphome.platformio.toolchain import (
|
||||
default_libdeps_dir,
|
||||
heal_platformio_python_env,
|
||||
)
|
||||
|
||||
# Heal first: its Python-version wipe would discard freshly warmed
|
||||
# caches and the sentinel's dirs (the later heal call is a no-op)
|
||||
heal_platformio_python_env()
|
||||
build_dir = Path(CORE.build_path)
|
||||
if _prefetch_is_warm(build_dir):
|
||||
return
|
||||
# The child is esphome itself: PYTHONPATH stays so it imports this
|
||||
# tree's esphome (tests/integration pins the source tree through it)
|
||||
env = dict(os.environ)
|
||||
# Must match run_platformio_cli's default or warm builds re-resolve
|
||||
# every library
|
||||
env.setdefault("PLATFORMIO_LIBDEPS_DIR", default_libdeps_dir())
|
||||
# -v/-vv must reach the child's debug logging or the swallowed
|
||||
# failure detail is undiagnosable in the field
|
||||
env["ESPHOME_PREFETCH_LOG_LEVEL"] = str(logging.getLogger().getEffectiveLevel())
|
||||
if CORE.dashboard:
|
||||
# The child's progress bar and log escaping key off CORE.dashboard
|
||||
env["ESPHOME_PREFETCH_DASHBOARD"] = "1"
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"esphome.platformio.prefetch",
|
||||
str(build_dir),
|
||||
CORE.name,
|
||||
]
|
||||
try:
|
||||
# Not a with-block: the lifetime spans the wait/terminate arms
|
||||
proc = subprocess.Popen(cmd, env=env) # pylint: disable=consider-using-with
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# The prefetch must never become a new way for the build to fail
|
||||
_LOGGER.warning("PlatformIO package prefetch skipped: %s", failure_reason(err))
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=True)
|
||||
return
|
||||
try:
|
||||
returncode = proc.wait(timeout=_PREFETCH_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
_stop_child(proc)
|
||||
_LOGGER.warning("PlatformIO package prefetch timed out; continuing without it")
|
||||
return
|
||||
except BaseException as err:
|
||||
# SIGKILL (subprocess.run's choice on interrupt) could land inside
|
||||
# a package-directory copy pio run would then trust; ask first
|
||||
_stop_child(proc)
|
||||
if isinstance(err, Exception):
|
||||
# An unexpected wait() failure must degrade, not fail the build
|
||||
_LOGGER.warning(
|
||||
"PlatformIO package prefetch skipped: %s", failure_reason(err)
|
||||
)
|
||||
return
|
||||
raise
|
||||
if returncode == _EXIT_HANDLED:
|
||||
# The child already warned with the reason; a second line is noise
|
||||
_LOGGER.debug("Prefetch child reported a handled failure")
|
||||
elif returncode != 0:
|
||||
# Exit 1 stays here: the interpreter exits 1 for import/module
|
||||
# failures before main() ever runs, a wiring break worth a warning
|
||||
_LOGGER.warning("PlatformIO package prefetch skipped (exit %d)", returncode)
|
||||
|
||||
|
||||
def _stop_child(proc: subprocess.Popen) -> None:
|
||||
"""Stop the child without cutting an in-flight package install short.
|
||||
|
||||
Wait first (a terminal interrupt already unwinds the child), then
|
||||
SIGTERM for the clean unwind main() installs, then kill. On Windows
|
||||
terminate() cannot reach the handler, so its arm is a plain wait.
|
||||
"""
|
||||
if proc.poll() is None:
|
||||
_LOGGER.info("Waiting for the prefetch child to finish its current install")
|
||||
try:
|
||||
with suppress(subprocess.TimeoutExpired):
|
||||
proc.wait(timeout=5)
|
||||
return
|
||||
if sys.platform != "win32":
|
||||
proc.terminate()
|
||||
with suppress(subprocess.TimeoutExpired):
|
||||
proc.wait(timeout=30)
|
||||
return
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
# The kill can land mid-copy; the uncertainty must be visible
|
||||
_LOGGER.warning("Prefetch child killed; a package install may be incomplete")
|
||||
except KeyboardInterrupt:
|
||||
# Kill so an interrupted stop cannot orphan a still-writing child
|
||||
# (BaseException: a further interrupt must not skip the kill),
|
||||
# then re-raise so the build aborts
|
||||
with suppress(BaseException):
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
if proc.poll() is None:
|
||||
_LOGGER.warning("The prefetch child could not be confirmed stopped")
|
||||
raise
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# A surviving child may still be writing packages pio run trusts
|
||||
_LOGGER.warning("The prefetch child could not be confirmed stopped")
|
||||
_LOGGER.debug("Stop detail", exc_info=True)
|
||||
|
||||
|
||||
def _project_platform_and_config(ini: Path, env: str) -> tuple[str | None, Any]:
|
||||
"""The env's platform spec and the ProjectConfig for the given ini."""
|
||||
from platformio import app
|
||||
from platformio.project.config import ProjectConfig
|
||||
|
||||
# PlatformBase.config reads the default ProjectConfig; it must see
|
||||
# this ini's env options
|
||||
app.set_session_var("custom_project_conf", str(ini))
|
||||
config = ProjectConfig.get_instance(str(ini))
|
||||
return config.get(f"env:{env}", "platform", None), config
|
||||
|
||||
|
||||
def _sibling_manager(manager: Any) -> Any:
|
||||
"""A same-store manager equivalent to the shared one."""
|
||||
# Hard read: a renamed attribute must fail loudly, not silently drop
|
||||
# the qualifiers wave-1 installs resolve with; is-not-None so a falsy
|
||||
# PackageCompatibility still propagates
|
||||
if (compatibility := manager.compatibility) is not None:
|
||||
return manager.__class__(manager.package_dir, compatibility=compatibility)
|
||||
return manager.__class__(manager.package_dir)
|
||||
|
||||
|
||||
def _registry_jobs(
|
||||
manager: Any, specs: list[Any], seen: set[str]
|
||||
) -> tuple[list[tuple[str, int, Any]], int, list[tuple[str, Any]]]:
|
||||
"""Resolve registry specs to ``(name, size, fetch)`` batch jobs.
|
||||
|
||||
Mirrors PlatformIO's install path: best version, systype file, first
|
||||
mirror, and the same sha1(url + checksum) download-cache key. Also
|
||||
returns how many resolutions errored (a clean skip is not an error)
|
||||
and the ``(name, spec)`` pairs whose archives will be installable.
|
||||
"""
|
||||
from platformio.registry.mirror import RegistryFileMirrorIterator
|
||||
|
||||
local = threading.local()
|
||||
errors: list[str] = []
|
||||
|
||||
def _resolve(spec) -> _Resolved | object | None:
|
||||
# One manager (and registry HTTP session) per worker thread;
|
||||
# installed-state was already checked on the shared manager
|
||||
if (mgr := getattr(local, "mgr", None)) is None:
|
||||
mgr = local.mgr = _sibling_manager(manager)
|
||||
try:
|
||||
packages = mgr.search_registry_packages(spec)
|
||||
if not packages:
|
||||
_LOGGER.debug("%s is unknown to the registry", spec)
|
||||
return None # let PlatformIO report it
|
||||
package, version = mgr.find_best_registry_version(packages, spec)
|
||||
if not package or not version:
|
||||
_LOGGER.debug("%s has no matching registry version", spec)
|
||||
return None
|
||||
pkgfile = mgr.pick_compatible_pkg_file(version["files"])
|
||||
if not pkgfile:
|
||||
_LOGGER.debug("%s has no file for this systype", spec)
|
||||
return None
|
||||
url, checksum = next(RegistryFileMirrorIterator(pkgfile["download_url"]))
|
||||
checksum = checksum or pkgfile["checksum"]["sha256"]
|
||||
dl_path = Path(mgr.compute_download_path(url, checksum))
|
||||
cached = dl_path.is_file() # fetched by an earlier run
|
||||
size = pkgfile.get("size")
|
||||
if not cached and not size:
|
||||
_LOGGER.debug("%s has no size; PlatformIO fetches it", spec)
|
||||
return None # no size, no bar share
|
||||
name = f"{package['name']}@{version['name']}"
|
||||
return _Resolved(spec, name, size or 0, url, dl_path, checksum, cached)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# One flaky spec must not discard the rest of the batch
|
||||
_LOGGER.debug("Could not resolve %s", spec, exc_info=True)
|
||||
errors.append(failure_reason(err))
|
||||
return _RESOLVE_FAILED
|
||||
|
||||
# Serial disk lookups on the shared manager: a fully warm build
|
||||
# resolves nothing, and duplicate specs resolve once
|
||||
unique: dict[tuple[str | None, str, str], Any] = {}
|
||||
for s in specs:
|
||||
if not s.uri and not manager.get_package(s):
|
||||
unique.setdefault((s.owner, s.name, str(s.requirements)), s)
|
||||
pending = list(unique.values())
|
||||
if not pending:
|
||||
return [], 0, []
|
||||
# Serial resolutions (registry GET + mirror HEAD each) dominate
|
||||
with ThreadPoolExecutor(max_workers=min(_RESOLVE_WORKERS, len(pending))) as ex:
|
||||
results = list(ex.map(_resolve, pending))
|
||||
jobs: list[tuple[str, int, Any]] = []
|
||||
installable: list[tuple[str, Any]] = []
|
||||
for res in results:
|
||||
if res is None or res is _RESOLVE_FAILED:
|
||||
continue
|
||||
installable.append((res.name, res.spec))
|
||||
if res.cached or str(res.dl_path) in seen:
|
||||
continue # already fetched, or a duplicate must not share a .part
|
||||
seen.add(str(res.dl_path))
|
||||
jobs.append(
|
||||
(
|
||||
res.name,
|
||||
res.size,
|
||||
_registry_fetch_job(
|
||||
manager, res.url, res.dl_path, res.checksum, res.size
|
||||
),
|
||||
)
|
||||
)
|
||||
if failed := len(errors):
|
||||
# Visible once per build, naming a cause so an API break does not
|
||||
# read as an outage; per-spec detail stays at debug
|
||||
_LOGGER.warning(
|
||||
"Could not resolve %d of %d PlatformIO package(s) (%s); "
|
||||
"PlatformIO will download them serially",
|
||||
failed,
|
||||
len(pending),
|
||||
errors[0],
|
||||
)
|
||||
return jobs, failed, installable
|
||||
|
||||
|
||||
def _uri_jobs(
|
||||
manager: Any, specs: list[Any], seen: set[str]
|
||||
) -> tuple[list[tuple[str, int, Any]], int, list[tuple[str, Any]]]:
|
||||
"""Jobs for direct-URL specs; a HEAD sizes each for the combined bar.
|
||||
|
||||
Also returns how many HEAD probes errored (an absent length is not an
|
||||
error) and the ``(name, spec)`` pairs whose archives will be
|
||||
installable.
|
||||
"""
|
||||
from esphome.net_retry import fetch_with_retry, http_request
|
||||
|
||||
candidates: list[tuple[str, str, Path, Any]] = []
|
||||
installable: list[tuple[str, Any]] = []
|
||||
for spec in specs:
|
||||
url = spec.uri
|
||||
if not url or not url.startswith(("http://", "https://")):
|
||||
continue # git+/file specs are cloned/copied, not downloaded
|
||||
if url.split("#", 1)[0].endswith(".git"):
|
||||
continue # bare-URL VCS spec; PlatformIO clones it
|
||||
if manager.get_package(spec):
|
||||
continue
|
||||
name = spec.name or url.rsplit("/", 1)[-1]
|
||||
# PlatformIO downloads URL specs with no checksum
|
||||
dl_path = Path(manager.compute_download_path(url, ""))
|
||||
if dl_path.is_file():
|
||||
if spec.has_custom_name():
|
||||
# Only a custom name (Foo=https://...) is the destination
|
||||
# dir; a URI-derived name's destination comes from the
|
||||
# archive manifest, so its dedupe key could collide with
|
||||
# another name and race one directory. pio run installs it.
|
||||
installable.append((name, spec)) # fetched by an earlier run
|
||||
continue
|
||||
if str(dl_path) in seen:
|
||||
continue # another spec already claimed this .part
|
||||
seen.add(str(dl_path))
|
||||
candidates.append((spec.name, url, dl_path, spec))
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
def _head_size(url: str) -> int:
|
||||
try:
|
||||
resp = fetch_with_retry(url, lambda: http_request("HEAD", url, timeout=30))
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
_LOGGER.debug("HEAD %s failed", url, exc_info=True)
|
||||
errors.append(failure_reason(err))
|
||||
return -1
|
||||
if not resp.ok:
|
||||
# An error page's Content-Length is not a download size
|
||||
_LOGGER.debug("HEAD %s returned %s", url, resp.status_code)
|
||||
if resp.status_code in (401, 403, 408, 429) or resp.status_code >= 500:
|
||||
# 401/403 included: registries rate-limit with them
|
||||
errors.append(f"HTTP {resp.status_code}")
|
||||
return -1 # transient; must not be cached as warm
|
||||
# Permanent (405/501 HEAD-unsupported, 401/403/404): a clean
|
||||
# skip so the warm sentinel is not disabled forever; pio run
|
||||
# surfaces a genuinely broken URL when it downloads
|
||||
return 0
|
||||
return content_length(resp)
|
||||
|
||||
if not candidates:
|
||||
return [], 0, installable
|
||||
with ThreadPoolExecutor(max_workers=min(_RESOLVE_WORKERS, len(candidates))) as ex:
|
||||
sizes = list(ex.map(_head_size, [url for _, url, _, _ in candidates]))
|
||||
jobs: list[tuple[str, int, Any]] = []
|
||||
failed = 0
|
||||
for (name, url, dl_path, spec), size in zip(candidates, sizes, strict=True):
|
||||
if size < 0:
|
||||
failed += 1
|
||||
elif size:
|
||||
jobs.append((name, size, _uri_fetch_job(manager, url, dl_path, size)))
|
||||
if spec.has_custom_name():
|
||||
# See above: derived-name specs stay with pio run's installer
|
||||
installable.append((name, spec))
|
||||
else:
|
||||
# Missing or unusable Content-Length; visible under -v
|
||||
_LOGGER.debug("%s reports no usable length; PlatformIO fetches it", url)
|
||||
if failed:
|
||||
_LOGGER.warning(
|
||||
"Could not size %d of %d PlatformIO package URL(s) (%s); "
|
||||
"PlatformIO will download them serially",
|
||||
failed,
|
||||
len(candidates),
|
||||
errors[0],
|
||||
)
|
||||
return jobs, failed, installable
|
||||
|
||||
|
||||
def _serialized_fetch_job(
|
||||
dl_path: Path, lock_path: str, body: Any, unlocked_ok: bool = True
|
||||
) -> Any:
|
||||
"""Wrap ``body`` so the shared destination is single-writer.
|
||||
|
||||
Interleaved writers truncate each other's ``.part`` bytes (see
|
||||
registry.py). The bounded poll observes Ctrl-C via the tracker; a
|
||||
blown deadline is a clean skip (the holder's copy is what the build
|
||||
needs). On a lock-less filesystem a sha256-verified body runs
|
||||
unlocked with one warning; a checksum-less one
|
||||
(``unlocked_ok=False``) is a counted failure instead.
|
||||
"""
|
||||
|
||||
def run(tracker: Any) -> None:
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
# fallback_to_soft would leave a stale marker on lock-less
|
||||
# filesystems that blocks every later build (see git.py)
|
||||
lock = FileLock(lock_path, fallback_to_soft=False)
|
||||
deadline = time.monotonic() + _DOWNLOAD_LOCK_TIMEOUT
|
||||
while True:
|
||||
try:
|
||||
lock.acquire(timeout=_URI_LOCK_POLL)
|
||||
break
|
||||
except Timeout:
|
||||
tracker(0) # raises when the batch is cancelled
|
||||
if time.monotonic() >= deadline:
|
||||
# Another process is fetching this same file; its copy
|
||||
# is what the build needs (a large framework archive
|
||||
# can hold the lock far longer than this deadline)
|
||||
_LOGGER.debug("Leaving %s to its current downloader", dl_path.name)
|
||||
return
|
||||
except OSError as err:
|
||||
if not unlocked_ok:
|
||||
# A body with no checksum to catch interleaved corruption
|
||||
raise
|
||||
lock = None
|
||||
_LOGGER.warning(
|
||||
"Could not lock %s (%s); downloading unlocked",
|
||||
dl_path.name,
|
||||
err,
|
||||
)
|
||||
break
|
||||
try:
|
||||
if dl_path.is_file():
|
||||
return # another process finished it while we waited
|
||||
body(tracker)
|
||||
finally:
|
||||
if lock is not None:
|
||||
lock.release()
|
||||
|
||||
return run
|
||||
|
||||
|
||||
# usage.db is a whole-file rewrite behind pio's self-unlinking LockFile;
|
||||
# concurrent writers could reset every recorded entry
|
||||
_REGISTER_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _register_download(manager: Any, dl_path: Path) -> None:
|
||||
"""Hand the archive to pio's usage.db pruner; an unregistered one is
|
||||
never expired (disk garbage, never a bad build)."""
|
||||
try:
|
||||
with _REGISTER_LOCK:
|
||||
manager.set_download_utime(str(dl_path))
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
_LOGGER.debug("Could not register %s with pio's cache: %s", dl_path, err)
|
||||
|
||||
|
||||
def _registry_fetch_job(
|
||||
manager: Any, url: str, dl_path: Path, checksum: str, size: int
|
||||
) -> Any:
|
||||
"""A locked fetch straight to the cache path; sha256 verifies it."""
|
||||
# .esphome.lock: pio's own LockFile(dl_path) owns <dl_path>.lock and
|
||||
# deletes it on release, which would unlink a held filelock
|
||||
fetch = _serialized_fetch_job(
|
||||
dl_path,
|
||||
f"{dl_path}.esphome.lock",
|
||||
resume_fetch_job(url, dl_path, sha256=checksum, size=size),
|
||||
)
|
||||
|
||||
def run(tracker: Any) -> None:
|
||||
fetch(tracker)
|
||||
if dl_path.is_file():
|
||||
# The deadline skip can end with no archive landed
|
||||
_register_download(manager, dl_path)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _uri_fetch_job(manager: Any, url: str, dl_path: Path, size: int) -> Any:
|
||||
"""Fetch to a locked staging path, then rename into the cache.
|
||||
|
||||
The stable staging name keeps resume working across interrupted
|
||||
runs; the rename makes the promotion atomic.
|
||||
"""
|
||||
tmp = dl_path.with_name(f"{dl_path.name}.prefetch")
|
||||
# attempts=2: the size is only a HEAD probe's word, and a HEAD/GET
|
||||
# disagreement would otherwise re-download the archive five times
|
||||
fetch = resume_fetch_job(url, tmp, size=size, attempts=2)
|
||||
|
||||
def promote(tracker: Any) -> None:
|
||||
fetch(tracker)
|
||||
if (actual := tmp.stat().st_size) != size:
|
||||
# A wrong-length checksum-less body must never be published
|
||||
discard_partial_download(tmp)
|
||||
raise ValueError(f"expected {size} bytes, fetched {actual}")
|
||||
tmp.replace(dl_path)
|
||||
|
||||
def run(tracker: Any) -> None:
|
||||
_serialized_fetch_job(dl_path, f"{tmp}.lock", promote, unlocked_ok=False)(
|
||||
tracker
|
||||
)
|
||||
if dl_path.is_file():
|
||||
# Won or lost, the race is over; staging files left behind
|
||||
# are dead weight PlatformIO's cache never prunes
|
||||
discard_partial_download(tmp)
|
||||
_register_download(manager, dl_path)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
# (name, spec) from wave 1, (name, spec, compatibility) from dep waves
|
||||
_Entry = tuple[str, Any] | tuple[str, Any, Any]
|
||||
|
||||
|
||||
def _dependency_entries(
|
||||
manager: Any, entries: list[_Entry], seen_names: set[str]
|
||||
) -> list[_Entry]:
|
||||
"""Registry dependencies of the installed entries, one per new name.
|
||||
|
||||
Mostly local manifest reads; the builtin probe walks installed
|
||||
platforms (each may run platform code). Name-only platform libs stay
|
||||
with pio run.
|
||||
"""
|
||||
|
||||
# Hard read: losing this filter would pre-install incompatible
|
||||
# packages pio run then trusts
|
||||
compatibility = manager.compatibility
|
||||
# Tool managers have no builtin table; the contract test pins the name
|
||||
is_builtin = getattr(manager, "is_builtin_lib", None)
|
||||
deps: dict[str, Any] = {}
|
||||
skipped = 0
|
||||
for name, spec, *_ in entries:
|
||||
try:
|
||||
deps_of = _entry_dependencies(manager, spec, compatibility, is_builtin)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# One unreadable manifest must not drop the group's whole wave
|
||||
_LOGGER.debug("Skipping dependencies of %s", name, exc_info=True)
|
||||
skipped += 1
|
||||
continue
|
||||
for key, entry in deps_of:
|
||||
if key not in seen_names:
|
||||
deps.setdefault(key, entry)
|
||||
if skipped:
|
||||
# Visible at default verbosity: a dropped subtree silently
|
||||
# degrades the wave; per-entry detail stays at debug
|
||||
_LOGGER.warning(
|
||||
"Could not read dependencies of %d of %d package(s)",
|
||||
skipped,
|
||||
len(entries),
|
||||
)
|
||||
return list(deps.values())
|
||||
|
||||
|
||||
def _entry_dependencies(
|
||||
manager: Any, spec: Any, compatibility: Any, is_builtin: Any
|
||||
) -> list[tuple[str, _Entry]]:
|
||||
from platformio.package.meta import PackageCompatibility
|
||||
|
||||
out: list[tuple[str, _Entry]] = []
|
||||
if (pkg := manager.get_package(spec)) is None:
|
||||
# Only successful installs are walked, so this is a real anomaly
|
||||
# (stale memcache, name/dir mismatch, a pio API change); raising
|
||||
# folds it into the caller's aggregate dropped-subtree warning
|
||||
raise RuntimeError(f"just-installed {spec} is not resolvable")
|
||||
for dep in manager.get_pkg_dependencies(pkg) or []:
|
||||
if not (dep.get("owner") or dep.get("version")):
|
||||
continue
|
||||
if compatibility and not PackageCompatibility.from_dependency(
|
||||
dep
|
||||
).is_compatible(compatibility):
|
||||
continue # pio's install_dependency would skip it too
|
||||
dspec = manager.dependency_to_spec(dep)
|
||||
if (
|
||||
is_builtin
|
||||
and not dspec.owner
|
||||
and not dspec.external
|
||||
and is_builtin(dspec.name)
|
||||
):
|
||||
# pio's LibraryPackageManager.install_dependency skips
|
||||
# builtins; a registry copy would shadow the bundled one
|
||||
continue
|
||||
if not (key := (dspec.name or "").lower()):
|
||||
_LOGGER.debug("Dependency %r of %s has no name; left to pio run", dep, spec)
|
||||
continue
|
||||
if manager.get_package(dspec) is not None:
|
||||
continue # already installed
|
||||
# Carry the dep's compatibility so _install searches the
|
||||
# registry qualified, exactly like pio's install_dependency
|
||||
out.append(
|
||||
(key, (dspec.name, dspec, PackageCompatibility.from_dependency(dep)))
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _clean_failed_install(mgr: Any, name: str, spec: Any) -> None:
|
||||
# A post-copy failure leaves a package pio run would trust; remove it
|
||||
# so pio run genuinely reinstalls it
|
||||
try:
|
||||
mgr.memcache_reset()
|
||||
if (pkg := mgr.get_package(spec)) is not None:
|
||||
# Dropping the metadata is the invariant: pio's own install
|
||||
# overwrites a metadata-less dir, so a stuck tree cannot be
|
||||
# trusted. The rmtree is best-effort tidiness.
|
||||
(Path(pkg.path) / ".piopm").unlink(missing_ok=True)
|
||||
with suppress(OSError):
|
||||
rmtree(pkg.path)
|
||||
else:
|
||||
# Nothing was moved into place; the common failure shape
|
||||
_LOGGER.debug("No on-disk install of %s to remove", name)
|
||||
except Exception as cleanup_err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
_LOGGER.warning(
|
||||
"Could not remove the failed install of %s: %s",
|
||||
name,
|
||||
failure_reason(cleanup_err),
|
||||
)
|
||||
|
||||
|
||||
def _preinstall(
|
||||
manager: Any, entries: list[_Entry], seen_names: set[str] | None = None
|
||||
) -> None:
|
||||
"""Install downloaded packages in parallel via pio's own ``_install``.
|
||||
|
||||
``entries`` are ``_Entry`` tuples, one per destination directory.
|
||||
The lock is held around each wave's pool, safe only because pio's
|
||||
private ``_install`` never re-acquires it (a same-process re-lock
|
||||
would hang, not fail). Waves skip dependencies; the installed
|
||||
manifests feed the next wave. Any failure falls back to pio run.
|
||||
"""
|
||||
workers = min(get_usable_cpu_count(), len(entries))
|
||||
# One manager per worker (_install mutates instance state); built
|
||||
# serially because construction rewires the shared manager logger
|
||||
managers: SimpleQueue = SimpleQueue()
|
||||
for _ in range(workers):
|
||||
managers.put(_sibling_manager(manager))
|
||||
local = threading.local()
|
||||
|
||||
def _install_one(entry) -> bool:
|
||||
# Wave-1 entries are (name, spec); dependency waves add compatibility
|
||||
name, spec, *rest = entry
|
||||
compat = rest[0] if rest else None
|
||||
if (mgr := getattr(local, "mgr", None)) is None:
|
||||
# at most `workers` pool threads, one dequeue each
|
||||
mgr = local.mgr = managers.get_nowait()
|
||||
try:
|
||||
mgr._install( # pylint: disable=protected-access # noqa: SLF001
|
||||
spec, skip_dependencies=True, compatibility=compat
|
||||
)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
_LOGGER.warning("Could not pre-install %s: %s", name, failure_reason(err))
|
||||
_LOGGER.debug("Pre-install failure detail", exc_info=True)
|
||||
_clean_failed_install(mgr, name, spec)
|
||||
return False
|
||||
except BaseException:
|
||||
# A SystemExit from a postinstall must not skip the cleanup
|
||||
# and leave a torn dir pio run trusts
|
||||
_clean_failed_install(mgr, name, spec)
|
||||
raise
|
||||
|
||||
_LOGGER.info(
|
||||
"Installing %d PlatformIO package(s) with %d extraction worker(s): %s",
|
||||
len(entries),
|
||||
workers,
|
||||
", ".join(name for name, *_ in entries),
|
||||
)
|
||||
# Postinstall scripts chdir process-globally; the cwd is restored
|
||||
# after the pool. Concurrent postinstalls can still race pio's
|
||||
# non-reentrant fs.cd mid-pool; that install fails, warns, and is
|
||||
# redone serially by pio run. Suppress interleaved progress bars.
|
||||
os.environ.setdefault("PLATFORMIO_DISABLE_PROGRESSBAR", "true")
|
||||
# get_tmp_dir/get_download_dir create without exist_ok; racing workers
|
||||
# would FileExistsError, so create them serially first. Concurrent
|
||||
# usage.db updates can drop download bookkeeping; never a bad build.
|
||||
manager.get_tmp_dir()
|
||||
manager.get_download_dir()
|
||||
cwd = Path.cwd()
|
||||
manager.lock()
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
try:
|
||||
results = list(ex.map(_install_one, entries))
|
||||
except BaseException:
|
||||
# Drop queued installs; in-flight ones finish so no
|
||||
# package directory is left half copied
|
||||
ex.shutdown(wait=True, cancel_futures=True)
|
||||
raise
|
||||
finally:
|
||||
# Cleanup must not mask an in-flight exception or skip a step
|
||||
# Each step runs even if an earlier one fails, and none may
|
||||
# displace the in-flight exception (SIGTERM's SystemExit
|
||||
# included) with a downgradeable one
|
||||
wave_ok = True
|
||||
for step, label in (
|
||||
(manager.memcache_reset, "reset the storage cache"),
|
||||
(manager.unlock, "release the manager lock"),
|
||||
(lambda: os.chdir(cwd), "restore the working dir"),
|
||||
):
|
||||
try:
|
||||
step()
|
||||
except Exception: # noqa: BLE001,PERF203 # pylint: disable=broad-exception-caught
|
||||
wave_ok = False
|
||||
_LOGGER.warning("Could not %s", label)
|
||||
_LOGGER.debug("Teardown detail", exc_info=True)
|
||||
if len(entries) > 1 and not any(results):
|
||||
# A systematic fault, not one bad archive; pio run installs serially
|
||||
_LOGGER.warning(
|
||||
"Could not pre-install any of %d PlatformIO package(s)", len(entries)
|
||||
)
|
||||
|
||||
seen = seen_names if seen_names is not None else set()
|
||||
# All entries join seen (failures must not be re-queued); only
|
||||
# successful installs feed the dependency walk
|
||||
seen.update(name.split("@", 1)[0].lower() for name, *_ in entries)
|
||||
installed = [e for e, ok in zip(entries, results, strict=True) if ok]
|
||||
if not wave_ok:
|
||||
# A stale cache, an unknown lock state, or a lost cwd would
|
||||
# poison the next wave; pio run installs the rest cleanly
|
||||
_LOGGER.warning("Skipping the dependency wave")
|
||||
return
|
||||
# The builtin probe may construct platforms whose setup rewrites
|
||||
# sys.path (see _prefetch); restore it for later imports
|
||||
saved_sys_path = list(sys.path)
|
||||
try:
|
||||
next_entries = _dependency_entries(manager, installed, seen)
|
||||
finally:
|
||||
sys.path[:] = saved_sys_path
|
||||
if next_entries:
|
||||
# Terminates without a cap: every wave admits only never-seen
|
||||
# names, so a cycle yields an empty next wave
|
||||
_preinstall(manager, next_entries, seen)
|
||||
|
||||
|
||||
def _prefetch(build_dir: Path, env: str) -> None:
|
||||
from platformio.dependencies import get_core_dependencies
|
||||
from platformio.package.manager.library import LibraryPackageManager
|
||||
from platformio.package.manager.platform import PlatformPackageManager
|
||||
from platformio.package.meta import PackageCompatibility, PackageSpec
|
||||
from platformio.platform.factory import PlatformFactory
|
||||
|
||||
platform_spec, config = _project_platform_and_config(
|
||||
build_dir / "platformio.ini", env
|
||||
)
|
||||
if not platform_spec:
|
||||
# An env mismatch must not disable the feature with no trace
|
||||
_LOGGER.debug(
|
||||
"No platform for env %s in %s; nothing to prefetch", env, build_dir
|
||||
)
|
||||
return
|
||||
|
||||
# The platform (manifest plus build scripts) installs first and
|
||||
# resolves the rest. Its setup may rewrite sys.path (pioarduino's penv
|
||||
# setup does); restore it so later imports here still resolve.
|
||||
saved_sys_path = list(sys.path)
|
||||
pm = PlatformPackageManager()
|
||||
_sweep_stale_sidecars(Path(pm.get_download_dir()), pm.DOWNLOAD_CACHE_EXPIRE)
|
||||
pkg = pm.install(platform_spec, skip_dependencies=True)
|
||||
p = PlatformFactory.new(pkg)
|
||||
p.configure_project_packages(env, ["run"])
|
||||
sys.path[:] = saved_sys_path
|
||||
|
||||
specs = [
|
||||
p.get_package_spec(name)
|
||||
for name, opts in p.packages.items()
|
||||
if not opts.get("optional")
|
||||
]
|
||||
# PIO's build engine installs outside the platform package list;
|
||||
# skipped when the platform lists it itself
|
||||
if not any(s.name == "tool-scons" for s in specs):
|
||||
specs.append(
|
||||
PackageSpec(
|
||||
owner="platformio",
|
||||
name="tool-scons",
|
||||
requirements=get_core_dependencies()["tool-scons"],
|
||||
)
|
||||
)
|
||||
lib_deps = config.get(f"env:{env}", "lib_deps", [])
|
||||
# pio run's storage dir for this env, with its compatibility
|
||||
# qualifiers: an unqualified library install could land a different
|
||||
# owner's package pio run would then trust
|
||||
qualifiers: dict[str, Any] = {"platforms": [p.name]}
|
||||
if framework := config.get(f"env:{env}", "framework", None):
|
||||
qualifiers["frameworks"] = framework
|
||||
libdeps_dir = Path(config.get("platformio", "libdeps_dir")) / env
|
||||
lm = LibraryPackageManager(
|
||||
str(libdeps_dir), compatibility=PackageCompatibility(**qualifiers)
|
||||
)
|
||||
# A bare name is usually a framework built-in (WiFi, SPI); with no
|
||||
# lib builders here to tell built-in from registry, skip it. The only
|
||||
# cost is that an owner-less user library is not prefetched
|
||||
lib_specs = [
|
||||
spec
|
||||
for dep in lib_deps
|
||||
if dep and not dep.startswith("$")
|
||||
if (spec := PackageSpec(dep)).external or spec.owner
|
||||
]
|
||||
|
||||
seen: set[str] = set()
|
||||
jobs: list[tuple[str, int, Any]] = []
|
||||
groups: list[tuple[Any, list[tuple[str, Any]]]] = []
|
||||
unresolved = 0
|
||||
for mgr, batch in ((p.pm, specs), (lm, lib_specs)):
|
||||
entries: list[tuple[str, Any]] = []
|
||||
for build_jobs in (_registry_jobs, _uri_jobs):
|
||||
batch_jobs, failed, installable = build_jobs(mgr, batch, seen)
|
||||
jobs += batch_jobs
|
||||
unresolved += failed
|
||||
entries += installable
|
||||
if entries:
|
||||
groups.append((mgr, entries))
|
||||
|
||||
sentinel = build_dir / _SENTINEL_NAME
|
||||
if jobs or groups:
|
||||
# Real work invalidates any previous no-work record
|
||||
sentinel.unlink(missing_ok=True)
|
||||
failed_names: set[str] = set()
|
||||
if jobs:
|
||||
_LOGGER.info(
|
||||
"Prefetching %d PlatformIO package(s): %s",
|
||||
len(jobs),
|
||||
", ".join(name for name, _, _ in jobs),
|
||||
)
|
||||
# PlatformIO retries failed packages itself, without resume
|
||||
failures = run_batch_downloads("Downloading PlatformIO packages", jobs)
|
||||
warn_prefetch_failures(failures)
|
||||
failed_names = {name for name, _ in failures}
|
||||
elif not groups and not unresolved:
|
||||
# Record the no-work run so the parent skips the next spawn.
|
||||
# A failed resolution is not "no work": a registry outage must
|
||||
# not be cached as warm.
|
||||
dirs = [config.get("platformio", "packages_dir")]
|
||||
if lib_specs:
|
||||
dirs.append(str(libdeps_dir))
|
||||
sentinel.write_text(
|
||||
json.dumps({**_sentinel_state(build_dir), "dirs": dirs}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
for mgr, entries in groups:
|
||||
# One install per destination: pio derives the directory from
|
||||
# the package name, so key on the name part
|
||||
to_install = {
|
||||
name.split("@", 1)[0].lower(): (name, spec)
|
||||
for name, spec in entries
|
||||
if name not in failed_names
|
||||
}
|
||||
if to_install:
|
||||
try:
|
||||
_preinstall(mgr, list(to_install.values()))
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# Each group degrades independently; pio run installs
|
||||
# whatever this one did not
|
||||
_LOGGER.warning(
|
||||
"Pre-install failed for the %s group: %s",
|
||||
mgr.__class__.__name__,
|
||||
failure_reason(err),
|
||||
)
|
||||
_LOGGER.debug("Pre-install group failure detail", exc_info=True)
|
||||
|
||||
|
||||
def _sigterm(_signum, _frame) -> None:
|
||||
# Raised in the main thread: the pool's BaseException arm cancels
|
||||
# queued installs while in-flight copies finish, then finally runs
|
||||
raise SystemExit(143)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Subprocess entry point: ``prefetch <build_dir> <env_name>``."""
|
||||
from esphome.core import CORE
|
||||
from esphome.log import setup_log
|
||||
|
||||
signal.signal(signal.SIGTERM, _sigterm)
|
||||
raw_level = os.environ.get("ESPHOME_PREFETCH_LOG_LEVEL")
|
||||
try:
|
||||
level = int(raw_level) if raw_level is not None else logging.INFO
|
||||
except ValueError:
|
||||
level = logging.INFO
|
||||
# Mirror the parent's log setup: warnings keep their level prefix and
|
||||
# color, and the download bar still draws under the dashboard
|
||||
CORE.dashboard = get_bool_env("ESPHOME_PREFETCH_DASHBOARD")
|
||||
setup_log(level)
|
||||
# pio's managers attach their own handler and still propagate; without
|
||||
# this every manager line also prints through the root handler. Their
|
||||
# construction re-pins the logger to INFO, so a logger-level filter
|
||||
# (which survives pio's handler reset) enforces a quiet level instead.
|
||||
for cls_name in (
|
||||
"ToolPackageManager",
|
||||
"LibraryPackageManager",
|
||||
"PlatformPackageManager",
|
||||
):
|
||||
manager_logger = logging.getLogger(cls_name.replace("Package", " "))
|
||||
manager_logger.propagate = False
|
||||
manager_logger.addFilter(lambda record: record.levelno >= level)
|
||||
if len(argv) != 2:
|
||||
# A wiring bug, not a network failure; make it distinguishable
|
||||
_LOGGER.warning("prefetch usage: <build_dir> <env_name>")
|
||||
return 2
|
||||
build_dir, env = argv
|
||||
try:
|
||||
_prefetch(Path(build_dir), env)
|
||||
except KeyboardInterrupt:
|
||||
# Shared process group: exit quietly, no traceback on the terminal
|
||||
_LOGGER.debug("Prefetch interrupted", exc_info=True)
|
||||
return 130
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# The parent treats any exit as warn-and-continue, never a failure
|
||||
_LOGGER.warning("PlatformIO package prefetch skipped: %s", failure_reason(err))
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=True)
|
||||
return _EXIT_HANDLED
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -96,7 +96,7 @@ def _clean_platformio_python_env(config: "ProjectConfig", core_dir: Path) -> Non
|
||||
rmtree(penv)
|
||||
|
||||
|
||||
def _current_python_minor() -> str:
|
||||
def current_python_minor() -> str:
|
||||
"""Return the running interpreter's ``major.minor`` (e.g. ``3.13``)."""
|
||||
return f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
|
||||
@@ -161,7 +161,7 @@ def heal_platformio_python_env() -> None:
|
||||
|
||||
def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
|
||||
"""Compare the stamp to the running interpreter; wipe and restamp on mismatch."""
|
||||
current = _current_python_minor()
|
||||
current = current_python_minor()
|
||||
stamp_dir = _pio_stamp_dir(config)
|
||||
# Host the stamp/lock even before PlatformIO's first run creates the dir.
|
||||
stamp_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -289,6 +289,12 @@ def copy_ccache_script() -> None:
|
||||
)
|
||||
|
||||
|
||||
def default_libdeps_dir() -> str:
|
||||
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
|
||||
prefetch must resolve installed libraries against the same dir."""
|
||||
return str(CORE.relative_piolibdeps_path().absolute())
|
||||
|
||||
|
||||
def run_platformio_cli(*args, **kwargs) -> str | int:
|
||||
# Re-provision the PlatformIO cache if the interpreter's major.minor changed
|
||||
# since it was last built; a stale platform otherwise rejects the new Python
|
||||
@@ -296,9 +302,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
|
||||
heal_platformio_python_env()
|
||||
os.environ["PLATFORMIO_FORCE_COLOR"] = "true"
|
||||
os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute())
|
||||
os.environ.setdefault(
|
||||
"PLATFORMIO_LIBDEPS_DIR", str(CORE.relative_piolibdeps_path().absolute())
|
||||
)
|
||||
os.environ.setdefault("PLATFORMIO_LIBDEPS_DIR", default_libdeps_dir())
|
||||
# Suppress Python syntax warnings from third-party scripts during compilation
|
||||
os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning")
|
||||
# Increase uv retry count to handle transient network errors (default is 3)
|
||||
@@ -346,6 +350,9 @@ def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int:
|
||||
|
||||
|
||||
def run_compile(config, verbose):
|
||||
from esphome.platformio.prefetch import prefetch_platformio_packages
|
||||
|
||||
prefetch_platformio_packages()
|
||||
args = []
|
||||
if CONF_COMPILE_PROCESS_LIMIT in config[CONF_ESPHOME]:
|
||||
args += [f"-j{config[CONF_ESPHOME][CONF_COMPILE_PROCESS_LIMIT]}"]
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@ lib_deps =
|
||||
${common.lib_deps_base}
|
||||
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
|
||||
esphome/noise-c@0.1.21 ; noise (api, ota)
|
||||
improv/Improv@1.2.6 ; improv_serial / esp32_improv
|
||||
improv/Improv@1.2.7 ; improv_serial / esp32_improv
|
||||
kikuchan98/pngle@1.1.0 ; online_image
|
||||
; Using the repository directly, otherwise ESP-IDF can't use the library
|
||||
https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image
|
||||
@@ -248,7 +248,7 @@ lib_deps =
|
||||
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
|
||||
DNSServer ; captive_portal
|
||||
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
|
||||
improv/Improv@1.2.6 ; improv_serial
|
||||
improv/Improv@1.2.7 ; improv_serial
|
||||
kikuchan98/pngle@1.1.0 ; online_image
|
||||
https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image
|
||||
build_flags =
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.3.1
|
||||
click==8.3.3
|
||||
aioesphomeapi==46.2.0
|
||||
aioesphomeapi==46.2.1
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
zeroconf==0.150.0
|
||||
puremagic==2.2.0
|
||||
@@ -29,7 +29,7 @@ requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.11.3 # native esp-idf toolchain global cache dir
|
||||
ninja==1.13.0 # native esp8266 arduino toolchain build driver
|
||||
filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
filelock==3.32.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
|
||||
# esp-idf >= 5.0 requires this
|
||||
pyparsing >= 3.3.2
|
||||
|
||||
@@ -247,6 +247,8 @@ def lint_ext_check(fname):
|
||||
"CLAUDE.md",
|
||||
"GEMINI.md",
|
||||
".github/copilot-instructions.md",
|
||||
# Symlink to the real wifi scan_list.h so the test stub cannot drift
|
||||
"tests/integration/fixtures/external_components/wifi/scan_list.h",
|
||||
]
|
||||
)
|
||||
def lint_executable_bit(fname: Path) -> str | None:
|
||||
|
||||
@@ -3,58 +3,375 @@
|
||||
# all platformio libraries in the global storage
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import configparser
|
||||
from contextlib import suppress
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
config = configparser.ConfigParser(inline_comment_prefixes=(";",))
|
||||
# esphome is not installed at this docker layer; pio's fs.rmtree is the
|
||||
# same chmod-on-readonly shape its own installer uses
|
||||
try:
|
||||
from platformio import fs
|
||||
from platformio.cache import ContentCache
|
||||
from platformio.package.manager.base import BasePackageManager
|
||||
from platformio.package.manager.library import LibraryPackageManager
|
||||
from platformio.package.manager.tool import ToolPackageManager
|
||||
from platformio.package.meta import PackageCompatibility
|
||||
|
||||
parser = argparse.ArgumentParser(description="")
|
||||
parser.add_argument("file", help="Path to platformio.ini", nargs=1)
|
||||
parser.add_argument("-l", "--libraries", help="Install libraries", action="store_true")
|
||||
parser.add_argument("-p", "--platforms", help="Install platforms", action="store_true")
|
||||
parser.add_argument("-t", "--tools", help="Install tools", action="store_true")
|
||||
PARALLEL_AVAILABLE = True
|
||||
except ImportError as err: # pragma: no cover
|
||||
# A moved pio module must degrade to the serial pass, not kill the
|
||||
# image build; the tripwire test makes the drift loud in CI
|
||||
PARALLEL_AVAILABLE = False
|
||||
IMPORT_ERROR = repr(err)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
config.read(args.file)
|
||||
# Network-bound downloads release the GIL, so the pool oversubscribes
|
||||
# the cores. This bypasses pio's 500ms registry throttle and races its
|
||||
# self-unlinking cache LockFiles; both are cache-only and self-healing.
|
||||
MAX_WORKERS = 16
|
||||
|
||||
|
||||
libs = []
|
||||
tools = []
|
||||
platforms = []
|
||||
# Extract from every lib_deps key in all sections
|
||||
for section in config.sections():
|
||||
conf = config[section]
|
||||
if "lib_deps" in conf and args.libraries:
|
||||
for lib_dep in conf["lib_deps"].splitlines():
|
||||
if not lib_dep:
|
||||
# Empty line or comment
|
||||
continue
|
||||
if lib_dep.startswith("${"):
|
||||
# Extending from another section
|
||||
continue
|
||||
if "@" not in lib_dep:
|
||||
# No version pinned, this is an internal lib
|
||||
continue
|
||||
libs.append("-l")
|
||||
libs.append(lib_dep)
|
||||
if "platform" in conf and args.platforms:
|
||||
platforms.append("-p")
|
||||
platforms.append(conf["platform"])
|
||||
if "platform_packages" in conf and args.tools:
|
||||
for tool in conf["platform_packages"].splitlines():
|
||||
if not tool:
|
||||
# Empty line or comment
|
||||
continue
|
||||
if tool.startswith("${"):
|
||||
# Extending from another section
|
||||
continue
|
||||
if tool.find("https://github.com") != -1:
|
||||
split = tool.find("@")
|
||||
tool = tool[split + 1 :]
|
||||
tools.append("-t")
|
||||
tools.append(tool)
|
||||
class CleanupError(RuntimeError):
|
||||
"""A torn destination could not be removed; the serial pass would
|
||||
trust it, so the build must fail rather than bake a corrupt image."""
|
||||
|
||||
subprocess.check_call(
|
||||
["platformio", "pkg", "install", "-g", *libs, *platforms, *tools], close_fds=False
|
||||
)
|
||||
|
||||
class LockReleaseError(RuntimeError):
|
||||
"""The manager lock could not be released; the serial pass would
|
||||
block on it, so the build must fail with the cause named."""
|
||||
|
||||
|
||||
def parse_specs(path: str, args: argparse.Namespace) -> tuple[list, list, list]:
|
||||
"""Extract lib/platform/tool specs from every section of a platformio.ini."""
|
||||
config = configparser.ConfigParser(inline_comment_prefixes=(";",))
|
||||
if not config.read(path):
|
||||
# ConfigParser silently ignores unreadable files; an empty spec
|
||||
# list would build an image with no dependencies at all
|
||||
raise SystemExit(f"Could not read {path}")
|
||||
libs = []
|
||||
tools = []
|
||||
platforms = []
|
||||
for section in config.sections():
|
||||
conf = config[section]
|
||||
if "lib_deps" in conf and args.libraries:
|
||||
for lib_dep in conf["lib_deps"].splitlines():
|
||||
if not lib_dep:
|
||||
# Empty line or comment
|
||||
continue
|
||||
if lib_dep.startswith("${"):
|
||||
# Extending from another section
|
||||
continue
|
||||
if "@" not in lib_dep:
|
||||
# No version pinned, this is an internal lib
|
||||
continue
|
||||
libs.append(lib_dep)
|
||||
if "platform" in conf and args.platforms:
|
||||
platforms.append(conf["platform"])
|
||||
if "platform_packages" in conf and args.tools:
|
||||
for tool in conf["platform_packages"].splitlines():
|
||||
if not tool:
|
||||
# Empty line or comment
|
||||
continue
|
||||
if tool.startswith("${"):
|
||||
# Extending from another section
|
||||
continue
|
||||
if tool.find("https://github.com") != -1:
|
||||
split = tool.find("@")
|
||||
tool = tool[split + 1 :]
|
||||
tools.append(tool)
|
||||
# Exact-string dedupe only: name-level dedupe would change which
|
||||
# version conflicts the pkg install pass reconciles
|
||||
return (
|
||||
list(dict.fromkeys(libs)),
|
||||
list(dict.fromkeys(platforms)),
|
||||
list(dict.fromkeys(tools)),
|
||||
)
|
||||
|
||||
|
||||
def piopm_matches(package_dir: str, spec) -> list[Path]:
|
||||
"""Dirs whose .piopm metadata names this spec; a positive match beats
|
||||
guessing the manifest-derived dirname from the registry name."""
|
||||
want = (BasePackageManager.ensure_spec(spec).name or "").lower()
|
||||
matches: list[Path] = []
|
||||
if not want:
|
||||
return matches
|
||||
try:
|
||||
entries = list(Path(package_dir).iterdir())
|
||||
except FileNotFoundError:
|
||||
return matches
|
||||
for d in entries:
|
||||
if not d.is_dir():
|
||||
continue # pio's get_installed skips files and *.pio-link too
|
||||
try:
|
||||
meta = fs.load_json(str(d / ".piopm"))
|
||||
except FileNotFoundError:
|
||||
continue # no metadata means pio does not trust it either
|
||||
except (OSError, ValueError):
|
||||
if d.name.lower() == want:
|
||||
# A corrupt .piopm under this spec's own name would crash
|
||||
# pio's whole storage scan; remove it
|
||||
matches.append(d)
|
||||
continue
|
||||
mspec = meta.get("spec") or {}
|
||||
if (mspec.get("name") or meta.get("name") or "").lower() == want:
|
||||
matches.append(d)
|
||||
return matches
|
||||
|
||||
|
||||
def remove_dir(spec, dest: Path) -> None:
|
||||
# fs.rmtree never raises (errors go to a printing onexc handler);
|
||||
# only the destination's absence proves the cleanup worked
|
||||
fs.rmtree(str(dest))
|
||||
if dest.exists():
|
||||
# Failing the build beats baking a corrupt image
|
||||
raise CleanupError(
|
||||
f"could not remove the failed pre-install of {spec} at {dest}"
|
||||
)
|
||||
print(f"Removed torn destination {dest}", flush=True)
|
||||
|
||||
|
||||
def cleanup_or_die(mgr, spec) -> None:
|
||||
"""Cleanup that did not demonstrably succeed must fail the build."""
|
||||
try:
|
||||
clean_torn(mgr, spec)
|
||||
except CleanupError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
raise CleanupError(f"cleanup failed for {spec}: {err!r}") from err
|
||||
|
||||
|
||||
def clean_torn(mgr, spec) -> None:
|
||||
"""Remove a torn destination so the serial pass cannot trust it."""
|
||||
pkg = None
|
||||
with suppress(Exception):
|
||||
# get_package memoizes a pre-install snapshot; reset to see the
|
||||
# torn dir. It also recognizes manifest-only legacy dirs pio's
|
||||
# storage scan would trust, which the .piopm fallback cannot see.
|
||||
mgr.memcache_reset()
|
||||
pkg = mgr.get_package(spec)
|
||||
if pkg is not None:
|
||||
remove_dir(spec, Path(pkg.path))
|
||||
elif dests := piopm_matches(mgr.package_dir, spec):
|
||||
# A .piopm naming this spec is the exact shape the serial pass
|
||||
# trusts; a dir without one is overwritten by pio's own install
|
||||
for dest in dests:
|
||||
remove_dir(spec, dest)
|
||||
else:
|
||||
print(f"No resolvable destination to clean for {spec}", flush=True)
|
||||
|
||||
|
||||
def spec_key(spec) -> str | None:
|
||||
"""The destination identity of a spec: PlatformIO installs by package
|
||||
name, so two specs sharing a name share a directory. ``None`` means
|
||||
the name could not be derived; such a spec must stay out of the wave
|
||||
(a raw-string key would break the one-per-destination guarantee)."""
|
||||
name = BasePackageManager.ensure_spec(spec).name
|
||||
return name.lower() if name else None
|
||||
|
||||
|
||||
def dependency_specs(manager, specs: list) -> list:
|
||||
"""``(spec, compatibility)`` registry dependencies of installed
|
||||
packages, from local manifest reads. Name-only dependencies
|
||||
(platform-bundled libs like SPI) stay with the ``pkg install`` pass;
|
||||
the compatibility qualifiers mirror pio's install_dependency, so a
|
||||
qualified dep resolves to the same package the serial pass picks."""
|
||||
return [
|
||||
(manager.dependency_to_spec(dep), PackageCompatibility.from_dependency(dep))
|
||||
for spec in specs
|
||||
if (pkg := manager.get_package(spec)) is not None
|
||||
for dep in manager.get_pkg_dependencies(pkg) or []
|
||||
if dep.get("owner") or dep.get("version")
|
||||
]
|
||||
|
||||
|
||||
def parallel_install(manager_cls, specs: list, prior_names: set | None = None) -> None:
|
||||
"""Best-effort parallel top-level install.
|
||||
|
||||
PlatformIO's own installer downloads and unpacks one package at a time
|
||||
on one core. Dependencies are skipped (two packages sharing one must
|
||||
not extract into the same directory from two threads) and failures are
|
||||
only reported: the stock ``pkg install`` pass afterwards installs
|
||||
whatever is missing and is the authority on the final state.
|
||||
"""
|
||||
if not specs:
|
||||
return
|
||||
manager = manager_cls(None)
|
||||
# One spec per destination: two threads must not extract into the
|
||||
# same directory. Second versions of a name and URL specs (their dir
|
||||
# comes from the archive manifest) stay with the pkg install pass.
|
||||
seen_names: set = prior_names if prior_names is not None else set()
|
||||
# Wave-1 items are strings; dependency waves carry (spec, compatibility)
|
||||
pairs = [item if isinstance(item, tuple) else (item, None) for item in specs]
|
||||
unique = {}
|
||||
for spec, compat in pairs:
|
||||
# Normalize once: a dependency's URL version surfaces as spec.uri
|
||||
parsed = BasePackageManager.ensure_spec(spec)
|
||||
if parsed.uri:
|
||||
continue
|
||||
if (key := spec_key(parsed)) is None:
|
||||
# No name, no destination identity; leave it to the serial pass
|
||||
print(f"Skipping unresolvable spec {spec!r} in the wave", flush=True)
|
||||
continue
|
||||
unique.setdefault(key, (spec, compat)) # first-wins, like pio's walk
|
||||
pending = [
|
||||
(spec, compat)
|
||||
for spec, compat in unique.values()
|
||||
if not manager.get_package(spec)
|
||||
]
|
||||
if not pending:
|
||||
# Nothing to install, but a warm store's dependencies must still
|
||||
# feed the next wave (a transitive dep may be missing)
|
||||
_next_wave(manager_cls, manager, unique, seen_names)
|
||||
return
|
||||
workers = min(len(pending), MAX_WORKERS)
|
||||
# One manager per worker (_install mutates instance state); built
|
||||
# serially because construction rewires the shared manager logger
|
||||
managers: queue.SimpleQueue = queue.SimpleQueue()
|
||||
for _ in range(workers):
|
||||
managers.put(manager_cls(None))
|
||||
local = threading.local()
|
||||
|
||||
def install_one(item) -> bool:
|
||||
spec, compat = item
|
||||
if (mgr := getattr(local, "mgr", None)) is None:
|
||||
mgr = local.mgr = managers.get_nowait()
|
||||
try:
|
||||
mgr._install( # noqa: SLF001
|
||||
spec, skip_dependencies=True, compatibility=compat
|
||||
)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"Pre-install of {spec} failed ({err!r})", flush=True)
|
||||
cleanup_or_die(mgr, spec)
|
||||
return False
|
||||
except BaseException:
|
||||
# A worker SystemExit (main() guards against it) must not skip
|
||||
# the cleanup and leave a torn dir the serial pass trusts
|
||||
cleanup_or_die(mgr, spec)
|
||||
raise
|
||||
|
||||
print(f"Preinstalling {len(pending)} package(s) with {workers} workers", flush=True)
|
||||
# The serial getter calls create pio's lazy dirs (made without
|
||||
# exist_ok) before cold-cache workers can race the creation
|
||||
manager.get_download_dir()
|
||||
manager.get_tmp_dir()
|
||||
ContentCache("http")
|
||||
cwd = Path.cwd()
|
||||
manager.lock()
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
futures = [ex.submit(install_one, item) for item in pending]
|
||||
# The with-block joined every future; drain them all so a
|
||||
# concurrent CleanupError is never dropped
|
||||
errors = [err for f in futures if (err := f.exception()) is not None]
|
||||
for err in errors:
|
||||
# Every failure is on the record; the raised one is a summary
|
||||
print(f"Wave failure: {err!r}", flush=True)
|
||||
if errors:
|
||||
raise next((e for e in errors if isinstance(e, CleanupError)), errors[0])
|
||||
results = [f.result() for f in futures]
|
||||
finally:
|
||||
try:
|
||||
manager.unlock()
|
||||
except Exception as unlock_err: # noqa: BLE001
|
||||
# A held flock would hang the serial pass in another process;
|
||||
# failing loudly beats an unexplained stuck docker build. Any
|
||||
# in-flight error stays attached as the context.
|
||||
raise LockReleaseError(
|
||||
f"could not release the manager lock: {unlock_err!r}"
|
||||
) from unlock_err
|
||||
# Worker postinstall scripts chdir process-wide (pio's fs.cd);
|
||||
# restore between waves. The serial pass pins its own cwd.
|
||||
with suppress(OSError):
|
||||
os.chdir(cwd)
|
||||
if failures := len(results) - sum(results):
|
||||
# The stock pass retries CLI specs and re-walks installed
|
||||
# packages' dependencies, so failed deps retry too
|
||||
print(
|
||||
f"Pre-install failed for {failures} of {len(results)} package(s); "
|
||||
"pkg install retries them serially",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Waves skip dependencies (a shared one must not extract from two
|
||||
# threads); the installed manifests feed the next wave
|
||||
_next_wave(manager_cls, manager, unique, seen_names)
|
||||
|
||||
|
||||
def _next_wave(manager_cls, manager, unique: dict, seen_names: set) -> None:
|
||||
"""Queue the dependency wave for every requested spec, installed or
|
||||
freshly waved; a warm store can still be missing a transitive dep.
|
||||
Terminates without a cap: each wave admits only never-seen names."""
|
||||
seen_names.update(unique)
|
||||
# The pre-wave get_package calls memoized an empty storage snapshot
|
||||
manager.memcache_reset()
|
||||
next_specs = [
|
||||
item
|
||||
for item in dependency_specs(manager, [spec for spec, _ in unique.values()])
|
||||
if spec_key(item[0]) not in seen_names
|
||||
]
|
||||
if next_specs:
|
||||
parallel_install(manager_cls, next_specs, seen_names)
|
||||
|
||||
|
||||
def build_cli_args(libs: list, platforms: list, tools: list) -> list:
|
||||
return [
|
||||
arg
|
||||
for flag, specs in (("-l", libs), ("-p", platforms), ("-t", tools))
|
||||
for spec in specs
|
||||
for arg in (flag, spec)
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="")
|
||||
parser.add_argument("file", help="Path to platformio.ini", nargs=1)
|
||||
parser.add_argument(
|
||||
"-l", "--libraries", help="Install libraries", action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--platforms", help="Install platforms", action="store_true"
|
||||
)
|
||||
parser.add_argument("-t", "--tools", help="Install tools", action="store_true")
|
||||
args = parser.parse_args()
|
||||
start_cwd = Path.cwd()
|
||||
libs, platforms, tools = parse_specs(args.file[0], args)
|
||||
|
||||
# Platforms stay serial: PlatformPackageManager.install runs an
|
||||
# on_installed hook the private _install path would skip
|
||||
if PARALLEL_AVAILABLE:
|
||||
wave_groups = [(ToolPackageManager, tools), (LibraryPackageManager, libs)]
|
||||
else: # pragma: no cover
|
||||
wave_groups = []
|
||||
print(
|
||||
f"PlatformIO layout changed ({IMPORT_ERROR}); serial install only",
|
||||
flush=True,
|
||||
)
|
||||
for manager_cls, specs in wave_groups:
|
||||
try:
|
||||
parallel_install(manager_cls, specs)
|
||||
except (CleanupError, LockReleaseError, KeyboardInterrupt):
|
||||
# A torn package or a held lock must fail the build
|
||||
raise
|
||||
except BaseException: # noqa: BLE001
|
||||
# BaseException: a worker postinstall's SystemExit must not
|
||||
# skip the authoritative serial pass (partial deps, exit 0)
|
||||
print("Parallel preinstall failed, falling back to serial", flush=True)
|
||||
traceback.print_exc()
|
||||
|
||||
# Postinstall scripts chdir process-wide (pio's fs.cd captures its
|
||||
# restore path at construction); pin the authoritative pass's cwd
|
||||
subprocess.check_call(
|
||||
["platformio", "pkg", "install", "-g", *build_cli_args(libs, platforms, tools)],
|
||||
close_fds=False,
|
||||
cwd=start_cwd,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -49,7 +49,7 @@ static void Encode_ListEntitiesSensorResponse(benchmark::State &state) {
|
||||
auto msg = make_sensor_response();
|
||||
APIBuffer buffer;
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -69,7 +69,7 @@ static void CalcAndEncode_ListEntitiesSensorResponse(benchmark::State &state) {
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ static void Encode_ListEntitiesBinarySensorResponse(benchmark::State &state) {
|
||||
auto msg = make_binary_sensor_response();
|
||||
APIBuffer buffer;
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -137,7 +137,7 @@ static void CalcAndEncode_ListEntitiesBinarySensorResponse(benchmark::State &sta
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
}
|
||||
@@ -202,7 +202,7 @@ static void Encode_ListEntitiesLightResponse(benchmark::State &state) {
|
||||
auto msg = make_light_response();
|
||||
APIBuffer buffer;
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -222,7 +222,7 @@ static void CalcAndEncode_ListEntitiesLightResponse(benchmark::State &state) {
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ static void Encode_LogResponse_Typical(benchmark::State &state) {
|
||||
msg.level = enums::LOG_LEVEL_DEBUG;
|
||||
msg.set_message(reinterpret_cast<const uint8_t *>(kTypicalLogLine), strlen(kTypicalLogLine));
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -42,7 +42,7 @@ static void Encode_LogResponse_Short(benchmark::State &state) {
|
||||
msg.level = enums::LOG_LEVEL_INFO;
|
||||
msg.set_message(reinterpret_cast<const uint8_t *>(kShortLogLine), strlen(kShortLogLine));
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -84,7 +84,7 @@ static void CalcAndEncode_LogResponse_Typical(benchmark::State &state) {
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
}
|
||||
@@ -105,7 +105,7 @@ static void CalcAndEncode_LogResponse_Typical_Fresh(benchmark::State &state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
APIBuffer buffer;
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
benchmark::DoNotOptimize(buffer.data());
|
||||
|
||||
@@ -33,7 +33,7 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) {
|
||||
// Pre-init buffer to typical TCP MSS size to avoid benchmarking
|
||||
// heap allocation — in real use the buffer is reused across writes.
|
||||
APIBuffer buffer;
|
||||
buffer.reserve(1460);
|
||||
(void) buffer.reserve(1460);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -44,7 +44,7 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) {
|
||||
msg.missing_state = false;
|
||||
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(padding + size);
|
||||
(void) buffer.resize(padding + size);
|
||||
ProtoWriteBuffer writer(&buffer, padding);
|
||||
msg.encode(writer);
|
||||
|
||||
@@ -70,7 +70,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) {
|
||||
// Pre-init buffer to typical TCP MSS size to avoid benchmarking
|
||||
// heap allocation — in real use the buffer is reused across writes.
|
||||
APIBuffer buffer;
|
||||
buffer.reserve(1460);
|
||||
(void) buffer.reserve(1460);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -85,7 +85,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) {
|
||||
msg.missing_state = false;
|
||||
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(offset + padding + size + footer);
|
||||
(void) buffer.resize(offset + padding + size + footer);
|
||||
ProtoWriteBuffer writer(&buffer, offset + padding);
|
||||
msg.encode(writer);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ static constexpr int kInnerIterations = 2000;
|
||||
template<typename T> static APIBuffer encode_message(const T &msg) {
|
||||
APIBuffer buffer;
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
return buffer;
|
||||
|
||||
@@ -19,7 +19,7 @@ static void Encode_SensorStateResponse(benchmark::State &state) {
|
||||
msg.state = 23.5f;
|
||||
msg.missing_state = false;
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -60,7 +60,7 @@ static void CalcAndEncode_SensorStateResponse(benchmark::State &state) {
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ static void CalcAndEncode_SensorStateResponse_Fresh(benchmark::State &state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
APIBuffer buffer;
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
benchmark::DoNotOptimize(buffer.data());
|
||||
@@ -103,7 +103,7 @@ static void Encode_BinarySensorStateResponse(benchmark::State &state) {
|
||||
msg.state = true;
|
||||
msg.missing_state = false;
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -126,7 +126,7 @@ static void Encode_HelloResponse(benchmark::State &state) {
|
||||
msg.server_info = StringRef::from_lit("esphome v2026.3.0");
|
||||
msg.name = StringRef::from_lit("living-room-sensor");
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -158,7 +158,7 @@ static void Encode_LightStateResponse(benchmark::State &state) {
|
||||
msg.warm_white = 0.0f;
|
||||
msg.effect = StringRef::from_lit("rainbow");
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -243,7 +243,7 @@ static void Encode_DeviceInfoResponse(benchmark::State &state) {
|
||||
auto msg = make_device_info_response();
|
||||
APIBuffer buffer;
|
||||
uint32_t total_size = msg.calculate_size();
|
||||
buffer.resize(total_size);
|
||||
(void) buffer.resize(total_size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -264,7 +264,7 @@ static void CalcAndEncode_DeviceInfoResponse(benchmark::State &state) {
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
}
|
||||
@@ -285,7 +285,7 @@ static void CalcAndEncode_DeviceInfoResponse_Fresh(benchmark::State &state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
APIBuffer buffer;
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
benchmark::DoNotOptimize(buffer.data());
|
||||
@@ -335,7 +335,7 @@ static void Encode_BLERawAdvs12(benchmark::State &state) {
|
||||
auto msg = make_ble_raw_advs_12();
|
||||
APIBuffer buffer;
|
||||
uint32_t total_size = msg.calculate_size();
|
||||
buffer.resize(total_size);
|
||||
(void) buffer.resize(total_size);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -355,7 +355,7 @@ static void CalcAndEncode_BLERawAdvs12(benchmark::State &state) {
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
}
|
||||
@@ -372,7 +372,7 @@ static void CalcAndEncode_BLERawAdvs12_Fresh(benchmark::State &state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
APIBuffer buffer;
|
||||
uint32_t size = msg.calculate_size();
|
||||
buffer.resize(size);
|
||||
(void) buffer.resize(size);
|
||||
ProtoWriteBuffer writer(&buffer, 0);
|
||||
msg.encode(writer);
|
||||
benchmark::DoNotOptimize(buffer.data());
|
||||
|
||||
@@ -16,7 +16,7 @@ static constexpr int kInnerIterations = 2000;
|
||||
// Encodes `src` into `out`. Caller owns `out` and must keep it alive across
|
||||
// the decode loop (decoded messages may store pointers back into its bytes).
|
||||
template<typename T> static void encode_into(APIBuffer &out, const T &src) {
|
||||
out.resize(src.calculate_size());
|
||||
(void) out.resize(src.calculate_size());
|
||||
ProtoWriteBuffer writer(&out, 0);
|
||||
src.encode(writer);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ static void Encode_ZWaveProxyFrame(benchmark::State &state) {
|
||||
msg.data = kZWaveFrameData;
|
||||
msg.data_len = sizeof(kZWaveFrameData);
|
||||
APIBuffer buffer;
|
||||
buffer.resize(msg.calculate_size());
|
||||
(void) buffer.resize(msg.calculate_size());
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -111,7 +111,7 @@ static void Encode_SerialProxyDataReceived(benchmark::State &state) {
|
||||
msg.instance = 0;
|
||||
msg.set_data(kSerialPayload, kSerialPayloadSize);
|
||||
APIBuffer buffer;
|
||||
buffer.resize(msg.calculate_size());
|
||||
(void) buffer.resize(msg.calculate_size());
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -171,7 +171,7 @@ static void Encode_InfraredRFReceiveEvent(benchmark::State &state) {
|
||||
msg.key = 0xDEADBEEF;
|
||||
msg.timings = &get_ir_timings_100();
|
||||
APIBuffer buffer;
|
||||
buffer.resize(msg.calculate_size());
|
||||
(void) buffer.resize(msg.calculate_size());
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -254,7 +254,7 @@ static APIBuffer build_infrared_rf_transmit_wire() {
|
||||
put_varint(1);
|
||||
|
||||
APIBuffer buf;
|
||||
buf.resize(len);
|
||||
(void) buf.resize(len);
|
||||
std::memcpy(buf.data(), bytes, len);
|
||||
return buf;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ BENCHMARK(ProtoVarInt_Parse_FiveByte);
|
||||
|
||||
static void Encode_Varint_Small(benchmark::State &state) {
|
||||
APIBuffer buffer;
|
||||
buffer.resize(16);
|
||||
(void) buffer.resize(16);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -73,7 +73,7 @@ BENCHMARK(Encode_Varint_Small);
|
||||
|
||||
static void Encode_Varint_Large(benchmark::State &state) {
|
||||
APIBuffer buffer;
|
||||
buffer.resize(16);
|
||||
(void) buffer.resize(16);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
@@ -88,7 +88,7 @@ BENCHMARK(Encode_Varint_Large);
|
||||
|
||||
static void Encode_Varint_MaxUint32(benchmark::State &state) {
|
||||
APIBuffer buffer;
|
||||
buffer.resize(16);
|
||||
(void) buffer.resize(16);
|
||||
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for variables handling in homeassistant.event and homeassistant.action."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
CONFIG = "tests/component_tests/api/test_homeassistant_variables.yaml"
|
||||
|
||||
|
||||
def test_plain_string_with_return_is_compiled_as_lambda_with_warning(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A plain string with a return statement compiles as a lambda and warns."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert main_cpp.count('add_variable(ESPHOME_F("lambda_var"), []() {') == 2
|
||||
assert "return millis();" in main_cpp
|
||||
# The source text must not be sent as a static string value.
|
||||
assert '"return millis();"' not in main_cpp
|
||||
assert "missing the !lambda tag" in caplog.text
|
||||
|
||||
|
||||
def test_static_string_is_kept_as_static_value(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A static string stays static, PROGMEM wrapped, with no warning."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert (
|
||||
main_cpp.count(
|
||||
'add_variable(ESPHOME_F("static_var"), ESPHOME_F("static value"));'
|
||||
)
|
||||
== 2
|
||||
)
|
||||
assert "static value" not in caplog.text
|
||||
|
||||
|
||||
def test_static_id_value_stays_literal_with_hint(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Lambda source without a return stays literal text but warns."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp
|
||||
assert "sent as literal text" in caplog.text
|
||||
|
||||
|
||||
def test_explicit_lambda_tag_is_compiled_as_lambda(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""A !lambda value keeps working unchanged."""
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp
|
||||
assert "return App.get_name();" in main_cpp
|
||||
@@ -0,0 +1,32 @@
|
||||
esphome:
|
||||
name: test
|
||||
on_boot:
|
||||
then:
|
||||
# Plain strings with a return statement compile as lambdas
|
||||
- homeassistant.event:
|
||||
event: esphome.test_event
|
||||
data_template:
|
||||
message: "{{ lambda_var }} {{ static_var }} {{ tagged_var }}"
|
||||
variables:
|
||||
lambda_var: |-
|
||||
return millis();
|
||||
static_var: static value
|
||||
tagged_var: !lambda return App.get_name();
|
||||
hint_var: id(test_sensor).state
|
||||
- homeassistant.action:
|
||||
action: notify.notify
|
||||
data_template:
|
||||
message: "{{ lambda_var }} {{ static_var }}"
|
||||
variables:
|
||||
lambda_var: |-
|
||||
return millis();
|
||||
static_var: static value
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
api:
|
||||
@@ -1,16 +1,21 @@
|
||||
"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update."""
|
||||
"""Tests for the external_components config pass."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.external_components import do_external_components_pass
|
||||
from esphome.const import (
|
||||
CONF_EXTERNAL_COMPONENTS,
|
||||
CONF_PATH,
|
||||
CONF_REFRESH,
|
||||
CONF_SOURCE,
|
||||
CONF_URL,
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
)
|
||||
from esphome.core import CORE, TimePeriodSeconds
|
||||
|
||||
@@ -69,3 +74,112 @@ def test_external_components_normal_refresh(
|
||||
mock_clone_or_update.assert_called_once()
|
||||
call_args = mock_clone_or_update.call_args
|
||||
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
||||
|
||||
|
||||
def test_external_components_logs_built_in_override(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A source that provides a component with the same name as a built-in one logs an info message."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
for name in ("gpio", "some_custom_component"):
|
||||
component_dir = tmp_path / "components" / name
|
||||
component_dir.mkdir()
|
||||
(component_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert (
|
||||
"External components are overriding built-in components:\n"
|
||||
" source: https://github.com/test/components\n"
|
||||
" components: gpio" in caplog.text
|
||||
)
|
||||
assert "some_custom_component" not in caplog.text
|
||||
|
||||
|
||||
def test_external_components_override_log_includes_ref(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A git source with a ref logs the ref appended to the url."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE] = "github://test/components@main"
|
||||
|
||||
component_dir = tmp_path / "components" / "gpio"
|
||||
component_dir.mkdir()
|
||||
(component_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert " source: https://github.com/test/components.git@main\n" in caplog.text
|
||||
|
||||
|
||||
def test_external_components_override_log_includes_git_path(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A git source with a subdirectory path logs the path after the url."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE][CONF_PATH] = "components"
|
||||
|
||||
component_dir = tmp_path / "components" / "gpio"
|
||||
component_dir.mkdir()
|
||||
(component_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert " source: https://github.com/test/components (components)\n" in caplog.text
|
||||
|
||||
|
||||
def test_external_components_override_log_local_source(
|
||||
tmp_path: Path,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A local source logs its resolved path."""
|
||||
components_dir = tmp_path / "my_components"
|
||||
gpio_dir = components_dir / "gpio"
|
||||
gpio_dir.mkdir(parents=True)
|
||||
(gpio_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
CORE.config_path = tmp_path / "dummy.yaml"
|
||||
config = {
|
||||
CONF_EXTERNAL_COMPONENTS: [
|
||||
{CONF_SOURCE: {"type": TYPE_LOCAL, CONF_PATH: "my_components"}}
|
||||
]
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert f" source: {components_dir}\n" in caplog.text
|
||||
assert " components: gpio" in caplog.text
|
||||
|
||||
|
||||
def test_external_components_no_override_no_log(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A source that only provides components not shipped with ESPHome logs nothing."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert "are overriding built-in components" not in caplog.text
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
|
||||
http_request:
|
||||
timeout: 10s
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user