mirror of
https://github.com/esphome/esphome.git
synced 2026-09-05 12:36:07 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5632aa20e | ||
|
|
c75e3898e4 | ||
|
|
b66990fbff | ||
|
|
05ec260ff3 | ||
|
|
ad40853283 | ||
|
|
52ce6668d6 | ||
|
|
2808837743 | ||
|
|
4caf7bafb8 | ||
|
|
22e29df396 | ||
|
|
cafc09bdca | ||
|
|
e632661adf | ||
|
|
8d0f3ea2bb | ||
|
|
8ee2f4247e | ||
|
|
edf2f7c62a | ||
|
|
1fc7a0798d | ||
|
|
39092a791a | ||
|
|
2031be0c23 | ||
|
|
708b7bfb53 | ||
|
|
7782bc11c6 | ||
|
|
a9a66baeba | ||
|
|
16d0fa1165 | ||
|
|
25d5cd3e14 | ||
|
|
9598449b6b | ||
|
|
a8c7279b3b | ||
|
|
c3c69c730c | ||
|
|
a8c8827a5b | ||
|
|
18b7002604 | ||
|
|
51105a25db | ||
|
|
fb132b4421 | ||
|
|
20b38ac8b0 | ||
|
|
cdd892a526 | ||
|
|
b99e7f5ae2 | ||
|
|
e55a8aeabe | ||
|
|
a8094ed548 | ||
|
|
2104096f02 | ||
|
|
e53870085b |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "d=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -x \"$d/venv/bin/python\" ] || { mkdir -p \"$d/.temp\"; env -u VIRTUAL_ENV \"$d/script/setup\" >\"$d/.temp/setup.log\" 2>&1 || echo '{\"systemMessage\":\"script/setup failed; see .temp/setup.log\"}'; }",
|
||||
"statusMessage": "Setting up dev environment (script/setup)...",
|
||||
"timeout": 900
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -119,16 +119,22 @@ jobs:
|
||||
# pushed image) keeps it working for fork PRs, which never push to ghcr.io.
|
||||
- name: Export image for compile-test
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
||||
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz
|
||||
# zstd over gzip: docker save is on the critical path for every
|
||||
# compile-test job, and zstd -T0 is multithreaded (export 50s -> 9s).
|
||||
# docker load auto-detects the format; its time is layer extraction,
|
||||
# not decompression, so it is unchanged. shell: bash adds pipefail so
|
||||
# a failed docker save cannot upload a truncated artifact.
|
||||
shell: bash
|
||||
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | zstd -T0 -3 > compile-test-image.tar.zst
|
||||
|
||||
- name: Upload compile-test image artifact
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# The tar is already gzipped, so upload it as-is. archive: false skips
|
||||
# the redundant zip and makes the file name the artifact name (the
|
||||
# `name` input is ignored in that mode).
|
||||
path: compile-test-image.tar.gz
|
||||
# The tar is already compressed, so upload it as-is. archive: false
|
||||
# skips the redundant zip and makes the file name the artifact name
|
||||
# (the `name` input is ignored in that mode).
|
||||
path: compile-test-image.tar.zst
|
||||
retention-days: 1
|
||||
archive: false
|
||||
|
||||
@@ -206,9 +212,9 @@ jobs:
|
||||
- name: Download image artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: compile-test-image.tar.gz
|
||||
name: compile-test-image.tar.zst
|
||||
- name: Load image
|
||||
run: docker load --input compile-test-image.tar.gz
|
||||
run: docker load --input compile-test-image.tar.zst
|
||||
- name: Compile ${{ matrix.id }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
|
||||
@@ -536,7 +536,7 @@ jobs:
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
|
||||
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
|
||||
@@ -577,6 +577,7 @@ esphome/components/tuya/select/* @bearpawmaxim
|
||||
esphome/components/tuya/sensor/* @jesserockz
|
||||
esphome/components/tuya/switch/* @jesserockz
|
||||
esphome/components/tuya/text_sensor/* @dentra
|
||||
esphome/components/tuya/water_heater/* @iago-veiga
|
||||
esphome/components/uart/* @esphome/core
|
||||
esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
|
||||
@@ -90,9 +90,10 @@ def get_project_cmakelists(
|
||||
"""
|
||||
idf_target = variant_to_idf_target(get_esp32_variant())
|
||||
|
||||
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
|
||||
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
|
||||
# --format=raw because the legacy mode doesn't support it.
|
||||
# esp_idf_size 2.x (IDF >=6.0) made NG the default and removed --ng;
|
||||
# 1.x (IDF 5.5) needs --ng for --format=json2. 1.x json2 also lacks
|
||||
# total_size, hence the ELF fallback in espidf/size_summary.py; both
|
||||
# go away together when 1.x support is dropped.
|
||||
size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
|
||||
|
||||
# Project-wide compile options: -D defines and -W warning flags (skip
|
||||
@@ -211,10 +212,12 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
project({CORE.name})
|
||||
|
||||
# Emit raw JSON size data for ESPHome to read post-build.
|
||||
# Emit per-memory-type JSON size data for ESPHome to read post-build.
|
||||
# json2 stays small; raw dumps every symbol (~2s on a large map) and
|
||||
# this command runs inside the link edge, blocking everything downstream.
|
||||
add_custom_command(
|
||||
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
|
||||
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
|
||||
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2
|
||||
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
|
||||
${{CMAKE_PROJECT_NAME}}.map
|
||||
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
|
||||
|
||||
@@ -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
|
||||
@@ -2239,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);
|
||||
@@ -2278,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()
|
||||
@@ -2292,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);
|
||||
}
|
||||
@@ -2351,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) {
|
||||
@@ -2366,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;
|
||||
@@ -2430,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,146 +7,146 @@ namespace esphome::captive_portal {
|
||||
|
||||
#ifdef USE_CAPTIVE_PORTAL_GZIP
|
||||
constexpr uint8_t INDEX_GZ[] PROGMEM = {
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7f,
|
||||
0x05, 0x8f, 0x4d, 0x1b, 0xa9, 0xb1, 0xa8, 0x87, 0xd7, 0xde, 0x44, 0x96, 0x54, 0xa4, 0x7b, 0x2d, 0x5a, 0xa0, 0x69,
|
||||
0x03, 0xec, 0x36, 0xf7, 0x21, 0x08, 0xb0, 0x34, 0x39, 0xb2, 0x98, 0xa5, 0x48, 0x1d, 0x49, 0xbf, 0x62, 0xf8, 0x7e,
|
||||
0xfb, 0x81, 0x92, 0xec, 0xf5, 0x2e, 0x9a, 0x03, 0x0e, 0x86, 0x85, 0x19, 0xce, 0x7b, 0x38, 0x0f, 0x16, 0xff, 0xe0,
|
||||
0x9a, 0xb9, 0x7d, 0x07, 0xa8, 0x71, 0xad, 0xac, 0x0a, 0xff, 0x45, 0x92, 0xaa, 0x55, 0x09, 0xaa, 0x2a, 0x1a, 0xa0,
|
||||
0xbc, 0x2a, 0x5a, 0x70, 0x14, 0xb1, 0x86, 0x1a, 0x0b, 0xae, 0xfc, 0xeb, 0xee, 0x97, 0xe8, 0x75, 0x55, 0x48, 0xa1,
|
||||
0x1e, 0x90, 0x01, 0x59, 0x0a, 0xa6, 0x15, 0x6a, 0x0c, 0xd4, 0x25, 0xa7, 0x8e, 0xe6, 0xa2, 0xa5, 0x2b, 0x18, 0x45,
|
||||
0x14, 0x6d, 0xa1, 0xdc, 0x08, 0xd8, 0x76, 0xda, 0x38, 0xc4, 0xb4, 0x72, 0xa0, 0x5c, 0x89, 0xb7, 0x82, 0xbb, 0xa6,
|
||||
0xe4, 0xb0, 0x11, 0x0c, 0xa2, 0x1e, 0x99, 0x08, 0x25, 0x9c, 0xa0, 0x32, 0xb2, 0x8c, 0x4a, 0x28, 0xd3, 0xc9, 0xda,
|
||||
0x82, 0xe9, 0x11, 0xba, 0x94, 0x50, 0x2a, 0x8d, 0xab, 0xc2, 0x32, 0x23, 0x3a, 0x87, 0xbc, 0xab, 0x65, 0xab, 0xf9,
|
||||
0x5a, 0x42, 0x15, 0xc7, 0xd4, 0x5a, 0x70, 0x36, 0x16, 0x8a, 0xc3, 0x8e, 0xd0, 0x6b, 0xb8, 0xa6, 0x2c, 0x4d, 0xc8,
|
||||
0x67, 0xfb, 0x0d, 0xd7, 0x6c, 0xdd, 0x82, 0x72, 0x44, 0x6a, 0x46, 0x9d, 0xd0, 0x8a, 0x58, 0xa0, 0x86, 0x35, 0x65,
|
||||
0x59, 0xe2, 0x1f, 0x2d, 0xdd, 0x00, 0xfe, 0xfe, 0xfb, 0xe0, 0xcc, 0xb4, 0x02, 0xf7, 0xb3, 0x04, 0x0f, 0xda, 0x9f,
|
||||
0xf6, 0x77, 0x74, 0xf5, 0x07, 0x6d, 0x21, 0xc0, 0xd4, 0x0a, 0x0e, 0x38, 0xfc, 0x98, 0x7c, 0x22, 0xd6, 0xed, 0x25,
|
||||
0x10, 0x2e, 0x6c, 0x27, 0xe9, 0xbe, 0xc4, 0x4b, 0xa9, 0xd9, 0x03, 0x0e, 0x17, 0xf5, 0x5a, 0x31, 0xaf, 0x1c, 0xe9,
|
||||
0x00, 0xc2, 0x83, 0x04, 0x87, 0x5c, 0xf9, 0x8e, 0xba, 0x86, 0xb4, 0x74, 0x17, 0x0c, 0x80, 0x50, 0x41, 0xf6, 0x43,
|
||||
0x00, 0xaf, 0xd2, 0x24, 0x09, 0x27, 0xfd, 0x27, 0x09, 0xe3, 0x34, 0x49, 0x16, 0x06, 0xdc, 0xda, 0x28, 0x44, 0x83,
|
||||
0xfb, 0xa2, 0xa3, 0xae, 0x41, 0xbc, 0xc4, 0xef, 0xd2, 0x0c, 0xa5, 0x6f, 0x48, 0x36, 0xfb, 0x9d, 0x5c, 0xa3, 0x2b,
|
||||
0x92, 0xcd, 0xd8, 0x75, 0x34, 0x43, 0xe9, 0x55, 0x34, 0x43, 0x59, 0x46, 0x66, 0x28, 0xf9, 0x82, 0x51, 0x2d, 0xa4,
|
||||
0x2c, 0xb1, 0xd2, 0x0a, 0x30, 0xb2, 0xce, 0xe8, 0x07, 0x28, 0x31, 0x5b, 0x1b, 0x03, 0xca, 0xdd, 0x68, 0xa9, 0x0d,
|
||||
0x8e, 0xab, 0x6f, 0xfe, 0x2f, 0x85, 0xce, 0x50, 0x65, 0x6b, 0x6d, 0xda, 0x12, 0xf7, 0xd9, 0x0f, 0x5e, 0x1c, 0xdc,
|
||||
0x11, 0xf9, 0x4f, 0x78, 0x41, 0x8c, 0xb4, 0x11, 0x2b, 0xa1, 0x4a, 0xec, 0x35, 0xbe, 0xc6, 0x71, 0x75, 0x1f, 0x1e,
|
||||
0xcf, 0xd1, 0x53, 0x1f, 0xfd, 0x18, 0x0f, 0x0f, 0x3e, 0xde, 0x17, 0x76, 0xb3, 0x42, 0xbb, 0x56, 0x2a, 0x5b, 0xe2,
|
||||
0xc6, 0xb9, 0x2e, 0x8f, 0xe3, 0xed, 0x76, 0x4b, 0xb6, 0x53, 0xa2, 0xcd, 0x2a, 0xce, 0x92, 0x24, 0x89, 0xed, 0x66,
|
||||
0x85, 0xd1, 0x50, 0x08, 0x38, 0xbb, 0xc2, 0xa8, 0x01, 0xb1, 0x6a, 0x5c, 0x0f, 0x57, 0x2f, 0x0e, 0x70, 0x2c, 0x3c,
|
||||
0x47, 0x75, 0xff, 0xe9, 0xc2, 0x8a, 0xb9, 0xb0, 0x02, 0x3f, 0xd2, 0x00, 0x9f, 0xc2, 0x7c, 0xd9, 0x87, 0x79, 0x4d,
|
||||
0x33, 0x94, 0xa1, 0xa4, 0xff, 0x65, 0x91, 0x87, 0x47, 0x2c, 0x7a, 0x86, 0xa1, 0x0b, 0xcc, 0x43, 0xed, 0x3c, 0x7a,
|
||||
0x73, 0x96, 0x4d, 0xfd, 0xc9, 0x26, 0x4d, 0x1e, 0x0f, 0xbc, 0xc0, 0xaf, 0xf3, 0x4b, 0x3c, 0xca, 0x3e, 0x5c, 0x32,
|
||||
0x78, 0x6b, 0x4d, 0xfa, 0x61, 0x4e, 0x67, 0x68, 0x36, 0x9e, 0xcc, 0x22, 0x0f, 0x9f, 0x31, 0x34, 0xdb, 0x64, 0x4d,
|
||||
0xda, 0x46, 0xf3, 0x68, 0x46, 0xa7, 0x68, 0x3a, 0x3a, 0x32, 0x45, 0xd3, 0x4d, 0xd6, 0xcc, 0x3f, 0xcc, 0x2f, 0xcf,
|
||||
0xa2, 0xe9, 0x97, 0x97, 0x71, 0x85, 0xc3, 0x1c, 0xe3, 0xc7, 0xc8, 0xf9, 0x65, 0xe4, 0xe4, 0xb3, 0x16, 0x2a, 0xc0,
|
||||
0x38, 0x3c, 0xd6, 0xe0, 0x58, 0x13, 0xe0, 0x98, 0x69, 0x55, 0x8b, 0x15, 0xf9, 0x6c, 0xb5, 0xc2, 0x21, 0x71, 0x0d,
|
||||
0xa8, 0xe0, 0x24, 0xea, 0x05, 0xa1, 0xa7, 0x04, 0xcf, 0x29, 0x2e, 0x3c, 0x9c, 0xeb, 0xdf, 0x09, 0x27, 0xa1, 0x74,
|
||||
0xc4, 0x37, 0xec, 0xe4, 0x6f, 0xba, 0xe2, 0xa7, 0xfd, 0x6f, 0x3c, 0xc0, 0x2d, 0x65, 0x38, 0x24, 0x42, 0x29, 0x30,
|
||||
0x77, 0xb0, 0x73, 0x25, 0x7e, 0xf7, 0xf6, 0x06, 0xbd, 0xe5, 0xdc, 0x80, 0xb5, 0x39, 0xc2, 0xaf, 0x1c, 0x69, 0x29,
|
||||
0xfb, 0xba, 0x78, 0x93, 0x3e, 0x95, 0xfe, 0x97, 0xf8, 0x45, 0xa0, 0x3f, 0xc0, 0x6d, 0xb5, 0x79, 0x18, 0xe5, 0xbd,
|
||||
0xfd, 0x85, 0x6f, 0x23, 0x56, 0x7e, 0x55, 0x8d, 0x02, 0x87, 0xc3, 0x89, 0xf8, 0x3a, 0x83, 0xb5, 0x82, 0xe3, 0x70,
|
||||
0x22, 0xbf, 0xce, 0xd1, 0x59, 0xdf, 0xbc, 0x8e, 0xd0, 0xce, 0x12, 0x2b, 0x05, 0x83, 0x20, 0x0d, 0x49, 0xad, 0xcd,
|
||||
0xcf, 0x94, 0x35, 0x8f, 0x09, 0xb2, 0x43, 0x47, 0xab, 0x47, 0x3d, 0xcc, 0x00, 0x75, 0x30, 0xaa, 0x0a, 0x30, 0x17,
|
||||
0x1b, 0x1c, 0x2e, 0x14, 0x61, 0x92, 0x5a, 0xeb, 0x47, 0x46, 0xe9, 0x9d, 0xf3, 0xe1, 0xe0, 0x89, 0x1a, 0x22, 0xfd,
|
||||
0xf5, 0xee, 0xdd, 0xef, 0xe5, 0x7d, 0x41, 0x87, 0x01, 0x89, 0xbf, 0xc5, 0xa8, 0x67, 0x3e, 0x33, 0x46, 0x12, 0x6a,
|
||||
0xe7, 0x2b, 0x5e, 0x07, 0x96, 0x18, 0x6b, 0x45, 0x78, 0x2c, 0x6c, 0x47, 0xd5, 0x73, 0xb6, 0x3e, 0xa6, 0xaa, 0x88,
|
||||
0x3d, 0xad, 0x2a, 0x62, 0x5a, 0xbd, 0x38, 0x98, 0xc0, 0xfa, 0xe1, 0xf6, 0x10, 0x1e, 0xef, 0x27, 0x8a, 0xfc, 0x7b,
|
||||
0x0d, 0x66, 0x7f, 0x0b, 0x12, 0x98, 0xd3, 0x26, 0xc0, 0xe4, 0x89, 0x60, 0x48, 0x1c, 0xec, 0xdc, 0xcd, 0x38, 0x7f,
|
||||
0x2d, 0xf1, 0x87, 0x13, 0x45, 0xb4, 0x62, 0x52, 0xb0, 0x87, 0xf2, 0x1c, 0x71, 0x78, 0x10, 0x64, 0x43, 0xe5, 0x1a,
|
||||
0x4e, 0x3c, 0x92, 0xd4, 0x9a, 0xad, 0x6d, 0x10, 0x1e, 0x27, 0x8c, 0xd0, 0xae, 0x03, 0xc5, 0x6f, 0x1a, 0x21, 0x79,
|
||||
0xa0, 0xc2, 0x63, 0xf8, 0x78, 0xd3, 0xcf, 0x8c, 0xfb, 0xd5, 0xf0, 0xd1, 0x80, 0xfc, 0x4f, 0xf9, 0xd2, 0x2f, 0x87,
|
||||
0x97, 0x9f, 0x70, 0x48, 0xfa, 0xf8, 0xef, 0x1f, 0x37, 0x84, 0x6f, 0xef, 0x57, 0xbb, 0x56, 0x4e, 0x7c, 0xe8, 0xd1,
|
||||
0x7c, 0x16, 0x1e, 0xef, 0x8f, 0xe1, 0x31, 0x5c, 0x14, 0xf1, 0x30, 0xe7, 0xab, 0xa2, 0x1f, 0xb9, 0xd5, 0x0f, 0x87,
|
||||
0xa5, 0xde, 0x45, 0x56, 0x7c, 0x11, 0x6a, 0x95, 0x0b, 0xd5, 0x80, 0x11, 0xee, 0xc8, 0xc5, 0x66, 0x22, 0x54, 0xb7,
|
||||
0x76, 0x87, 0x8e, 0x72, 0xee, 0x29, 0xb3, 0x6e, 0xb7, 0xa8, 0xb5, 0x72, 0x9e, 0x13, 0xf2, 0x14, 0xda, 0xe3, 0x40,
|
||||
0xef, 0x27, 0x4c, 0xfe, 0x66, 0xf6, 0xdd, 0x71, 0xa9, 0xf9, 0xfe, 0xe0, 0xd3, 0x10, 0x51, 0x29, 0x56, 0x2a, 0x67,
|
||||
0xa0, 0x1c, 0x98, 0x41, 0xa8, 0xa6, 0xad, 0x90, 0xfb, 0xdc, 0x52, 0x65, 0x23, 0x0b, 0x46, 0xd4, 0xc7, 0xe5, 0xda,
|
||||
0x39, 0xad, 0x0e, 0x4b, 0x6d, 0x38, 0x98, 0x3c, 0x59, 0x0c, 0x40, 0x64, 0x28, 0x17, 0x6b, 0x9b, 0x93, 0xa9, 0x81,
|
||||
0x76, 0xb1, 0xa4, 0xec, 0x61, 0x65, 0xf4, 0x5a, 0xf1, 0x88, 0xf9, 0xc9, 0x9b, 0x7f, 0x9b, 0xd6, 0x74, 0x0a, 0x6c,
|
||||
0x31, 0x62, 0x75, 0x5d, 0x2f, 0xa4, 0x50, 0x10, 0x0d, 0xb3, 0x2d, 0xcf, 0xc8, 0x95, 0x17, 0xbb, 0x70, 0x93, 0x64,
|
||||
0xfe, 0x60, 0xf0, 0x31, 0x4d, 0x92, 0xef, 0x16, 0xa7, 0x70, 0x92, 0x05, 0x5b, 0x1b, 0xab, 0x4d, 0xde, 0x69, 0xe1,
|
||||
0xdd, 0x3c, 0xb6, 0x54, 0xa8, 0x4b, 0xef, 0x7d, 0xd9, 0x2c, 0xc6, 0x75, 0x94, 0x0b, 0xd5, 0x9b, 0xe9, 0x97, 0xd2,
|
||||
0xa2, 0x15, 0x6a, 0xd8, 0xa9, 0x79, 0x36, 0x4f, 0xba, 0xdd, 0xf1, 0x54, 0x09, 0x87, 0x13, 0x77, 0x2d, 0x61, 0xb7,
|
||||
0xf8, 0xbc, 0xb6, 0x4e, 0xd4, 0xfb, 0x68, 0xdc, 0xc9, 0xb9, 0xed, 0x28, 0x83, 0x68, 0x09, 0x6e, 0x0b, 0xa0, 0x16,
|
||||
0xbd, 0x8d, 0x48, 0x38, 0x68, 0xed, 0x98, 0xa7, 0xb3, 0x9a, 0xbe, 0x60, 0x9f, 0xea, 0xfa, 0x5f, 0xdc, 0xbe, 0x8a,
|
||||
0x0e, 0x2d, 0x35, 0x2b, 0xa1, 0xa2, 0xa5, 0x76, 0x4e, 0xb7, 0x79, 0x74, 0xdd, 0xed, 0x16, 0xe3, 0x91, 0x57, 0x96,
|
||||
0xa7, 0xde, 0xcd, 0x7e, 0xd7, 0x9e, 0xf2, 0x9d, 0x76, 0x3b, 0x64, 0xb5, 0x14, 0x7c, 0xe4, 0xeb, 0x59, 0x50, 0x72,
|
||||
0x4e, 0x4f, 0x3a, 0xeb, 0x76, 0xc8, 0x9f, 0x9d, 0x52, 0x7d, 0x55, 0xbf, 0xa6, 0x69, 0xf2, 0x37, 0x37, 0xc2, 0xeb,
|
||||
0x3a, 0x5b, 0xd6, 0xe7, 0x4c, 0xf9, 0xb5, 0xe9, 0x57, 0x4b, 0x5f, 0x5a, 0x45, 0x3c, 0xbc, 0x6e, 0x7c, 0x65, 0x54,
|
||||
0x85, 0xcf, 0x70, 0x55, 0x34, 0x29, 0x12, 0xbc, 0x6c, 0x29, 0xab, 0x2e, 0x66, 0x5b, 0x11, 0x37, 0xe9, 0x89, 0xd4,
|
||||
0xa4, 0xd5, 0x93, 0xb9, 0x35, 0xd0, 0x7a, 0xef, 0xab, 0x1b, 0xad, 0x14, 0x30, 0x27, 0xd4, 0x0a, 0x39, 0x8d, 0xc6,
|
||||
0x14, 0x10, 0x42, 0x8a, 0xa5, 0xa9, 0xde, 0x4b, 0xa0, 0x16, 0xd0, 0x96, 0x0a, 0x47, 0x8a, 0x78, 0xe0, 0x1f, 0x3a,
|
||||
0x5d, 0xf0, 0x52, 0x81, 0x3b, 0xf7, 0x76, 0x33, 0x1d, 0x0c, 0xdc, 0x82, 0xf3, 0x9a, 0xbc, 0x81, 0x69, 0x55, 0xf8,
|
||||
0x15, 0x8c, 0x68, 0xdf, 0xa5, 0x65, 0xbc, 0x15, 0xb5, 0xf0, 0x4f, 0x98, 0xaa, 0xe8, 0x8b, 0xdc, 0x6b, 0xf0, 0x79,
|
||||
0x1e, 0x9e, 0x5b, 0x3d, 0x24, 0x41, 0xad, 0x5c, 0x53, 0x4e, 0x33, 0xd4, 0x49, 0xca, 0xa0, 0xd1, 0x92, 0x83, 0x29,
|
||||
0x6f, 0x6f, 0x7f, 0xfb, 0x67, 0xe5, 0x9d, 0x79, 0x94, 0xeb, 0xec, 0xc3, 0x20, 0xe6, 0x81, 0x51, 0x6a, 0x7e, 0x35,
|
||||
0x3c, 0xb2, 0x3a, 0x6a, 0xed, 0x56, 0x1b, 0xfe, 0x44, 0xc7, 0xfb, 0xf1, 0x70, 0xd0, 0xd3, 0xff, 0xfb, 0x56, 0xa9,
|
||||
0x6e, 0xe9, 0x06, 0x8a, 0x78, 0x44, 0x8a, 0xd8, 0x3b, 0x3c, 0xd0, 0x9b, 0x91, 0xaf, 0x49, 0xab, 0x3f, 0xef, 0xde,
|
||||
0xa2, 0xbf, 0x3a, 0x4e, 0x1d, 0x0c, 0x69, 0xeb, 0xa3, 0x6a, 0xc1, 0x35, 0x9a, 0x97, 0xef, 0xff, 0xbc, 0xbd, 0x3b,
|
||||
0x47, 0xb8, 0xee, 0x99, 0x10, 0x28, 0x36, 0x3c, 0xf7, 0xd6, 0xd2, 0x89, 0x8e, 0x1a, 0xd7, 0xab, 0x8d, 0xfc, 0x14,
|
||||
0x39, 0xc5, 0xd0, 0xd3, 0x6b, 0x21, 0x61, 0x08, 0x63, 0x10, 0xac, 0xd0, 0xc9, 0xab, 0x93, 0xb5, 0x67, 0x7e, 0xc5,
|
||||
0xc3, 0x6d, 0xc7, 0xc3, 0xd5, 0xc7, 0xfd, 0xcb, 0xf7, 0xbf, 0x81, 0xdb, 0x13, 0xb5, 0x09, 0x0b, 0x00, 0x00};
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x56, 0x6d, 0x8f, 0xdb, 0x36, 0x0c, 0xfe, 0xbe,
|
||||
0x5f, 0xa1, 0x79, 0xdd, 0x6a, 0xaf, 0xb1, 0xfc, 0x92, 0x4b, 0xda, 0x3a, 0x96, 0x8b, 0xee, 0xd6, 0x62, 0x03, 0xd6,
|
||||
0xad, 0xc0, 0xdd, 0xba, 0x0f, 0x45, 0x01, 0x2b, 0x32, 0x1d, 0xab, 0x27, 0x4b, 0x9e, 0xa4, 0xbc, 0x35, 0xc8, 0x7e,
|
||||
0xfb, 0x20, 0xdb, 0xc9, 0xe5, 0x8a, 0x16, 0xd8, 0x10, 0xc4, 0xa0, 0x44, 0xf2, 0xe1, 0x8b, 0x28, 0x52, 0xf9, 0xb7,
|
||||
0x95, 0x62, 0x76, 0xdf, 0x01, 0x6a, 0x6c, 0x2b, 0x8a, 0xdc, 0x7d, 0x91, 0xa0, 0x72, 0x45, 0x40, 0x16, 0x79, 0x03,
|
||||
0xb4, 0x2a, 0xf2, 0x16, 0x2c, 0x45, 0xac, 0xa1, 0xda, 0x80, 0x25, 0x7f, 0xde, 0xbe, 0x0e, 0x9f, 0x15, 0xb9, 0xe0,
|
||||
0xf2, 0x0e, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0xa8,
|
||||
0x22, 0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0xc4, 0xdb, 0xf2, 0xca, 0x36,
|
||||
0xa4, 0x82, 0x0d, 0x67, 0x10, 0xf6, 0x8b, 0x09, 0x97, 0xdc, 0x72, 0x2a, 0x42, 0xc3, 0xa8, 0x00, 0x92, 0x4c, 0xd6,
|
||||
0x06, 0x74, 0xbf, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0xaf, 0xc8, 0x0d, 0xd3, 0xbc, 0xb3, 0xc8, 0xb9, 0x4a, 0x5a, 0x55,
|
||||
0xad, 0x05, 0x20, 0xa6, 0x95, 0x31, 0x4a, 0xf3, 0x15, 0x97, 0x45, 0xa5, 0xd8, 0xba, 0x05, 0x69, 0xb1, 0x50, 0x8c,
|
||||
0x5a, 0xae, 0x24, 0x36, 0x40, 0x35, 0x6b, 0x08, 0x21, 0xe5, 0x0b, 0x43, 0x37, 0x50, 0xfe, 0xf0, 0x83, 0x7f, 0x16,
|
||||
0x5a, 0x81, 0x7d, 0x25, 0xc0, 0x91, 0xe6, 0xa7, 0xfd, 0x2d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x4b, 0x6a, 0x78, 0x05,
|
||||
0x65, 0xf0, 0x3e, 0xfe, 0x80, 0x8d, 0xdd, 0x0b, 0xc0, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x52, 0x2e, 0x85, 0x62, 0x77,
|
||||
0x65, 0xb0, 0xa8, 0xd7, 0x92, 0x39, 0x70, 0x04, 0x3e, 0x04, 0x07, 0x01, 0x16, 0x49, 0xf2, 0x86, 0xda, 0x06, 0xb7,
|
||||
0x74, 0xe7, 0x0f, 0x04, 0x97, 0x7e, 0xfa, 0xa3, 0x0f, 0x4f, 0x92, 0x38, 0x0e, 0x26, 0xfd, 0x27, 0x0e, 0xa2, 0x24,
|
||||
0x8e, 0x17, 0x1a, 0xec, 0x5a, 0x4b, 0x64, 0xfd, 0x32, 0xef, 0xa8, 0x6d, 0x50, 0x45, 0xbc, 0x37, 0x49, 0x8a, 0x92,
|
||||
0xe7, 0x38, 0x9d, 0xfd, 0x86, 0x9f, 0xa2, 0x2b, 0x9c, 0xce, 0xd8, 0xd3, 0x70, 0x86, 0x92, 0xab, 0x70, 0x86, 0xd2,
|
||||
0x14, 0xcf, 0x50, 0xfc, 0xc9, 0x43, 0x35, 0x17, 0x82, 0x78, 0x52, 0x49, 0xf0, 0x90, 0xb1, 0x5a, 0xdd, 0x01, 0xf1,
|
||||
0xd8, 0x5a, 0x6b, 0x90, 0xf6, 0x5a, 0x09, 0xa5, 0xbd, 0xa8, 0xf8, 0xe6, 0x7f, 0x01, 0x5a, 0x4d, 0xa5, 0xa9, 0x95,
|
||||
0x6e, 0x89, 0xd7, 0xa7, 0xdb, 0x7f, 0x74, 0x90, 0x47, 0xe4, 0x3e, 0xc1, 0x05, 0x33, 0x1c, 0xf2, 0x4a, 0x3c, 0x87,
|
||||
0xf8, 0xcc, 0x8b, 0x8a, 0x32, 0x38, 0x9e, 0xa3, 0xb7, 0x2e, 0xfa, 0x31, 0x1e, 0xed, 0xbf, 0x2f, 0x73, 0xb3, 0x59,
|
||||
0xa1, 0x5d, 0x2b, 0xa4, 0x21, 0x5e, 0x63, 0x6d, 0x97, 0x45, 0xd1, 0x76, 0xbb, 0xc5, 0xdb, 0x29, 0x56, 0x7a, 0x15,
|
||||
0xa5, 0x71, 0x1c, 0x47, 0x66, 0xb3, 0xf2, 0xd0, 0x70, 0xf2, 0x5e, 0x7a, 0xe5, 0xa1, 0x06, 0xf8, 0xaa, 0xb1, 0x3d,
|
||||
0x5d, 0x3c, 0x3a, 0xc0, 0x31, 0x77, 0x12, 0x45, 0xf9, 0xe1, 0xc2, 0x8a, 0xbc, 0xb0, 0x02, 0x2f, 0x2e, 0xf2, 0xf6,
|
||||
0xb8, 0x0f, 0xf3, 0x29, 0x4d, 0x51, 0x8a, 0xe2, 0xfe, 0x97, 0x86, 0x8e, 0x1e, 0x57, 0xe1, 0x67, 0x2b, 0x74, 0xb1,
|
||||
0x72, 0x54, 0x3b, 0x0f, 0x9f, 0x9f, 0x75, 0x13, 0xb7, 0xb3, 0x49, 0xe2, 0xfb, 0x0d, 0xa7, 0xf0, 0xcb, 0xfc, 0x72,
|
||||
0x1d, 0xa6, 0xef, 0x2e, 0x05, 0x9c, 0xb5, 0x26, 0x79, 0x37, 0xa7, 0x33, 0x34, 0x1b, 0x77, 0x66, 0xa1, 0xa3, 0xcf,
|
||||
0x2b, 0x34, 0xdb, 0xa4, 0x4d, 0xd2, 0x86, 0xf3, 0x70, 0x46, 0xa7, 0x68, 0x3a, 0x3a, 0x32, 0x45, 0xd3, 0x4d, 0xda,
|
||||
0xcc, 0xdf, 0xcd, 0x2f, 0xf7, 0xc2, 0xe9, 0xa7, 0xc7, 0x2e, 0xb9, 0x59, 0x59, 0xde, 0x47, 0xae, 0x2f, 0x23, 0xc7,
|
||||
0x1f, 0x15, 0x97, 0x7e, 0xe9, 0xf2, 0x0f, 0x96, 0x35, 0x7e, 0x19, 0x31, 0x25, 0x6b, 0xbe, 0xc2, 0x1f, 0x8d, 0x92,
|
||||
0x65, 0x80, 0x6d, 0x03, 0xd2, 0x3f, 0xa9, 0xfa, 0x36, 0x38, 0xd8, 0x9e, 0xe3, 0x7f, 0x81, 0x73, 0xae, 0x7f, 0xcb,
|
||||
0xad, 0x00, 0x62, 0xb1, 0xbb, 0xa1, 0x93, 0x2f, 0xdc, 0x8a, 0x9f, 0xf6, 0xbf, 0x56, 0x7e, 0xd9, 0x52, 0x56, 0x06,
|
||||
0x98, 0x4b, 0x09, 0xfa, 0x16, 0x76, 0x96, 0x94, 0x6f, 0x5e, 0x5e, 0xa3, 0x97, 0x55, 0xa5, 0xc1, 0x98, 0x0c, 0x95,
|
||||
0x4f, 0x2c, 0x6e, 0x29, 0xfb, 0xba, 0x7a, 0x93, 0x3c, 0xd4, 0xfe, 0x8b, 0xbf, 0xe6, 0xe8, 0x77, 0xb0, 0x5b, 0xa5,
|
||||
0xef, 0x46, 0x7d, 0x67, 0x7f, 0xe1, 0xae, 0x91, 0x26, 0x5f, 0x85, 0x91, 0x60, 0xcb, 0x60, 0xc2, 0xbf, 0x2e, 0x60,
|
||||
0x0c, 0xaf, 0xca, 0x60, 0x42, 0xbf, 0x2e, 0xd1, 0x19, 0x77, 0x79, 0x2d, 0xa6, 0x9d, 0xc1, 0x46, 0x70, 0x06, 0x7e,
|
||||
0x12, 0xe0, 0x5a, 0xe9, 0x57, 0x94, 0x35, 0x0f, 0x12, 0xe4, 0x5c, 0x51, 0xf7, 0x38, 0x4c, 0x03, 0xb5, 0x30, 0x42,
|
||||
0xf9, 0x65, 0xc5, 0x37, 0x65, 0xb0, 0x50, 0x98, 0x09, 0x6a, 0x8c, 0x6b, 0x19, 0xc4, 0x39, 0xe7, 0xc2, 0x29, 0x27,
|
||||
0x6a, 0x88, 0xf4, 0x97, 0xdb, 0x37, 0xbf, 0x91, 0x32, 0xa7, 0x43, 0x47, 0xf4, 0xbe, 0xf3, 0x50, 0x2f, 0x4c, 0xbc,
|
||||
0x51, 0x30, 0x14, 0x50, 0xdb, 0xbe, 0xe2, 0x7d, 0x8b, 0xb5, 0x31, 0x3c, 0x38, 0xe6, 0xa6, 0xa3, 0xf2, 0x73, 0x31,
|
||||
0x17, 0x93, 0x57, 0xe4, 0x91, 0xe3, 0x15, 0x79, 0x44, 0x8b, 0x47, 0x07, 0xe9, 0xf7, 0xcd, 0xed, 0x2e, 0x38, 0x3a,
|
||||
0x6b, 0x7f, 0xaf, 0x41, 0xef, 0x6f, 0x40, 0x00, 0xb3, 0x4a, 0xfb, 0x25, 0xbe, 0x54, 0x74, 0x45, 0x01, 0x3b, 0x7b,
|
||||
0x3d, 0x36, 0x5c, 0x8b, 0xdd, 0xe6, 0x44, 0x61, 0x25, 0x99, 0xe0, 0xec, 0x8e, 0x9c, 0x23, 0x0e, 0x0e, 0x1c, 0x6f,
|
||||
0xa8, 0x58, 0xc3, 0x49, 0x86, 0xe2, 0x5a, 0xb1, 0xb5, 0xf1, 0x83, 0xe3, 0x44, 0x63, 0xda, 0x75, 0x20, 0xab, 0xeb,
|
||||
0x86, 0x8b, 0xca, 0x57, 0xc1, 0x31, 0xb8, 0x3f, 0xe9, 0xcf, 0x8c, 0xbb, 0x59, 0xf0, 0x5e, 0x83, 0xf8, 0x87, 0x3c,
|
||||
0x76, 0xd3, 0xe0, 0xf1, 0x87, 0x32, 0xc0, 0x7d, 0xfc, 0xe5, 0xfd, 0x48, 0x70, 0xd7, 0xfb, 0xc9, 0xae, 0x15, 0x13,
|
||||
0x17, 0x7a, 0x38, 0x9f, 0x05, 0xc7, 0xf2, 0x18, 0x1c, 0x83, 0x45, 0x1e, 0x0d, 0x8d, 0xbd, 0xc8, 0xfb, 0x96, 0xdb,
|
||||
0x8f, 0x94, 0x9e, 0x32, 0x0d, 0x80, 0x7d, 0xd0, 0xe2, 0x7f, 0x3c, 0x2c, 0xd5, 0x2e, 0x34, 0xfc, 0x13, 0x97, 0xab,
|
||||
0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0x26, 0x5c, 0x76, 0x6b, 0x7b, 0xe8, 0x68, 0x55, 0x39, 0xce, 0xac,
|
||||
0xdb, 0x2d, 0x6a, 0x25, 0xad, 0x93, 0x84, 0x2c, 0x81, 0xf6, 0x38, 0xf0, 0xfb, 0xe6, 0x93, 0x3d, 0x9f, 0x7d, 0x7f,
|
||||
0x5c, 0xaa, 0x6a, 0x7f, 0x70, 0x19, 0x0a, 0xa9, 0xe0, 0x2b, 0x99, 0x31, 0x90, 0x16, 0xf4, 0xa0, 0x54, 0xd3, 0x96,
|
||||
0x8b, 0x7d, 0x66, 0xa8, 0x34, 0xa1, 0x01, 0xcd, 0xeb, 0xe3, 0x72, 0x6d, 0xad, 0x92, 0x07, 0xe6, 0x9a, 0x6d, 0xf6,
|
||||
0x5d, 0x5d, 0xd7, 0x0b, 0xb6, 0xd6, 0x46, 0xe9, 0xac, 0x53, 0xbc, 0xd7, 0x5b, 0x52, 0x76, 0xb7, 0xd2, 0x6a, 0x2d,
|
||||
0xab, 0x70, 0x14, 0x4a, 0x6a, 0x3a, 0x05, 0xb6, 0x58, 0x2a, 0x5d, 0x81, 0xce, 0xe2, 0x91, 0x08, 0x35, 0xad, 0xf8,
|
||||
0xda, 0x64, 0x78, 0xaa, 0xa1, 0x5d, 0x0c, 0xee, 0x24, 0x71, 0xfc, 0xfd, 0xe2, 0xe4, 0x79, 0x7c, 0xe9, 0x37, 0x4e,
|
||||
0x9d, 0x94, 0xe0, 0x12, 0xc2, 0xa1, 0x57, 0x66, 0x29, 0xbe, 0xd2, 0xd0, 0x1e, 0x5b, 0xca, 0xe5, 0xa5, 0xf7, 0xae,
|
||||
0xa2, 0x16, 0x2d, 0x97, 0xc3, 0x28, 0xcd, 0xd2, 0x79, 0xdc, 0xed, 0x16, 0xe3, 0xe4, 0xca, 0xb8, 0xec, 0x11, 0xfa,
|
||||
0xf9, 0x75, 0x3c, 0x15, 0xc9, 0xe1, 0xe3, 0xda, 0x58, 0x5e, 0xef, 0xc3, 0x71, 0x24, 0x67, 0xa6, 0xa3, 0x0c, 0xc2,
|
||||
0x25, 0xd8, 0x2d, 0x80, 0x5c, 0xf4, 0xb0, 0x21, 0xb7, 0xd0, 0x9a, 0x53, 0x6a, 0x4e, 0x70, 0xb5, 0x80, 0xdd, 0x19,
|
||||
0xa6, 0xaf, 0xe5, 0xc3, 0x7f, 0x96, 0x76, 0x05, 0x76, 0x68, 0xa9, 0x5e, 0x71, 0x19, 0x2e, 0x95, 0xb5, 0xaa, 0xcd,
|
||||
0xc2, 0xa7, 0xdd, 0x6e, 0x31, 0x6e, 0x39, 0xb0, 0x2c, 0x89, 0xbb, 0xdd, 0xb1, 0x1f, 0xc3, 0xa7, 0x7c, 0x5f, 0xd5,
|
||||
0xcf, 0x68, 0x12, 0x7f, 0x21, 0xc7, 0x55, 0x5d, 0xa7, 0xcb, 0xfa, 0x94, 0xe3, 0xa4, 0xdb, 0x21, 0xa3, 0x04, 0xaf,
|
||||
0x46, 0xb8, 0x1e, 0x09, 0xc5, 0xe7, 0xd4, 0x26, 0xb3, 0x6e, 0x87, 0x92, 0xcb, 0xcc, 0xb8, 0x89, 0xea, 0xa6, 0x8e,
|
||||
0xab, 0xb5, 0x22, 0x8f, 0x86, 0x97, 0x8e, 0xab, 0x8c, 0x22, 0x77, 0x19, 0x2e, 0xf2, 0x26, 0x41, 0xbc, 0x22, 0x2d,
|
||||
0x65, 0xc5, 0x45, 0xdb, 0xcb, 0xa3, 0x26, 0x39, 0xb1, 0x9a, 0xa4, 0x78, 0xd0, 0xd2, 0x06, 0x5e, 0xef, 0x7d, 0x71,
|
||||
0xad, 0xa4, 0x04, 0x66, 0xb9, 0x5c, 0x21, 0xab, 0xd0, 0x98, 0x02, 0x8c, 0x71, 0xbe, 0xd4, 0xc5, 0x5b, 0x01, 0xd4,
|
||||
0x00, 0xda, 0x52, 0x6e, 0x71, 0x1e, 0x0d, 0xf2, 0x43, 0x13, 0xe0, 0x15, 0x91, 0x60, 0xcf, 0xd7, 0xbe, 0x99, 0x0e,
|
||||
0x06, 0x6e, 0xc0, 0x3a, 0x24, 0x67, 0x60, 0x5a, 0xe4, 0x6e, 0x3a, 0x23, 0xda, 0x5f, 0x60, 0x12, 0x6d, 0x79, 0xcd,
|
||||
0xdd, 0xeb, 0xa6, 0xc8, 0xfb, 0x22, 0x77, 0x08, 0x2e, 0xcf, 0xc3, 0xd3, 0xab, 0xa7, 0x04, 0xc8, 0x95, 0x6d, 0xc8,
|
||||
0x34, 0x45, 0x9d, 0xa0, 0x0c, 0x1a, 0x25, 0x2a, 0xd0, 0xe4, 0xe6, 0xe6, 0xd7, 0x9f, 0x0b, 0xe7, 0xcc, 0xbd, 0x5e,
|
||||
0x67, 0xee, 0x06, 0x35, 0x47, 0x8c, 0x5a, 0xf3, 0xab, 0xe1, 0xc1, 0xd5, 0x51, 0x63, 0xb6, 0x4a, 0x57, 0x0f, 0x30,
|
||||
0xde, 0x8e, 0x9b, 0x03, 0x4e, 0xff, 0xef, 0xaf, 0x4a, 0x71, 0x43, 0x37, 0x90, 0x47, 0xe3, 0x22, 0x8f, 0x9c, 0xc3,
|
||||
0x03, 0xbf, 0x19, 0xe5, 0x9a, 0xa4, 0xf8, 0xe3, 0xf6, 0x25, 0xfa, 0xb3, 0xab, 0xa8, 0x85, 0x21, 0x6d, 0x7d, 0x54,
|
||||
0x2d, 0xd8, 0x46, 0x55, 0xe4, 0xed, 0x1f, 0x37, 0xb7, 0xe7, 0x08, 0xd7, 0xbd, 0x10, 0x02, 0xc9, 0x86, 0xa7, 0xdf,
|
||||
0x5a, 0x58, 0xde, 0x51, 0x6d, 0x7b, 0xd8, 0xd0, 0x35, 0x98, 0x53, 0x0c, 0x3d, 0xbf, 0xe6, 0x02, 0x86, 0x30, 0x06,
|
||||
0xc5, 0x02, 0x9d, 0xbc, 0x3a, 0x59, 0xfb, 0xcc, 0xaf, 0x68, 0x38, 0xed, 0x68, 0x38, 0xfa, 0xa8, 0x7f, 0x05, 0xff,
|
||||
0x0b, 0x54, 0xcf, 0x54, 0x8b, 0x15, 0x0b, 0x00, 0x00};
|
||||
|
||||
#else // Brotli (default, smaller)
|
||||
constexpr uint8_t INDEX_BR[] PROGMEM = {
|
||||
0x1b, 0x08, 0x0b, 0x00, 0xe4, 0x7f, 0x9b, 0xad, 0xbb, 0x97, 0x53, 0xde, 0xb7, 0x25, 0x0e, 0x69, 0xd4, 0x69, 0x89,
|
||||
0xba, 0xa5, 0x55, 0x22, 0x04, 0x27, 0xeb, 0x00, 0x52, 0xac, 0xbc, 0xec, 0x5f, 0xfb, 0xb5, 0x7a, 0x12, 0x0a, 0xd6,
|
||||
0x48, 0x84, 0x4a, 0x48, 0x5a, 0xcb, 0xbd, 0xb7, 0x72, 0x66, 0x4e, 0x62, 0xd8, 0xbd, 0x7f, 0x88, 0x68, 0x86, 0x28,
|
||||
0xd6, 0xf1, 0xda, 0x2c, 0xa2, 0x32, 0x5c, 0xdd, 0xab, 0xb2, 0x15, 0xbc, 0x24, 0x13, 0xe6, 0xbd, 0x0c, 0x52, 0x63,
|
||||
0x82, 0x88, 0x6d, 0x74, 0x71, 0x51, 0x9c, 0xe2, 0x40, 0x56, 0xea, 0xb0, 0x5c, 0xb7, 0x1d, 0x81, 0x3a, 0x0c, 0xf2,
|
||||
0xd7, 0xa8, 0x5c, 0x35, 0x2d, 0x43, 0xb3, 0x42, 0x37, 0x7c, 0x60, 0xd8, 0xc1, 0x95, 0x91, 0x94, 0x3c, 0x29, 0x20,
|
||||
0x96, 0x20, 0xf6, 0x24, 0xb7, 0x83, 0x4a, 0x06, 0xf1, 0xc3, 0x2f, 0x88, 0x50, 0x55, 0x35, 0x2d, 0xe8, 0xb6, 0x21,
|
||||
0x38, 0x61, 0x8e, 0x44, 0x2a, 0xac, 0x39, 0xcf, 0x74, 0xaa, 0x31, 0x7f, 0x62, 0x32, 0x9b, 0x99, 0x42, 0x0a, 0xf6,
|
||||
0x6f, 0xd8, 0x89, 0x3f, 0x49, 0xb1, 0xa2, 0x52, 0x0a, 0x8e, 0xb3, 0x27, 0x0b, 0x07, 0x07, 0x58, 0xb0, 0x8f, 0xaa,
|
||||
0xdc, 0x68, 0x12, 0x0f, 0x95, 0xbf, 0xc7, 0x36, 0xd5, 0x60, 0x5a, 0xc7, 0x80, 0xac, 0x52, 0xf7, 0xa7, 0x2d, 0xb6,
|
||||
0x64, 0xda, 0xda, 0x11, 0x8d, 0x6a, 0xe5, 0xba, 0xe9, 0xda, 0xdc, 0x62, 0xc3, 0xcb, 0xae, 0xc1, 0xe1, 0x11, 0xb6,
|
||||
0x33, 0x29, 0x04, 0x09, 0xfe, 0x09, 0x08, 0xc2, 0x1f, 0x98, 0x16, 0x26, 0x0d, 0xce, 0xd7, 0x75, 0xfb, 0xd7, 0xa5,
|
||||
0x82, 0xd7, 0x32, 0x44, 0x72, 0xc1, 0xc2, 0xe4, 0x15, 0xcb, 0x50, 0xfc, 0xd3, 0x58, 0x64, 0x34, 0x41, 0x32, 0xbe,
|
||||
0x1f, 0x08, 0x43, 0x16, 0x14, 0xf7, 0x70, 0x2c, 0x1c, 0x61, 0x09, 0x78, 0x73, 0xd1, 0x30, 0xf6, 0xed, 0x85, 0x55,
|
||||
0x50, 0x02, 0xf0, 0x68, 0x50, 0x5c, 0x1a, 0xd7, 0x3b, 0x8e, 0xf2, 0x23, 0xc8, 0x88, 0x39, 0xd0, 0x84, 0xf7, 0xa6,
|
||||
0xd1, 0xa3, 0xfb, 0xe5, 0x44, 0xc0, 0x4c, 0x2f, 0x6f, 0x19, 0x70, 0x75, 0xf6, 0x1c, 0xb8, 0xce, 0x89, 0x4f, 0x01,
|
||||
0x8d, 0xa1, 0x71, 0xf2, 0x97, 0xf8, 0xe7, 0xd3, 0xf1, 0xe1, 0xfa, 0xfc, 0xc8, 0x03, 0x78, 0x14, 0xa0, 0x3d, 0x28,
|
||||
0xb7, 0xeb, 0x26, 0x52, 0x59, 0xa8, 0xb5, 0x0d, 0x5c, 0x8a, 0x6b, 0xe5, 0xf6, 0x30, 0xd6, 0xf7, 0x71, 0x31, 0xe8,
|
||||
0xbd, 0xc9, 0xfa, 0xf5, 0x71, 0x9d, 0xff, 0xf6, 0x69, 0x62, 0x5f, 0xb5, 0xc7, 0x06, 0x43, 0x54, 0xb5, 0x87, 0xf1,
|
||||
0xcc, 0x84, 0xe8, 0xb7, 0x56, 0x38, 0x43, 0x6a, 0xa6, 0x2f, 0x53, 0xd1, 0x89, 0x48, 0x8d, 0x72, 0x75, 0x4a, 0x17,
|
||||
0xf6, 0x8d, 0xb2, 0xeb, 0xb1, 0x6b, 0x29, 0x1e, 0xd5, 0x74, 0xc7, 0xb3, 0xf4, 0xf1, 0x04, 0x0d, 0xbf, 0x08, 0x81,
|
||||
0x8f, 0xfe, 0x8d, 0xfc, 0xf2, 0x35, 0x92, 0xa0, 0x24, 0x88, 0x12, 0x6a, 0xe6, 0x2f, 0x01, 0x94, 0x5c, 0x4b, 0xf9,
|
||||
0x6b, 0x9a, 0xf6, 0x1a, 0x35, 0x11, 0x8a, 0xc6, 0x04, 0x8d, 0x50, 0x64, 0x48, 0x2d, 0x8b, 0xdf, 0xdf, 0xa1, 0xd1,
|
||||
0xfd, 0x21, 0xd7, 0x40, 0x96, 0x00, 0xb1, 0x9f, 0x54, 0xc2, 0xe1, 0x00, 0x79, 0x15, 0x80, 0xf8, 0xca, 0x8e, 0xc5,
|
||||
0x06, 0x03, 0xdf, 0x72, 0x49, 0xc0, 0x08, 0x27, 0x90, 0xe3, 0x14, 0xc2, 0xac, 0xc3, 0x83, 0xc9, 0xf6, 0x9d, 0x12,
|
||||
0xab, 0x46, 0xae, 0x03, 0x74, 0x1d, 0x27, 0xce, 0x91, 0x1d, 0x4e, 0xb4, 0xbe, 0x80, 0xe7, 0x6a, 0x6d, 0x0a, 0x20,
|
||||
0x47, 0x6b, 0x00, 0x4e, 0x25, 0x52, 0xe6, 0xf5, 0xe9, 0x43, 0x84, 0xc1, 0x0d, 0x4b, 0x04, 0xb3, 0x91, 0x49, 0xf5,
|
||||
0xe9, 0xc4, 0x2e, 0x1b, 0xf9, 0x98, 0xf9, 0xea, 0x9e, 0xb8, 0xf6, 0x53, 0xf8, 0x42, 0xc2, 0x60, 0x5c, 0xa9, 0xd2,
|
||||
0xfe, 0x0a, 0xe5, 0xd4, 0x7d, 0x36, 0x76, 0x04, 0x12, 0x38, 0xa1, 0xc4, 0x30, 0xb8, 0xf2, 0x69, 0x86, 0x5b, 0xe7,
|
||||
0xe5, 0xa0, 0xc0, 0xfe, 0x91, 0x19, 0x03, 0x1e, 0xa9, 0x93, 0x26, 0x49, 0x58, 0xd5, 0xf6, 0x33, 0x4e, 0x0a, 0x89,
|
||||
0x64, 0x1e, 0xb4, 0x7a, 0x5d, 0x0d, 0x12, 0xcf, 0x2b, 0xdd, 0x35, 0x90, 0x55, 0xc3, 0x0e, 0xcd, 0x0e, 0xad, 0x6b,
|
||||
0x8f, 0x43, 0xd0, 0xb4, 0x6a, 0xdb, 0x4b, 0xe5, 0xa4, 0x9b, 0x09, 0x23, 0x1a, 0x43, 0xa9, 0xff, 0x61, 0x8b, 0x07,
|
||||
0xd6, 0x0f, 0x83, 0x23, 0xbe, 0x8a, 0x66, 0xa2, 0x10, 0x2f, 0x11, 0x01, 0x0d, 0xc6, 0x96, 0xba, 0x87, 0xaa, 0xa8,
|
||||
0x44, 0x7c, 0x1e, 0x34, 0xdb, 0xba, 0x41, 0x90, 0xf0, 0x7a, 0xdb, 0x63, 0x60, 0x96, 0x8d, 0x64, 0xcb, 0x2f, 0x28,
|
||||
0x96, 0x04, 0xd5, 0xc0, 0x7a, 0xe8, 0xec, 0xd1, 0x61, 0x1b, 0x09, 0x4b, 0x4c, 0x6e, 0x1b, 0x31, 0x98, 0x9c, 0x6d,
|
||||
0xdb, 0xd0, 0xec, 0xa4, 0x0f, 0x0a, 0xe0, 0xc8, 0x35, 0xc4, 0x93, 0xdc, 0xee, 0x19, 0x00, 0x80, 0x07, 0xf5, 0xcf,
|
||||
0xe0, 0x7f, 0x75, 0xf8, 0x52, 0x3a, 0xfc, 0x0d, 0x84, 0xa5, 0xc1, 0xb2, 0x1c, 0x25, 0x40, 0xc5, 0x8b, 0xb3, 0xdb,
|
||||
0x7a, 0x1b, 0x44, 0xff, 0x79, 0x9a, 0x26, 0xc4, 0xe7, 0x9e, 0x78, 0x4c, 0xa5, 0x94, 0xab, 0x78, 0x34, 0x9d, 0xb5,
|
||||
0xb7, 0x24, 0x26, 0xe7, 0x9a, 0xf3, 0xe5, 0x2a, 0x35, 0x4b, 0xbe, 0x74, 0xd7, 0x01, 0xbc, 0x71, 0x72, 0x03, 0x5c,
|
||||
0x40, 0x24, 0x17, 0x61, 0x5b, 0x7b, 0x19, 0xc2, 0x7f, 0x4e, 0x5b, 0x24, 0xfb, 0x8b, 0xc3, 0x51, 0x00, 0x3d, 0x09,
|
||||
0x04, 0x05, 0x72, 0x64, 0xf6, 0xf0, 0x6b, 0x99, 0x38, 0x93, 0x5b, 0xac, 0x0c, 0xff, 0x40, 0x7b, 0x53, 0xba, 0xab,
|
||||
0x61, 0xc9, 0xa2, 0xde, 0xd6, 0x2b, 0x0c, 0xbd, 0x85, 0xac, 0x4c, 0x64, 0x8b, 0xe6, 0x69, 0x2b, 0x56, 0x10, 0x1b,
|
||||
0x08, 0x59, 0x6c, 0x55, 0x0a, 0xaa, 0x93, 0x85, 0x1d, 0x45, 0xda, 0xc6, 0x39, 0xe6, 0x6a, 0xab, 0x71, 0x73, 0x46,
|
||||
0x0f, 0xf7, 0xab, 0x4e, 0x88, 0xae, 0x8c, 0xe0, 0x91, 0xda, 0x35, 0x8c, 0xd3, 0x18, 0x92, 0x3d, 0xab, 0x2f, 0x0d,
|
||||
0xd6, 0xc9, 0x06, 0xdb, 0xc2, 0x62, 0xcb, 0x8a, 0x23, 0x5b, 0x28, 0x2e, 0x9b, 0x96, 0xc4, 0xc1, 0x42, 0xa9, 0x8d,
|
||||
0x69, 0xe5, 0x8f, 0x89, 0x12, 0x11, 0x9a, 0x56, 0x3b, 0x3c, 0x2b, 0xb4, 0xb2, 0x7b, 0xfd, 0xdb, 0x80, 0x92, 0xb4,
|
||||
0xca, 0x44, 0x37, 0x8c, 0x94, 0x2f, 0x4a, 0xb4, 0xc4, 0x28, 0x83, 0x8a, 0x78, 0xcb, 0xd2, 0x5c, 0x5c, 0x35, 0x29,
|
||||
0x95, 0x35, 0x2f, 0x99, 0x28, 0x4f, 0x22, 0x18, 0x70, 0x85, 0xee, 0x2c, 0xb9, 0xef, 0x14, 0x57, 0x73, 0x23, 0x45,
|
||||
0xae, 0xdc, 0x54, 0x56, 0x55, 0x78, 0x56, 0xad, 0x48, 0x26, 0x27, 0xbf, 0xcb, 0xd7, 0x54, 0xa9, 0xa8, 0xd7, 0xb5,
|
||||
0x71, 0x9c, 0xe7, 0x79, 0x89, 0x5c, 0xa9, 0x6a, 0x53, 0xe8, 0xfa, 0x0d, 0x69, 0x36, 0xed, 0xad, 0x33, 0xc9, 0x75,
|
||||
0x17, 0xe9, 0x8f, 0x31, 0x58, 0xa6, 0x27, 0xfc, 0x79, 0xc6, 0xb6, 0xd5, 0x65, 0x90, 0x31, 0xc6, 0x9d, 0x29, 0x95,
|
||||
0x83, 0xe5, 0x4e, 0xbb, 0xd7, 0xdc, 0x8e, 0xd0, 0x3a, 0x54, 0x27, 0xce, 0xf5, 0xdb, 0xbd, 0x89, 0x3c, 0x61, 0xb3,
|
||||
0x71, 0x23, 0xc7, 0x55, 0x9e, 0xea, 0x50, 0xe4, 0x37, 0xae, 0x72, 0x34, 0x66, 0xb9, 0x6e, 0x2c, 0x0a, 0x69, 0x8d,
|
||||
0x94, 0x8b, 0x98, 0x08, 0x05, 0x3c, 0x45, 0x45, 0xe1, 0x6c, 0x22, 0xc5, 0x8f, 0x1f, 0x9f, 0x3f, 0xd2, 0x01, 0x12,
|
||||
0xec, 0x86, 0x2f, 0x87, 0x8b, 0x47, 0x30, 0xd0, 0x77, 0x77, 0x1a, 0x13, 0x2d, 0xc6, 0x31, 0x65, 0x77, 0xd8, 0x8c,
|
||||
0xe7, 0xe0, 0x16, 0xf9, 0x4b, 0xd4, 0xc5, 0xa8, 0x67, 0x79, 0xe7, 0xc3, 0x07, 0x94, 0x46, 0xa3, 0x8c, 0x67, 0xd3,
|
||||
0xff, 0x5f, 0x96, 0xfa, 0xed, 0xa7, 0xd3, 0xdd, 0x4f, 0x27, 0x49, 0x27, 0xcf, 0xa4, 0x02, 0xc3, 0x27, 0x3c, 0x96,
|
||||
0x64, 0x24, 0x55, 0xc8, 0x36, 0x45, 0x68, 0x90, 0xea, 0x03, 0x0b, 0x46, 0xa7, 0x8d, 0xb4, 0x26, 0x91, 0x07, 0x4c,
|
||||
0x80, 0x7b, 0x93, 0xa8, 0x10, 0xcb, 0x5e, 0x8d, 0x42, 0x86, 0x3e, 0x2a, 0x7c, 0x99, 0x79, 0x8e, 0xcb, 0x43, 0x28};
|
||||
0x1b, 0x14, 0x0b, 0x00, 0xe4, 0x6f, 0xcd, 0xfc, 0x7b, 0x2e, 0x27, 0xd2, 0x21, 0x2b, 0x58, 0xea, 0x16, 0xf8, 0xa5,
|
||||
0xb6, 0x47, 0x14, 0xb3, 0x4c, 0x56, 0xcc, 0x20, 0x5b, 0x1d, 0xfe, 0x7d, 0xfb, 0xb5, 0x7a, 0x12, 0x0a, 0xd6, 0x48,
|
||||
0x84, 0xa2, 0x21, 0x69, 0x0d, 0x77, 0x33, 0xf3, 0x77, 0xcf, 0x4c, 0x21, 0xf1, 0xde, 0xee, 0x7d, 0x44, 0xbc, 0xd3,
|
||||
0xc4, 0x33, 0x89, 0x1a, 0x69, 0x68, 0x48, 0x57, 0xa7, 0x17, 0x0b, 0x4d, 0x91, 0xac, 0x30, 0x48, 0x21, 0x4d, 0x4c,
|
||||
0x10, 0x57, 0x46, 0x14, 0x17, 0xd5, 0x21, 0x0e, 0xb4, 0x50, 0x87, 0xad, 0x62, 0xda, 0x11, 0x68, 0xc3, 0x20, 0x7f,
|
||||
0x2d, 0xdc, 0x57, 0xeb, 0x2c, 0x36, 0xdb, 0xc4, 0x84, 0x0f, 0xac, 0xec, 0x08, 0x91, 0xa3, 0x92, 0x27, 0x45, 0xc4,
|
||||
0x1e, 0xa4, 0x9e, 0x72, 0x3b, 0xc2, 0xe3, 0x20, 0x7d, 0xf8, 0x05, 0x09, 0xea, 0xe3, 0xa6, 0x3f, 0x11, 0x8b, 0x16,
|
||||
0x11, 0x6b, 0x26, 0x91, 0x1a, 0x2c, 0x19, 0x24, 0xf7, 0x5d, 0x44, 0x82, 0x49, 0x44, 0x15, 0xce, 0x39, 0xdc, 0xcb,
|
||||
0x8f, 0x5b, 0xe1, 0xe2, 0x42, 0x96, 0xa2, 0x10, 0x3c, 0x20, 0xa5, 0x65, 0x85, 0x49, 0x6a, 0x06, 0x08, 0xd1, 0xcb,
|
||||
0x41, 0xb8, 0x49, 0x20, 0x73, 0x71, 0xfe, 0x55, 0x61, 0x45, 0xc6, 0x95, 0x72, 0xc8, 0xf0, 0xa5, 0xe9, 0xe6, 0x3a,
|
||||
0xb9, 0xc3, 0x8a, 0x9f, 0xb5, 0xc1, 0xc9, 0x15, 0x56, 0x93, 0x38, 0x8a, 0x48, 0xf0, 0x4f, 0x38, 0x22, 0xe1, 0x8d,
|
||||
0x55, 0xbb, 0x8c, 0xc3, 0xb0, 0x68, 0xcc, 0x7f, 0x6f, 0xf8, 0xc9, 0x9b, 0x38, 0x41, 0xf1, 0x94, 0x25, 0xf9, 0x6b,
|
||||
0x56, 0xa2, 0xec, 0xa7, 0xbd, 0x2e, 0x69, 0x8e, 0xe2, 0xec, 0x36, 0x94, 0x24, 0x2c, 0x12, 0x1d, 0x4e, 0x76, 0x7e,
|
||||
0x23, 0xcc, 0xf2, 0x6e, 0x35, 0x1a, 0x9c, 0xed, 0x6f, 0x15, 0x3f, 0xc9, 0x72, 0xdc, 0xfd, 0x13, 0x0f, 0x16, 0x8a,
|
||||
0x23, 0x4f, 0xf9, 0x2e, 0x63, 0x44, 0x91, 0x77, 0xe0, 0xb3, 0xd1, 0x78, 0x74, 0xdb, 0x4a, 0x2c, 0xd8, 0xe8, 0xf9,
|
||||
0x2c, 0x03, 0xbe, 0xae, 0xac, 0x4e, 0x42, 0x01, 0xc4, 0x4b, 0x40, 0xe7, 0x94, 0x34, 0x85, 0x2c, 0xfe, 0xf5, 0x74,
|
||||
0x75, 0xd8, 0xdc, 0xec, 0x6a, 0x00, 0x3f, 0x3f, 0x81, 0x77, 0xa8, 0xcd, 0xde, 0x6d, 0x5a, 0x47, 0xa1, 0x99, 0x36,
|
||||
0x87, 0xb6, 0x78, 0x35, 0x3c, 0x9a, 0x64, 0x15, 0x7c, 0x56, 0x76, 0x22, 0xce, 0x46, 0xe5, 0x17, 0x57, 0x05, 0xfc,
|
||||
0x09, 0x69, 0xae, 0x39, 0xaa, 0xee, 0xc9, 0x4e, 0x7f, 0x99, 0x2a, 0x65, 0x82, 0x7e, 0xeb, 0x23, 0x4f, 0x42, 0xd5,
|
||||
0xca, 0xcb, 0x42, 0x74, 0x2e, 0xd2, 0xa2, 0x62, 0x57, 0xd0, 0xa9, 0x7b, 0x4b, 0xac, 0x7b, 0x65, 0x13, 0x47, 0x0f,
|
||||
0x2d, 0x3d, 0xf6, 0xbc, 0x78, 0x5c, 0xa3, 0xc9, 0x57, 0x4b, 0x10, 0x62, 0x68, 0x19, 0x7f, 0xfd, 0x1a, 0xcb, 0x51,
|
||||
0x1e, 0x41, 0x39, 0x55, 0xf3, 0x97, 0x30, 0xca, 0x37, 0xb6, 0x42, 0x1d, 0x2d, 0x8c, 0xc6, 0x65, 0x8a, 0xd2, 0x89,
|
||||
0x88, 0xa6, 0x28, 0xbd, 0x56, 0x7c, 0x2d, 0x5e, 0x0d, 0x2f, 0x9e, 0xc3, 0xa5, 0x80, 0xaf, 0xcc, 0x00, 0xde, 0xe6,
|
||||
0x5a, 0x8b, 0xda, 0xff, 0x1f, 0x16, 0xc8, 0x83, 0x5e, 0xe5, 0xea, 0x25, 0x86, 0x70, 0x55, 0x25, 0x01, 0x14, 0x0c,
|
||||
0x77, 0xd8, 0x31, 0x21, 0xcc, 0x79, 0x15, 0x3b, 0x32, 0x3a, 0xd3, 0xc5, 0x30, 0xaf, 0x03, 0xd8, 0x3e, 0x44, 0xb8,
|
||||
0x63, 0xb5, 0x74, 0x4f, 0x41, 0x4b, 0xf0, 0x24, 0x74, 0xb2, 0x06, 0xb2, 0x7b, 0x06, 0x60, 0xdc, 0xc1, 0x3e, 0x0e,
|
||||
0x6f, 0x1e, 0x3c, 0x42, 0xa0, 0xdb, 0x36, 0x43, 0x30, 0x71, 0xcc, 0xd6, 0x1a, 0xbd, 0x38, 0x61, 0x19, 0x3f, 0xf2,
|
||||
0xdf, 0xf4, 0x53, 0x3d, 0xc1, 0x14, 0xbe, 0x50, 0x1c, 0x2c, 0xf3, 0xaa, 0x74, 0x36, 0xcb, 0xbd, 0x7a, 0x4b, 0xa3,
|
||||
0x1c, 0x90, 0x40, 0x5b, 0x4a, 0x0f, 0x83, 0x6e, 0x9e, 0x96, 0x28, 0x3d, 0x77, 0x43, 0x05, 0x0e, 0x39, 0x26, 0x15,
|
||||
0xb8, 0x6b, 0x4e, 0x3a, 0x62, 0xc2, 0xda, 0xde, 0x8e, 0x29, 0x29, 0x0b, 0x09, 0xa2, 0xb3, 0xa7, 0x1e, 0x9a, 0x0c,
|
||||
0x8f, 0xa1, 0x46, 0x6f, 0x92, 0xf3, 0x9e, 0xb5, 0xc5, 0x7e, 0x0e, 0x8d, 0xeb, 0x55, 0x08, 0xfa, 0xc9, 0xd8, 0x4e,
|
||||
0xe3, 0xd0, 0xca, 0x2a, 0x96, 0x11, 0x7e, 0xb1, 0xd4, 0x7f, 0x8f, 0x1d, 0xb3, 0xc3, 0xa0, 0x89, 0x6f, 0x93, 0x99,
|
||||
0x55, 0x48, 0x97, 0x08, 0x79, 0x66, 0xe9, 0x4a, 0x6b, 0xa0, 0x29, 0xf2, 0x10, 0x1f, 0x22, 0xa2, 0x1b, 0x41, 0xdf,
|
||||
0xa3, 0xde, 0x62, 0x60, 0x8e, 0xa1, 0x60, 0xc4, 0xd5, 0xce, 0x81, 0x47, 0x84, 0x3b, 0x66, 0x60, 0x74, 0xa7, 0x74,
|
||||
0xcc, 0x48, 0xe0, 0xe1, 0xd5, 0x0c, 0x15, 0x98, 0x3d, 0xa7, 0x9c, 0xb2, 0xec, 0xba, 0x0f, 0x2c, 0x52, 0x14, 0x7b,
|
||||
0xe2, 0x49, 0x6e, 0x0f, 0x8c, 0x00, 0xe0, 0x81, 0xf6, 0x57, 0xe4, 0x3f, 0xbf, 0x7c, 0xa9, 0x5e, 0xfe, 0x01, 0xc2,
|
||||
0x64, 0xb0, 0x05, 0x60, 0x01, 0xaa, 0x78, 0x65, 0xb2, 0xeb, 0x56, 0x41, 0xf2, 0xbf, 0xa3, 0x45, 0x4e, 0x3c, 0x78,
|
||||
0xe2, 0xa1, 0x0d, 0xa9, 0x2a, 0xc0, 0x8a, 0xc0, 0x6f, 0xe4, 0x66, 0xbe, 0x72, 0x35, 0x5e, 0xf7, 0x3b, 0x42, 0x53,
|
||||
0xd4, 0xe6, 0x66, 0xb6, 0x78, 0xcd, 0xaa, 0x6f, 0xf4, 0x26, 0x80, 0x3a, 0x4e, 0x74, 0x80, 0x17, 0x88, 0x44, 0x23,
|
||||
0xa6, 0x3a, 0x6f, 0x87, 0xb8, 0xd0, 0x4d, 0xd3, 0xfc, 0x7c, 0xd6, 0x38, 0x2a, 0x00, 0x28, 0x01, 0xa2, 0x40, 0x94,
|
||||
0x6c, 0x1e, 0x8a, 0xed, 0xe3, 0x92, 0x9d, 0x90, 0xe3, 0x0d, 0x02, 0x4e, 0x15, 0x30, 0xed, 0x8f, 0x5b, 0x99, 0xaa,
|
||||
0x7a, 0x4e, 0xb9, 0xec, 0x91, 0xe2, 0x9f, 0xd4, 0xca, 0x46, 0xaf, 0x87, 0x19, 0x4b, 0xad, 0xea, 0xe6, 0x6c, 0x8d,
|
||||
0x53, 0x4b, 0x29, 0xee, 0x1e, 0x96, 0xd8, 0x94, 0x30, 0x3a, 0x9c, 0xb0, 0x4c, 0xdb, 0xe2, 0xa1, 0x7f, 0xc7, 0x11,
|
||||
0xdd, 0xe3, 0x9d, 0x36, 0x44, 0xd3, 0x93, 0x14, 0x9c, 0x4c, 0x9d, 0xdd, 0x3a, 0x7c, 0x41, 0xb1, 0x8f, 0x14, 0xd9,
|
||||
0x4e, 0x61, 0xd9, 0x3a, 0xe3, 0x0d, 0x76, 0xca, 0x6c, 0xac, 0x73, 0xaf, 0xad, 0x94, 0x87, 0x30, 0xf1, 0x30, 0x2f,
|
||||
0x8b, 0xed, 0x4a, 0xed, 0xbc, 0xc2, 0xf2, 0x7c, 0x30, 0xa3, 0x0b, 0x28, 0x64, 0x3b, 0x8c, 0xd4, 0xc3, 0x42, 0xb9,
|
||||
0xa3, 0x44, 0x51, 0x80, 0x07, 0x5a, 0x3d, 0x14, 0x33, 0x99, 0xbf, 0x2a, 0x6b, 0x2b, 0x19, 0x47, 0x72, 0x9e, 0xd4,
|
||||
0xb4, 0x6d, 0x72, 0xdd, 0x8a, 0x4b, 0x33, 0x55, 0xbc, 0xb4, 0xcd, 0xc8, 0x2b, 0x17, 0x2f, 0x74, 0xeb, 0x22, 0x17,
|
||||
0x94, 0x08, 0x27, 0x27, 0xc2, 0x5b, 0x17, 0xb4, 0xa9, 0x22, 0x16, 0x9d, 0xd4, 0xfc, 0xc7, 0x15, 0xa3, 0x9b, 0x86,
|
||||
0x1f, 0xad, 0x45, 0xd3, 0x87, 0x94, 0x5b, 0x31, 0x36, 0xaa, 0xe4, 0x66, 0x8d, 0xcc, 0x31, 0x05, 0x5b, 0xc4, 0x40,
|
||||
0xc0, 0xb8, 0xeb, 0x91, 0x18, 0x22, 0x8c, 0x31, 0x1e, 0xad, 0xd0, 0x3a, 0x98, 0x07, 0xb5, 0x6f, 0x11, 0xba, 0x11,
|
||||
0xa6, 0x14, 0x35, 0x5a, 0xe7, 0x55, 0xdf, 0xb7, 0x4c, 0x03, 0x61, 0xa3, 0x74, 0x23, 0xdf, 0x55, 0x1f, 0x02, 0x51,
|
||||
0x09, 0xb7, 0xba, 0xd5, 0x0c, 0x67, 0xab, 0x98, 0x70, 0x14, 0x64, 0x8d, 0xf4, 0x8b, 0x54, 0x44, 0x07, 0x6f, 0xe0,
|
||||
0x69, 0x32, 0xca, 0x48, 0xe5, 0xd3, 0xa7, 0x17, 0x8f, 0x45, 0x84, 0x04, 0xb7, 0xd1, 0xbb, 0xe1, 0xf6, 0x01, 0x0a,
|
||||
0xf6, 0xee, 0x2b, 0x32, 0xd2, 0xc5, 0xf8, 0xa6, 0xec, 0x0f, 0x1b, 0x09, 0x1d, 0xfc, 0xa2, 0xbf, 0x54, 0x5d, 0x2c,
|
||||
0x62, 0xf4, 0x77, 0xde, 0xad, 0xa0, 0x50, 0x6a, 0xb4, 0xe3, 0x5f, 0xda, 0xff, 0x6b, 0xb1, 0x78, 0xf7, 0xf9, 0xc1,
|
||||
0xa6, 0xa8, 0x93, 0xe8, 0xe4, 0x11, 0x56, 0xa0, 0x5b, 0x85, 0xa7, 0x92, 0x7a, 0x58, 0x45, 0x95, 0xa9, 0x63, 0x83,
|
||||
0xb4, 0x1f, 0x18, 0x31, 0x7a, 0x6d, 0xa1, 0x8d, 0x8c, 0xdc, 0x91, 0x02, 0x3c, 0x9c, 0x92, 0x42, 0x8e, 0x03, 0x02,
|
||||
0xc5, 0x0c, 0x43, 0x54, 0xf9, 0xb2, 0x85, 0x39, 0x2e, 0x77, 0xad, 0x00};
|
||||
|
||||
// Backwards compatibility alias
|
||||
#define INDEX_GZ INDEX_BR
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#include "esphome/core/string_ref.h"
|
||||
#include "esphome/components/wifi/scan_list.h"
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#ifdef USE_PROVISIONING
|
||||
#include "esphome/components/provisioning/provisioning.h"
|
||||
#endif
|
||||
#include "captive_index.h"
|
||||
|
||||
namespace esphome::captive_portal {
|
||||
@@ -78,6 +81,20 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
|
||||
void CaptivePortal::setup() {
|
||||
// Disable loop by default - will be enabled when captive portal starts
|
||||
this->disable_loop();
|
||||
#ifdef USE_PROVISIONING
|
||||
// The captive portal is a provisioning surface: once the provisioning window
|
||||
// has closed, stop serving it. WiFi's own closed-callback shuts down the
|
||||
// access point the portal runs on, and the gated fallback in WiFiComponent's
|
||||
// loop() ensures neither is started again afterwards.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
if (this->active_) {
|
||||
ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal");
|
||||
this->end();
|
||||
}
|
||||
});
|
||||
}
|
||||
#endif
|
||||
}
|
||||
void CaptivePortal::start() {
|
||||
this->base_->init();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "esp32_improv_component.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
#include "esphome/components/bytebuffer/bytebuffer.h"
|
||||
#include "esphome/components/esp32_ble/ble.h"
|
||||
#include "esphome/components/esp32_ble_server/ble_2902.h"
|
||||
@@ -19,7 +21,13 @@ using namespace bytebuffer;
|
||||
|
||||
static const char *const TAG = "esp32_improv.component";
|
||||
static constexpr size_t IMPROV_MAX_LOG_BYTES = 128;
|
||||
static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome";
|
||||
static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome";
|
||||
// command + data length + trailing byte
|
||||
static constexpr size_t RPC_RESPONSE_OVERHEAD = 3;
|
||||
// Reserves the ESPHOME_MY_LINK entry; a maximal next URL displaces only the
|
||||
// lower value web server URL
|
||||
static constexpr size_t MAX_NEXT_URL_LEN =
|
||||
improv::RPC_RESPONSE_MAX_SIZE - RPC_RESPONSE_OVERHEAD - 1 - sizeof(ESPHOME_MY_LINK);
|
||||
static constexpr uint16_t STOP_ADVERTISING_DELAY =
|
||||
10000; // Delay (ms) before stopping service to allow BLE clients to read the final state
|
||||
static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds
|
||||
@@ -285,8 +293,9 @@ void ESP32ImprovComponent::set_error_(improv::Error error) {
|
||||
}
|
||||
}
|
||||
|
||||
void ESP32ImprovComponent::send_response_(std::vector<uint8_t> &&response) {
|
||||
this->rpc_response_->set_value(std::move(response));
|
||||
void ESP32ImprovComponent::send_response_(std::span<const uint8_t> response) {
|
||||
// The BLE characteristic owns its value, so one exact-size copy is required here
|
||||
this->rpc_response_->set_value(std::vector<uint8_t>(response.begin(), response.end()));
|
||||
if (this->state_ != improv::STATE_STOPPED)
|
||||
this->rpc_response_->notify();
|
||||
}
|
||||
@@ -430,40 +439,35 @@ void ESP32ImprovComponent::check_wifi_connection_() {
|
||||
this->connecting_sta_ = {};
|
||||
this->cancel_timeout("wifi-connect-timeout");
|
||||
|
||||
// Build URL list with minimal allocations
|
||||
// Maximum 3 URLs: custom next_url + ESPHOME_MY_LINK + webserver URL
|
||||
std::string url_strings[3];
|
||||
size_t url_count = 0;
|
||||
// Build the URL list directly into a stack buffer with no heap allocation
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS);
|
||||
|
||||
#ifdef USE_ESP32_IMPROV_NEXT_URL
|
||||
// Add next_url if configured (should be first per Improv BLE spec)
|
||||
{
|
||||
char url_buffer[384];
|
||||
size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
|
||||
if (len > 0) {
|
||||
url_strings[url_count++] = std::string(url_buffer, len);
|
||||
}
|
||||
}
|
||||
this->add_next_url_(builder, MAX_NEXT_URL_LEN);
|
||||
#endif
|
||||
|
||||
// Add default URLs for backward compatibility
|
||||
url_strings[url_count++] = ESPHOME_MY_LINK;
|
||||
// Add default URLs for backward compatibility; MAX_NEXT_URL_LEN reserves this
|
||||
// entry's space, so it always fits
|
||||
builder.add_string(ESPHOME_MY_LINK, sizeof(ESPHOME_MY_LINK) - 1);
|
||||
#ifdef USE_WEBSERVER
|
||||
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
|
||||
if (ip.is_ip4()) {
|
||||
// "http://" (7) + IPv4 max (15) + ":" (1) + port max (5) + null = 29
|
||||
char url_buffer[32];
|
||||
memcpy(url_buffer, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates
|
||||
ip.str_to(url_buffer + 7);
|
||||
size_t len = strlen(url_buffer);
|
||||
snprintf(url_buffer + len, sizeof(url_buffer) - len, ":%d", USE_WEBSERVER_PORT);
|
||||
url_strings[url_count++] = url_buffer;
|
||||
char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
|
||||
ip.str_to(ip_buf);
|
||||
// "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54
|
||||
char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1];
|
||||
size_t len =
|
||||
buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
|
||||
if (!builder.add_string(webserver_url, len)) {
|
||||
ESP_LOGW(TAG, "Response full; URL dropped");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
this->send_response_(improv::build_rpc_response(improv::WIFI_SETTINGS,
|
||||
std::vector<std::string>(url_strings, url_strings + url_count)));
|
||||
this->send_response_(builder.finish());
|
||||
} else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) {
|
||||
ESP_LOGD(TAG, "WiFi provisioned externally");
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "esphome/components/output/binary_output.h"
|
||||
#endif
|
||||
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
@@ -109,7 +110,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB
|
||||
void set_state_(improv::State state, bool update_advertising = true);
|
||||
void set_error_(improv::Error error);
|
||||
improv::State get_initial_state_() const;
|
||||
void send_response_(std::vector<uint8_t> &&response);
|
||||
void send_response_(std::span<const uint8_t> response);
|
||||
void process_incoming_data_();
|
||||
void on_wifi_connect_timeout_();
|
||||
void check_wifi_connection_();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::improv_base {
|
||||
|
||||
#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL)
|
||||
static const char *const TAG = "improv_base";
|
||||
|
||||
static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}";
|
||||
static constexpr size_t DEVICE_NAME_PLACEHOLDER_LEN = sizeof(DEVICE_NAME_PLACEHOLDER) - 1;
|
||||
static constexpr const char IP_ADDRESS_PLACEHOLDER[] = "{{ip_address}}";
|
||||
@@ -62,6 +65,21 @@ size_t ImprovBase::get_formatted_next_url_(char *buffer, size_t buffer_size) {
|
||||
*out = '\0';
|
||||
return out - buffer;
|
||||
}
|
||||
|
||||
void ImprovBase::add_next_url_(improv::RpcResponseBuilder &builder, size_t max_len) {
|
||||
// The builder rejects strings above 254 bytes, so anything longer than this
|
||||
// buffer could never be sent anyway
|
||||
char url_buffer[256];
|
||||
size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
|
||||
if (len == 0) {
|
||||
return;
|
||||
}
|
||||
// max_len is the transport's budget for this entry; skipping an over-long URL
|
||||
// here keeps the rest of the response sendable instead of oversizing the frame
|
||||
if (len > max_len || !builder.add_string(url_buffer, len)) {
|
||||
ESP_LOGW(TAG, "Next URL too long; skipping");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::improv_base
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#include <cstddef>
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL)
|
||||
#include <improv.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::improv_base {
|
||||
|
||||
class ImprovBase {
|
||||
@@ -15,6 +19,8 @@ class ImprovBase {
|
||||
#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL)
|
||||
/// Format next_url_ into buffer, replacing placeholders. Returns length written.
|
||||
size_t get_formatted_next_url_(char *buffer, size_t buffer_size);
|
||||
/// Append the formatted next_url to the RPC response, warning if it does not fit.
|
||||
void add_next_url_(improv::RpcResponseBuilder &builder, size_t max_len);
|
||||
const char *next_url_{nullptr};
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -9,13 +9,17 @@
|
||||
#include "esphome/components/logger/logger.h"
|
||||
#include "esphome/components/wifi/scan_list.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::improv_serial {
|
||||
|
||||
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)
|
||||
@@ -59,8 +63,7 @@ void ImprovSerialComponent::loop() {
|
||||
this->cancel_timeout("wifi-connect-timeout");
|
||||
this->set_state_(improv::STATE_PROVISIONED);
|
||||
|
||||
std::vector<uint8_t> url = this->build_rpc_settings_response_(improv::WIFI_SETTINGS);
|
||||
this->send_response_(url);
|
||||
this->send_settings_response_(improv::WIFI_SETTINGS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,7 +92,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:
|
||||
@@ -134,16 +143,11 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size)
|
||||
#endif
|
||||
}
|
||||
|
||||
std::vector<uint8_t> ImprovSerialComponent::build_rpc_settings_response_(improv::Command command) {
|
||||
std::vector<std::string> urls;
|
||||
void ImprovSerialComponent::send_settings_response_(improv::Command command) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, command);
|
||||
#ifdef USE_IMPROV_SERIAL_NEXT_URL
|
||||
{
|
||||
char url_buffer[384];
|
||||
size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
|
||||
if (len > 0) {
|
||||
urls.emplace_back(url_buffer, len);
|
||||
}
|
||||
}
|
||||
this->add_next_url_(builder, MAX_NEXT_URL_LEN);
|
||||
#endif
|
||||
#ifdef USE_WEBSERVER
|
||||
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
|
||||
@@ -152,25 +156,63 @@ std::vector<uint8_t> ImprovSerialComponent::build_rpc_settings_response_(improv:
|
||||
ip.str_to(ip_buf);
|
||||
// "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54
|
||||
char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1];
|
||||
snprintf(webserver_url, sizeof(webserver_url), "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
|
||||
urls.emplace_back(webserver_url);
|
||||
// buf_append_printf keeps the format string in flash on ESP8266
|
||||
size_t len =
|
||||
buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
|
||||
if (!builder.add_string(webserver_url, len)) {
|
||||
ESP_LOGW(TAG, "Response full; URL dropped");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
std::vector<uint8_t> data = improv::build_rpc_response(command, urls, false);
|
||||
return data;
|
||||
this->send_response_(builder.finish(false));
|
||||
}
|
||||
|
||||
std::vector<uint8_t> ImprovSerialComponent::build_version_info_() {
|
||||
void ImprovSerialComponent::send_version_info_() {
|
||||
// Entry cost per field is sizeof(lit): a length byte plus the string
|
||||
#ifdef ESPHOME_PROJECT_NAME
|
||||
std::vector<std::string> infos = {ESPHOME_PROJECT_NAME, ESPHOME_PROJECT_VERSION, ESPHOME_VARIANT, App.get_name()};
|
||||
static constexpr size_t INFO_ENTRIES_LEN =
|
||||
sizeof(ESPHOME_PROJECT_NAME) + sizeof(ESPHOME_PROJECT_VERSION) + sizeof(ESPHOME_VARIANT);
|
||||
#else
|
||||
std::vector<std::string> infos = {"ESPHome", ESPHOME_VERSION, ESPHOME_VARIANT, App.get_name()};
|
||||
static constexpr size_t INFO_ENTRIES_LEN = sizeof("ESPHome") + sizeof(ESPHOME_VERSION) + sizeof(ESPHOME_VARIANT);
|
||||
#endif
|
||||
std::vector<uint8_t> data = improv::build_rpc_response(improv::GET_DEVICE_INFO, infos, false);
|
||||
return data;
|
||||
};
|
||||
static_assert(INFO_ENTRIES_LEN < MAX_SERIAL_PAYLOAD,
|
||||
"esphome project name and version too long for the improv_serial device info frame");
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, improv::GET_DEVICE_INFO);
|
||||
#ifdef USE_ESP8266
|
||||
// Keep each literal in flash and copy it through an exact size stack buffer,
|
||||
// so a long project name or version can never be truncated
|
||||
#define IMPROV_ADD_INFO(lit) \
|
||||
do { \
|
||||
static const char progmem_str[] PROGMEM = lit; \
|
||||
char tmp[sizeof(lit)]; \
|
||||
progmem_memcpy(tmp, progmem_str, sizeof(lit)); \
|
||||
builder.add_string(tmp, sizeof(lit) - 1); \
|
||||
} while (0)
|
||||
#else
|
||||
// Literals are directly flash mapped on all other platforms
|
||||
#define IMPROV_ADD_INFO(lit) builder.add_string(lit, sizeof(lit) - 1)
|
||||
#endif
|
||||
#ifdef ESPHOME_PROJECT_NAME
|
||||
IMPROV_ADD_INFO(ESPHOME_PROJECT_NAME);
|
||||
IMPROV_ADD_INFO(ESPHOME_PROJECT_VERSION);
|
||||
#else
|
||||
IMPROV_ADD_INFO("ESPHome");
|
||||
IMPROV_ADD_INFO(ESPHOME_VERSION);
|
||||
#endif
|
||||
IMPROV_ADD_INFO(ESPHOME_VARIANT);
|
||||
#undef IMPROV_ADD_INFO
|
||||
// Only the device name length is unknown at compile time
|
||||
const auto &name = App.get_name();
|
||||
if (INFO_ENTRIES_LEN + 1 + name.size() <= MAX_SERIAL_PAYLOAD) {
|
||||
builder.add_string(name.c_str(), name.size());
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Response full; device name dropped");
|
||||
}
|
||||
this->send_response_(builder.finish(false));
|
||||
}
|
||||
|
||||
bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) {
|
||||
size_t at = this->rx_buffer_.size();
|
||||
@@ -221,32 +263,35 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command
|
||||
}
|
||||
this->set_state_(this->state_);
|
||||
if (this->state_ == improv::STATE_PROVISIONED) {
|
||||
std::vector<uint8_t> url = this->build_rpc_settings_response_(improv::GET_CURRENT_STATE);
|
||||
this->send_response_(url);
|
||||
this->send_settings_response_(improv::GET_CURRENT_STATE);
|
||||
}
|
||||
return true;
|
||||
case improv::GET_DEVICE_INFO: {
|
||||
std::vector<uint8_t> info = this->build_version_info_();
|
||||
this->send_response_(info);
|
||||
this->send_version_info_();
|
||||
return true;
|
||||
}
|
||||
case improv::GET_WIFI_NETWORKS: {
|
||||
const auto &results = wifi::global_wifi_component->get_scan_result();
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
for (const auto &scan : results) {
|
||||
bool with_auth = false;
|
||||
if (!wifi::should_show_scan_entry(results, scan, with_auth))
|
||||
continue;
|
||||
// Send each ssid separately to avoid overflowing the buffer
|
||||
char rssi_buf[5]; // int8_t: -128 to 127, max 4 chars + null
|
||||
*int8_to_str(rssi_buf, scan.get_rssi()) = '\0';
|
||||
std::vector<uint8_t> data = improv::build_rpc_response(
|
||||
improv::GET_WIFI_NETWORKS, {scan.get_ssid().str(), rssi_buf, YESNO(with_auth)}, false);
|
||||
this->send_response_(data);
|
||||
char *rssi_end = int8_to_str(rssi_buf, scan.get_rssi());
|
||||
*rssi_end = '\0';
|
||||
improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS);
|
||||
// SSID(32) + RSSI(4) + YESNO(3) entries always fit the payload
|
||||
const auto &ssid = scan.get_ssid();
|
||||
builder.add_string(ssid.c_str(), ssid.size());
|
||||
builder.add_string(rssi_buf, rssi_end - rssi_buf);
|
||||
builder.add_string(YESNO(with_auth));
|
||||
this->send_response_(builder.finish(false));
|
||||
}
|
||||
// Send empty response to signify the end of the list.
|
||||
std::vector<uint8_t> data =
|
||||
improv::build_rpc_response(improv::GET_WIFI_NETWORKS, std::vector<std::string>{}, false);
|
||||
this->send_response_(data);
|
||||
improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS);
|
||||
this->send_response_(builder.finish(false));
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
@@ -274,7 +319,14 @@ void ImprovSerialComponent::set_error_(improv::Error error) {
|
||||
this->write_data_();
|
||||
}
|
||||
|
||||
void ImprovSerialComponent::send_response_(std::vector<uint8_t> &response) {
|
||||
void ImprovSerialComponent::send_response_(std::span<const uint8_t> response) {
|
||||
// The serial frame length field is a single byte
|
||||
if (response.size() > MAX_SERIAL_RESPONSE) {
|
||||
ESP_LOGE(TAG, "Response too long");
|
||||
// Fail fast instead of leaving the client to wait out its timeout
|
||||
this->set_error_(improv::ERROR_UNKNOWN);
|
||||
return;
|
||||
}
|
||||
this->tx_header_[TX_TYPE_IDX] = TYPE_RPC_RESPONSE;
|
||||
this->write_data_(response.data(), response.size());
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#ifdef USE_WIFI
|
||||
#include <improv.h>
|
||||
#include <span>
|
||||
#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>
|
||||
@@ -45,6 +48,22 @@ enum ImprovSerialType : uint8_t {
|
||||
static const uint16_t IMPROV_SERIAL_TIMEOUT = 100;
|
||||
static const uint8_t IMPROV_SERIAL_VERSION = 1;
|
||||
|
||||
// The serial frame length field is one byte
|
||||
static constexpr size_t MAX_SERIAL_RESPONSE = 255;
|
||||
// command + data length + trailing byte
|
||||
static constexpr size_t RPC_RESPONSE_OVERHEAD = 3;
|
||||
static constexpr size_t MAX_SERIAL_PAYLOAD = MAX_SERIAL_RESPONSE - RPC_RESPONSE_OVERHEAD;
|
||||
#ifdef USE_WEBSERVER
|
||||
// length byte + "http://" + IPv4 + ":" + port
|
||||
static constexpr size_t WEBSERVER_URL_RESERVE = 1 + 7 + 15 + 1 + 5;
|
||||
#else
|
||||
static constexpr size_t WEBSERVER_URL_RESERVE = 0;
|
||||
#endif
|
||||
// Entry budget minus its own length byte
|
||||
static constexpr size_t MAX_NEXT_URL_LEN = MAX_SERIAL_PAYLOAD - WEBSERVER_URL_RESERVE - 1;
|
||||
|
||||
static_assert(MAX_SERIAL_RESPONSE <= improv::RPC_RESPONSE_MAX_SIZE, "builder buffer too small for the frame");
|
||||
|
||||
class ImprovSerialComponent final : public Component, public improv_base::ImprovBase {
|
||||
public:
|
||||
void setup() override;
|
||||
@@ -53,6 +72,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);
|
||||
@@ -60,16 +83,20 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
|
||||
void set_state_(improv::State state);
|
||||
void send_current_state_(improv::State state);
|
||||
void set_error_(improv::Error error);
|
||||
void send_response_(std::vector<uint8_t> &response);
|
||||
void send_response_(std::span<const uint8_t> response);
|
||||
void on_wifi_connect_timeout_();
|
||||
|
||||
std::vector<uint8_t> build_rpc_settings_response_(improv::Command command);
|
||||
std::vector<uint8_t> build_version_info_();
|
||||
void send_settings_response_(improv::Command command);
|
||||
void send_version_info_();
|
||||
|
||||
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 +156,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)
|
||||
|
||||
@@ -20,7 +20,9 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E
|
||||
|
||||
enum class FunctionCode : uint8_t {
|
||||
INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions).
|
||||
CUSTOM = 0x00, // The CUSTOM alias should be removed in future.
|
||||
// Remove before 2027.3.0
|
||||
CUSTOM ESPDEPRECATED("0x00 is not a function code; use FunctionCode::INVALID for the sentinel. Removed in 2027.3.0",
|
||||
"2026.9.0") = 0x00,
|
||||
READ_COILS = 0x01,
|
||||
READ_DISCRETE_INPUTS = 0x02,
|
||||
READ_HOLDING_REGISTERS = 0x03,
|
||||
|
||||
@@ -153,6 +153,50 @@ inline std::span<const uint8_t> server_pdu_payload(std::span<const uint8_t> pdu)
|
||||
|
||||
inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; }
|
||||
|
||||
/** Extract data from modbus response buffer
|
||||
* @param T one of supported integer data types int_8,int_16,int_32,int_64
|
||||
* @param data modbus response buffer (uint8_t)
|
||||
* @param buffer_offset offset in bytes.
|
||||
* @return value of type T extracted from buffer
|
||||
*/
|
||||
template<typename T> T get_data(const uint8_t *data, size_t buffer_offset) {
|
||||
if (sizeof(T) == sizeof(uint8_t)) {
|
||||
return T(data[buffer_offset]);
|
||||
}
|
||||
if (sizeof(T) == sizeof(uint16_t)) {
|
||||
return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0));
|
||||
}
|
||||
if (sizeof(T) == sizeof(uint32_t)) {
|
||||
return static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset)) << 16 |
|
||||
static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset + 2));
|
||||
}
|
||||
if (sizeof(T) == sizeof(uint64_t)) {
|
||||
return static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset)) << 32 |
|
||||
(static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset + 4)));
|
||||
}
|
||||
static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) ||
|
||||
sizeof(T) == sizeof(uint64_t),
|
||||
"Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported.");
|
||||
return T{};
|
||||
}
|
||||
|
||||
/// Function code of a PDU, exception flag masked; 0 for an empty PDU.
|
||||
inline uint8_t pdu_function_code(std::span<const uint8_t> pdu) {
|
||||
return pdu.empty() ? 0 : (pdu[0] & FUNCTION_CODE_MASK);
|
||||
}
|
||||
|
||||
/// Start address of a standard client request PDU ([fc, addr_hi, addr_lo, ...]). Empty when the PDU is
|
||||
/// too short or its function code has no known layout - custom-frame bytes are not misread as an address.
|
||||
inline std::optional<uint16_t> client_pdu_start_address(std::span<const uint8_t> pdu) {
|
||||
if (pdu.size() < 3 || is_function_code_unknown_length(pdu[0]))
|
||||
return std::nullopt;
|
||||
const auto fc = static_cast<FunctionCode>(pdu[0]);
|
||||
// The file-record PDUs are known-length but carry a byte count, not a start address.
|
||||
if (fc == FunctionCode::READ_FILE_RECORD || fc == FunctionCode::WRITE_FILE_RECORD)
|
||||
return std::nullopt;
|
||||
return get_data<uint16_t>(pdu.data(), 1);
|
||||
}
|
||||
|
||||
enum class SensorValueType : uint8_t {
|
||||
RAW = 0x00, // variable length
|
||||
U_WORD = 0x1, // 1 Register unsigned
|
||||
@@ -256,34 +300,6 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) {
|
||||
return static_cast<uint64_t>(dword_from_hex_str(value, pos)) << 32 | dword_from_hex_str(value, pos + 4);
|
||||
}
|
||||
|
||||
// Extract data from modbus response buffer
|
||||
/** Extract data from modbus response buffer
|
||||
* @param T one of supported integer data types int_8,int_16,int_32,int_64
|
||||
* @param data modbus response buffer (uint8_t)
|
||||
* @param buffer_offset offset in bytes.
|
||||
* @return value of type T extracted from buffer
|
||||
*/
|
||||
template<typename T> T get_data(const uint8_t *data, size_t buffer_offset) {
|
||||
if (sizeof(T) == sizeof(uint8_t)) {
|
||||
return T(data[buffer_offset]);
|
||||
}
|
||||
if (sizeof(T) == sizeof(uint16_t)) {
|
||||
return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0));
|
||||
}
|
||||
if (sizeof(T) == sizeof(uint32_t)) {
|
||||
return static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset)) << 16 |
|
||||
static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset + 2));
|
||||
}
|
||||
if (sizeof(T) == sizeof(uint64_t)) {
|
||||
return static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset)) << 32 |
|
||||
(static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset + 4)));
|
||||
}
|
||||
static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) ||
|
||||
sizeof(T) == sizeof(uint64_t),
|
||||
"Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported.");
|
||||
return T{};
|
||||
}
|
||||
|
||||
template<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_offset) {
|
||||
return get_data<T>(data.data(), buffer_offset);
|
||||
}
|
||||
|
||||
@@ -278,6 +278,21 @@ def _final_validate(config: ConfigType) -> None:
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def reject_odd_holding_write_offset(config: ConfigType) -> ConfigType:
|
||||
"""Reject an odd byte offset on a holding-register write entity.
|
||||
|
||||
A 16-bit register write cannot target half a register, so the residual byte is inexpressible.
|
||||
"""
|
||||
key = CONF_BYTE_OFFSET if CONF_BYTE_OFFSET in config else CONF_OFFSET
|
||||
if config.get(key, 0) % 2:
|
||||
raise cv.Invalid(
|
||||
f"An odd '{key}' cannot be used with holding-register writes: a 16-bit register "
|
||||
"write cannot target half a register. Use an even offset, or fold it into 'address'",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def modbus_calc_properties(config: ConfigType) -> tuple[int, int]:
|
||||
byte_offset = 0
|
||||
reg_count = 0
|
||||
|
||||
@@ -23,60 +23,106 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1
|
||||
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) {
|
||||
void ControllerDevice::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));
|
||||
// A request whose layout carries no start address (a custom PDU) reports -1; 0 stays a real address.
|
||||
static int trigger_address(std::span<const uint8_t> request_pdu) {
|
||||
const auto addr = modbus::helpers::client_pdu_start_address(request_pdu);
|
||||
return addr.has_value() ? *addr : -1;
|
||||
}
|
||||
|
||||
void ControllerDevice::notify_online_(std::span<const uint8_t> request_pdu) {
|
||||
if (this->controller_ != nullptr) {
|
||||
this->controller_->set_online(true, modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
|
||||
}
|
||||
}
|
||||
|
||||
void ControllerDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
|
||||
this->notify_online_(request_pdu);
|
||||
}
|
||||
|
||||
void ControllerDevice::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
|
||||
ESP_LOGW(TAG, "Modbus error function code: 0x%X register %d exception: %d",
|
||||
modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu),
|
||||
static_cast<uint8_t>(exception_code));
|
||||
this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online
|
||||
}
|
||||
|
||||
void WriterDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
|
||||
this->notify_online_(request_pdu);
|
||||
ControllerDevice::on_response(request_pdu, response_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
|
||||
ControllerDevice::on_error(request_pdu, exception_code);
|
||||
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));
|
||||
void ControllerDevice::on_sent(std::span<const uint8_t> request_pdu) {
|
||||
if (this->controller_ != nullptr) {
|
||||
this->controller_->command_sent(modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
|
||||
}
|
||||
}
|
||||
|
||||
bool WriterDevice::on_no_response(std::span<const uint8_t> request_pdu) {
|
||||
void ControllerDevice::on_not_sent(std::span<const uint8_t> request_pdu) {
|
||||
const uint8_t fc = modbus::helpers::pdu_function_code(request_pdu);
|
||||
const int addr = trigger_address(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)) {
|
||||
ESP_LOGW(TAG, "Write not sent: function 0x%X register %d", fc, addr);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Request not sent: function 0x%X register %d", fc, addr);
|
||||
}
|
||||
}
|
||||
|
||||
bool ControllerDevice::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));
|
||||
this->controller_->set_online(false, modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
|
||||
return false;
|
||||
}
|
||||
|
||||
PollingDevice::PollingDevice(ModbusController &controller, RegisterRange &&range)
|
||||
: ControllerDevice(&controller), range_(std::move(range)) {}
|
||||
|
||||
bool PollingDevice::queue(modbus::CommandOptions options) {
|
||||
bool accepted;
|
||||
if (this->range_.custom_pdu != nullptr) {
|
||||
accepted = this->queue_pdu(std::span<const uint8_t>(*this->range_.custom_pdu), options);
|
||||
} else {
|
||||
accepted = this->read_entities(this->range_.register_type, this->range_.start_address, this->range_.register_count,
|
||||
options);
|
||||
}
|
||||
if (accepted) {
|
||||
ESP_LOGV(TAG, "Poll queued type=%u 0x%X %d", static_cast<uint8_t>(this->range_.register_type),
|
||||
this->range_.start_address, this->range_.register_count);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
void PollingDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
|
||||
this->notify_online_(request_pdu);
|
||||
auto data = modbus::helpers::server_pdu_payload(response_pdu);
|
||||
for (auto *sensor : this->range_.sensors)
|
||||
sensor->parse_and_publish(data);
|
||||
}
|
||||
|
||||
// ModbusCommandItem's machinery stays as-is until its removal in 2027.3.0; silence its self-references.
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
|
||||
RegisterRange &&range)
|
||||
: modbus::ModbusClientDevice(parent, address),
|
||||
@@ -207,6 +253,8 @@ bool ModbusCommandItem::on_no_response(std::span<const uint8_t> request_pdu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
void ModbusController::set_online(bool online, int function_code, int register_address) {
|
||||
if (online) {
|
||||
this->cmd_non_responses_ = 0;
|
||||
@@ -228,6 +276,8 @@ void ModbusController::set_online(bool online, int function_code, int register_a
|
||||
}
|
||||
}
|
||||
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
void ModbusController::queue_command(ModbusCommandItem command) {
|
||||
this->sweep_completed_one_shots_(); // reclaim finished one-shots before adding a new one
|
||||
// Duplicates are the caller's to manage; the controller only holds the item until its terminal callback.
|
||||
@@ -262,6 +312,8 @@ void ModbusController::sweep_completed_one_shots_() {
|
||||
[](const std::unique_ptr<ModbusCommandItem> &item) { return item->pending_removal; });
|
||||
}
|
||||
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
void ModbusController::update() {
|
||||
this->sweep_completed_one_shots_(); // reclaim one-shots deferred out of their own callbacks
|
||||
if (this->module_offline_) {
|
||||
@@ -270,11 +322,11 @@ void ModbusController::update() {
|
||||
if (offline_retry_due(this->update_counter_, this->module_offline_at_, this->offline_skip_updates_)) {
|
||||
ESP_LOGV(TAG, "Module offline - retrying");
|
||||
this->cmd_non_responses_ = 0; // allow the probe through can_send()
|
||||
for (auto &cmd : this->polling_command_items_) {
|
||||
for (auto &poll : this->polling_devices_) {
|
||||
// Probes carry the read-side options too, so a recovering device resumes streaming on the
|
||||
// probe itself rather than waiting for the next update_interval.
|
||||
if (!cmd.send(this->read_options_)) {
|
||||
ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address());
|
||||
if (!poll.queue(this->read_options_)) {
|
||||
ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", poll.register_address());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -285,12 +337,12 @@ void ModbusController::update() {
|
||||
}
|
||||
|
||||
if (this->can_send()) {
|
||||
for (auto &cmd : this->polling_command_items_) {
|
||||
ESP_LOGVV(TAG, "Updating range 0x%X", cmd.register_address());
|
||||
for (auto &poll : this->polling_devices_) {
|
||||
ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address());
|
||||
// read_options_ carries the controller's continuous flag (the offline probe above sends it too).
|
||||
// A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
|
||||
if (!cmd.send(this->read_options_)) {
|
||||
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address());
|
||||
if (!poll.queue(this->read_options_)) {
|
||||
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,6 +360,10 @@ void ModbusController::create_polling_commands_() {
|
||||
// force_new_range ahead of the rest, then address - so the walk is not purely address-ordered.
|
||||
// Each keeps the address it was configured with; what is resolved here is its `offset`, the position
|
||||
// of its data within the response of whichever range it ends up in.
|
||||
// One range per sensor is a strict upper bound: each walk step closes at most one range, plus one
|
||||
// closed after the walk. Sized to that bound so no push is ever silently dropped, then handed on by move.
|
||||
FixedVector<RegisterRange> ranges;
|
||||
ranges.init(this->sensorset_.size());
|
||||
RegisterRange r = {};
|
||||
bool have_range = false;
|
||||
// Set while the open range belongs to a force_new_range sensor: a range the user asked to keep
|
||||
@@ -390,7 +446,7 @@ void ModbusController::create_polling_commands_() {
|
||||
if (!join) {
|
||||
if (have_range) {
|
||||
ESP_LOGV(TAG, "Add range 0x%X %d", r.start_address, r.register_count);
|
||||
this->create_polling_command_(std::move(r));
|
||||
ranges.push_back(std::move(r));
|
||||
}
|
||||
r = {};
|
||||
range_bytes = curr->get_register_size();
|
||||
@@ -401,6 +457,8 @@ void ModbusController::create_polling_commands_() {
|
||||
r.start_address = curr->start_address;
|
||||
r.register_count = curr->register_count;
|
||||
r.register_type = curr->register_type;
|
||||
if (curr->register_type == modbus::EntityType::CUSTOM)
|
||||
r.custom_pdu = &curr->custom_pdu;
|
||||
have_range = true;
|
||||
}
|
||||
|
||||
@@ -412,11 +470,13 @@ void ModbusController::create_polling_commands_() {
|
||||
}
|
||||
if (have_range) {
|
||||
ESP_LOGV(TAG, "Add last range 0x%X %d", r.start_address, r.register_count);
|
||||
this->create_polling_command_(std::move(r));
|
||||
ranges.push_back(std::move(r));
|
||||
}
|
||||
// Staged in a setup-time vector so the device storage can be sized exactly (see polling_devices_).
|
||||
this->polling_devices_.init(ranges.size());
|
||||
for (auto &range : ranges) {
|
||||
this->polling_devices_.emplace_back(*this, std::move(range));
|
||||
}
|
||||
// Reclaim growth slack; safe here because nothing has registered with the hub yet (see the
|
||||
// lifetime note on polling_command_items_).
|
||||
this->polling_command_items_.shrink_to_fit();
|
||||
}
|
||||
|
||||
void ModbusController::dump_config() {
|
||||
@@ -435,13 +495,15 @@ void ModbusController::dump_config() {
|
||||
it->get_register_size());
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, "ranges");
|
||||
for (auto &it : this->polling_command_items_) {
|
||||
for (auto &it : this->polling_devices_) {
|
||||
ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d", static_cast<uint8_t>(it.register_type()),
|
||||
it.register_address(), it.register_count());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
void ModbusController::on_write_register_response(EntityType register_type, uint16_t start_address,
|
||||
std::span<const uint8_t> data) {
|
||||
// A well-formed write ACK echoes address and value, but a truncated PDU yields a short/empty span.
|
||||
@@ -598,5 +660,6 @@ bool ModbusCommandItem::send(modbus::CommandOptions options) {
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
} // namespace esphome::modbus_controller
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "esphome/components/modbus/modbus.h"
|
||||
#include "esphome/components/modbus/modbus_helpers.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/automation.h"
|
||||
|
||||
#include <list>
|
||||
@@ -230,15 +231,22 @@ struct RegisterRange {
|
||||
modbus::EntityType register_type;
|
||||
uint8_t register_count;
|
||||
SensorSet sensors; // all sensors of this range
|
||||
/// A custom range polls this PDU, referenced from the sensor that opened the range.
|
||||
const SmallInlineBuffer<8> *custom_pdu{nullptr};
|
||||
};
|
||||
|
||||
/// 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 {
|
||||
/// The shared feedback half of a controller-owned hub device: online/offline tracking, retry counting
|
||||
/// and the on_command_sent trigger all route to the controller from here. The hub base is inherited
|
||||
/// protected, so a subclass chooses exactly what request API it exposes.
|
||||
class ControllerDevice : protected modbus::ModbusClientDevice {
|
||||
public:
|
||||
// Public: only the owner can reach this instance, so reachability is the access gate.
|
||||
void set_controller(ModbusController *controller);
|
||||
|
||||
protected:
|
||||
ControllerDevice() = default; // WriterEntity's member is wired later via set_controller()
|
||||
explicit ControllerDevice(ModbusController *controller) { this->set_controller(controller); }
|
||||
|
||||
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;
|
||||
@@ -246,90 +254,82 @@ class WriterDevice final : protected modbus::ModbusClientDevice {
|
||||
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.
|
||||
/// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's
|
||||
/// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0.
|
||||
bool dispatched_{false};
|
||||
bool write_buffer_deprecated_warned_{false};
|
||||
ModbusController *controller_{nullptr};
|
||||
};
|
||||
|
||||
/// The write side of a ControllerDevice, owned by the writer entities through WriterEntity, whose
|
||||
/// forwarders re-expose exactly the request API a write lambda may use and record every dispatch.
|
||||
class WriterDevice final : public ControllerDevice {
|
||||
public:
|
||||
/// Whether a frame was queued to the hub since the last clear_dispatched_().
|
||||
bool dispatched() const { return this->dispatched_; }
|
||||
using modbus::ModbusClientDevice::clear_tx_queue_for_device;
|
||||
using modbus::ModbusClientDevice::queue_pdu;
|
||||
using modbus::ModbusClientDevice::write_multiple_coils;
|
||||
using modbus::ModbusClientDevice::write_multiple_registers;
|
||||
using modbus::ModbusClientDevice::write_single_coil;
|
||||
using modbus::ModbusClientDevice::write_single_register;
|
||||
|
||||
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);
|
||||
bool dispatched() const { return this->dispatched_; }
|
||||
void set_dispatched() { this->dispatched_ = true; }
|
||||
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);
|
||||
|
||||
protected:
|
||||
// Only the write side forwards to the typed callbacks (for item->queue_pdu() replies): a poll parses
|
||||
// its own response, and dispatching its errors would trip the base unhandled-custom-response warning.
|
||||
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;
|
||||
};
|
||||
|
||||
/// 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.
|
||||
/// The forwarders keep `item->write_*()` working unchanged inside a write_lambda, and record every
|
||||
/// dispatch, so the write path can tell "the lambda sent it itself" from "use the default write".
|
||||
class WriterEntity {
|
||||
public:
|
||||
/// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records
|
||||
/// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write.
|
||||
bool dispatched() const { return this->device_.dispatched(); }
|
||||
bool write_single_register(uint16_t address, uint16_t value) {
|
||||
this->device_.set_dispatched();
|
||||
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_single_coil(uint16_t address, bool value) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_single_coil(address, value);
|
||||
}
|
||||
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_multiple_registers(address, values);
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_multiple_coils(address, values);
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_multiple_coils(address, bits);
|
||||
}
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
|
||||
this->device_.set_dispatched();
|
||||
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) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.send_raw_frame_deprecated(frame);
|
||||
}
|
||||
void set_controller_(ModbusController *controller) { this->device_.set_controller(controller); }
|
||||
@@ -338,13 +338,40 @@ class WriterEntity {
|
||||
this->device_.warn_write_buffer_deprecated(platform, address);
|
||||
}
|
||||
|
||||
private:
|
||||
// Private so a derived entity cannot reach the device except through the recording forwarders above.
|
||||
WriterDevice device_;
|
||||
};
|
||||
|
||||
/// A persistent hub device that polls one register range - the read-side mirror of WriterDevice.
|
||||
/// Owned by the controller, one per range; the response is parsed straight to the range's sensors.
|
||||
class PollingDevice final : public ControllerDevice {
|
||||
public:
|
||||
PollingDevice(ModbusController &controller, RegisterRange &&range);
|
||||
|
||||
/// Queue this range's read (or its sensor's custom PDU) on the hub. False = refused, no callback follows.
|
||||
bool queue(modbus::CommandOptions options = {});
|
||||
|
||||
uint16_t register_address() const { return this->range_.start_address; }
|
||||
uint16_t register_count() const { return this->range_.register_count; }
|
||||
EntityType register_type() const { return this->range_.register_type; }
|
||||
|
||||
protected:
|
||||
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
|
||||
|
||||
RegisterRange range_;
|
||||
};
|
||||
|
||||
/// 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.
|
||||
class ModbusCommandItem : public modbus::ModbusClientDevice {
|
||||
// The deprecated class references other deprecated names. Remove before 2027.3.0.
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
class ESPDEPRECATED(
|
||||
"One-shot writes go through the entity write helpers (WriterDevice) or the modbus_client actions, and "
|
||||
"polling runs through PollingDevice. Removed in 2027.3.0",
|
||||
"2026.9.0") ModbusCommandItem : public modbus::ModbusClientDevice {
|
||||
public:
|
||||
/// Empty command with no controller connection (kept for source compatibility with value-type usage).
|
||||
ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address)
|
||||
@@ -489,6 +516,7 @@ class ModbusCommandItem : public modbus::ModbusClientDevice {
|
||||
const SmallInlineBuffer<8> *custom_pdu_{nullptr};
|
||||
ModbusController *controller_{nullptr};
|
||||
};
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
/// Whether an offline probe is due this update cycle: every offline_skip_updates + 1 cycles,
|
||||
/// anchored at the cycle the device went offline. Pure so the cadence (including update_counter
|
||||
@@ -522,14 +550,24 @@ class ModbusController final : public PollingComponent {
|
||||
modbus::ModbusClientHub *hub() const { return this->hub_; }
|
||||
uint8_t device_address() const { return this->address_; }
|
||||
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
/// Queues a one-shot modbus command (writes, custom commands); taken by value, so std::move to avoid a copy.
|
||||
/// Remove with ModbusCommandItem before 2027.3.0.
|
||||
ESPDEPRECATED("Use the entity write helpers or the modbus_client actions instead. Removed in 2027.3.0", "2026.9.0")
|
||||
void queue_command(ModbusCommandItem command);
|
||||
/// Flags a finished one-shot command for removal. Called by the command as the last action of its own
|
||||
/// callback, so the item is not destroyed here (send() and the hub still touch it) but swept later.
|
||||
/// Remove with ModbusCommandItem before 2027.3.0.
|
||||
ESPDEPRECATED("Serves only ModbusCommandItem's own callbacks. Removed in 2027.3.0", "2026.9.0")
|
||||
void unqueue_command(const ModbusCommandItem *command);
|
||||
#pragma GCC diagnostic pop
|
||||
/// Registers a sensor with the controller. Called by esphomes code generator
|
||||
void add_sensor_item(SensorItem *item) { sensorset_.insert(item); }
|
||||
/// Handles a write command acknowledgement (used by write command on_data_func handlers).
|
||||
/// Remove with ModbusCommandItem before 2027.3.0.
|
||||
ESPDEPRECATED("Write acknowledgements are handled by the writing entity's own device. Removed in 2027.3.0",
|
||||
"2026.9.0")
|
||||
void on_write_register_response(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data);
|
||||
/// Update the online/offline state after a response or a run of timeouts, firing the callbacks.
|
||||
void set_online(bool online, int function_code, int register_address);
|
||||
@@ -568,32 +606,22 @@ class ModbusController final : public PollingComponent {
|
||||
const modbus::CommandOptions &read_options() const { return this->read_options_; }
|
||||
|
||||
protected:
|
||||
/// parse sensormap_ and create range of sequential addresses
|
||||
/// Group the registered sensors into contiguous ranges and create one polling command per range.
|
||||
/// Group the registered sensors into contiguous ranges and create one PollingDevice per range.
|
||||
void create_polling_commands_();
|
||||
/// build one persistent polling command from a range and add it to polling_command_items_
|
||||
void create_polling_command_(RegisterRange &&range) {
|
||||
// A custom range polls the first sensor's custom_pdu (referenced, not copied); the sensor constructor
|
||||
// decodes the real function code. The response still dispatches to every sensor in the range.
|
||||
if (range.register_type == EntityType::CUSTOM && !range.sensors.empty()) {
|
||||
auto &cmd = this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, *range.sensors.begin());
|
||||
cmd.sensors = std::move(range.sensors);
|
||||
} else {
|
||||
this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, std::move(range));
|
||||
}
|
||||
}
|
||||
/// The hub this controller's commands/entities send through, and the modbus address they target.
|
||||
modbus::ModbusClientHub *hub_{nullptr};
|
||||
uint8_t address_{0};
|
||||
/// Collection of all sensors for this component
|
||||
SensorSet sensorset_;
|
||||
/// One persistent command per register range, each its own ModbusClientDevice. Built once in setup()
|
||||
/// (create_polling_commands_ feeds each range straight in; the vector may reallocate as it grows, which
|
||||
/// is safe because no command has registered with the hub yet) and never appended to afterward, so the
|
||||
/// hub's device pointers stay valid once commands start sending.
|
||||
std::vector<ModbusCommandItem> polling_command_items_{};
|
||||
/// One persistent PollingDevice per register range. Built once in setup() with the exact count
|
||||
/// (FixedVector never reallocates), so the hub's device pointers stay valid once polls start sending.
|
||||
FixedVector<PollingDevice> polling_devices_;
|
||||
/// Dynamically queued one-shot commands (writes, custom commands). std::list keeps stable addresses.
|
||||
/// Remove with ModbusCommandItem before 2027.3.0.
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
std::list<std::unique_ptr<ModbusCommandItem>> one_shot_command_items_;
|
||||
#pragma GCC diagnostic pop
|
||||
/// Erases one-shot commands flagged by unqueue_command(). Safe even when reached from inside a hub
|
||||
/// callback (via an on_online/on_offline/on_command_sent automation that queues a command): the
|
||||
/// destructor detaches via clear_tx_queue_for_device(), which the hub allows from callbacks, and the
|
||||
|
||||
@@ -14,6 +14,7 @@ from .. import (
|
||||
SensorItem,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
reject_odd_holding_write_offset,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_CUSTOM_COMMAND,
|
||||
@@ -53,23 +54,26 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
"holding": output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ModbusFloatOutput),
|
||||
cv.Required(CONF_ADDRESS): cv.positive_int,
|
||||
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
|
||||
"custom_pdu is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
|
||||
"custom_command is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(
|
||||
SENSOR_VALUE_TYPE
|
||||
),
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
}
|
||||
"holding": cv.All(
|
||||
output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ModbusFloatOutput),
|
||||
cv.Required(CONF_ADDRESS): cv.positive_int,
|
||||
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
|
||||
"custom_pdu is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
|
||||
"custom_command is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(
|
||||
SENSOR_VALUE_TYPE
|
||||
),
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
reject_odd_holding_write_offset,
|
||||
),
|
||||
},
|
||||
lower=True,
|
||||
|
||||
@@ -12,7 +12,8 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu
|
||||
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 + offset);
|
||||
// A byte offset folds into the address as whole registers; odd offsets are rejected at validation.
|
||||
this->set_address(start_address + offset / 2);
|
||||
this->set_offset_from_start_address(0);
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
this->register_count = register_count;
|
||||
|
||||
@@ -11,6 +11,7 @@ from .. import (
|
||||
add_modbus_base_properties,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
reject_odd_holding_write_offset,
|
||||
validate_custom_pdu_item,
|
||||
validate_modbus_register,
|
||||
)
|
||||
@@ -31,6 +32,14 @@ ModbusSwitch = modbus_controller_ns.class_(
|
||||
"ModbusSwitch", cg.Component, switch.Switch, SensorItem
|
||||
)
|
||||
|
||||
|
||||
def _validate_holding_offset(config: ConfigType) -> ConfigType:
|
||||
# Only a holding-register switch folds the byte offset into a 16-bit register write.
|
||||
if config.get(CONF_REGISTER_TYPE) == "holding":
|
||||
reject_odd_holding_write_offset(config)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
switch.switch_schema(ModbusSwitch, default_restore_mode="DISABLED")
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
@@ -44,6 +53,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
),
|
||||
validate_modbus_register,
|
||||
_validate_holding_offset,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
|
||||
|
||||
@@ -65,6 +65,7 @@ void ModbusSwitch::write_state(bool state) {
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::helpers::PduBuffer data;
|
||||
bool write_value = state;
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// 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.
|
||||
@@ -92,26 +93,29 @@ void ModbusSwitch::write_state(bool state) {
|
||||
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
|
||||
return;
|
||||
}
|
||||
// The returned bool is the wire value only; the entity still reports the requested state. A polled
|
||||
// entity needs the read lambda inverted to match, or the next poll flips the display back.
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
state = val.value();
|
||||
write_value = val.value();
|
||||
}
|
||||
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);
|
||||
ESP_LOGV(TAG, "write_state '%s': new value = %s (wire = %s) type = %d address = %X offset = %x",
|
||||
this->get_name().c_str(), ONOFF(state), ONOFF(write_value), (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};
|
||||
std::array<bool, 1> states{write_value};
|
||||
queued = this->write_multiple_coils(this->write_address(), states);
|
||||
} else {
|
||||
queued = this->write_single_coil(this->write_address(), state);
|
||||
queued = this->write_single_coil(this->write_address(), write_value);
|
||||
}
|
||||
} else {
|
||||
if (this->use_write_multiple_) {
|
||||
std::array<uint16_t, 1> states{static_cast<uint16_t>(state ? (0xFFFF & this->bitmask) : 0)};
|
||||
std::array<uint16_t, 1> states{static_cast<uint16_t>(write_value ? (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);
|
||||
queued = this->write_single_register(this->write_address(), write_value ? 0xFFFF & this->bitmask : 0u);
|
||||
}
|
||||
}
|
||||
if (!queued) {
|
||||
|
||||
@@ -18,8 +18,13 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens
|
||||
this->bitmask = bitmask;
|
||||
this->sensor_value_type = SensorValueType::BIT;
|
||||
this->register_count = 1;
|
||||
if (register_type == modbus::EntityType::HOLDING || register_type == modbus::EntityType::COIL) {
|
||||
this->set_address(this->start_address + offset);
|
||||
// A holding byte offset folds into the address as whole registers (odd offsets are rejected at
|
||||
// validation: a 16-bit register write cannot target half a register); a coil offset is a coil count.
|
||||
if (register_type == modbus::EntityType::HOLDING) {
|
||||
this->set_address(start_address + offset / 2);
|
||||
this->set_offset_from_start_address(0);
|
||||
} else if (register_type == modbus::EntityType::COIL) {
|
||||
this->set_address(start_address + offset);
|
||||
this->set_offset_from_start_address(0);
|
||||
}
|
||||
this->force_new_range = force_new_range;
|
||||
|
||||
@@ -41,7 +41,11 @@ MQTTClientComponent::MQTTClientComponent() {
|
||||
global_mqtt_client = this;
|
||||
char mac_addr[MAC_ADDRESS_BUFFER_SIZE];
|
||||
get_mac_address_into_buffer(mac_addr);
|
||||
this->credentials_.client_id = make_name_with_suffix(App.get_name(), '-', mac_addr, MAC_ADDRESS_BUFFER_SIZE - 1);
|
||||
const StringRef &name = App.get_name();
|
||||
char client_id[MAX_NAME_WITH_SUFFIX_SIZE];
|
||||
size_t len = make_name_with_suffix_to(client_id, sizeof(client_id), name.c_str(), name.size(), '-', mac_addr,
|
||||
MAC_ADDRESS_BUFFER_SIZE - 1);
|
||||
this->credentials_.client_id.assign(client_id, len);
|
||||
}
|
||||
|
||||
// Connection
|
||||
|
||||
@@ -188,6 +188,8 @@ struct IPAddress {
|
||||
}
|
||||
IPAddress(const std::string &in_address) { inet_aton(in_address.c_str(), &ip_addr_); }
|
||||
IPAddress(const ip_addr_t *other_ip) { ip_addr_ = *other_ip; }
|
||||
bool is_ip4() const { return true; }
|
||||
bool is_ip6() const { return false; }
|
||||
/// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes.
|
||||
char *str_to(char *buf) const {
|
||||
inet_ntop(AF_INET, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <openthread/cli.h>
|
||||
#include <openthread/instance.h>
|
||||
#include <openthread/ip6.h>
|
||||
#include <openthread/logging.h>
|
||||
#include <openthread/netdata.h>
|
||||
#include <openthread/tasklet.h>
|
||||
@@ -229,26 +230,43 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) {
|
||||
void OpenThreadSrpComponent::set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; }
|
||||
|
||||
bool OpenThreadComponent::teardown() {
|
||||
if (!this->teardown_started_) {
|
||||
this->teardown_started_ = true;
|
||||
ESP_LOGD(TAG, "Clear Srp");
|
||||
auto lock = InstanceLock::try_acquire(100);
|
||||
if (!lock) {
|
||||
ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory");
|
||||
return true;
|
||||
}
|
||||
otInstance *instance = lock.get_instance();
|
||||
otSrpClientClearHostAndServices(instance);
|
||||
otSrpClientBuffersFreeAllServices(instance);
|
||||
global_openthread_component = nullptr;
|
||||
ESP_LOGD(TAG, "Exit main loop ");
|
||||
int error = this->openthread_stop_();
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error);
|
||||
this->teardown_complete_ = true;
|
||||
}
|
||||
switch (this->teardown_stage_) {
|
||||
case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: {
|
||||
auto lock = InstanceLock::try_acquire(100);
|
||||
if (!lock) {
|
||||
// Try again on next teardown loop
|
||||
ESP_LOGV(TAG, "Failed to acquire OpenThread lock during teardown");
|
||||
return false;
|
||||
}
|
||||
// Start tearing down
|
||||
this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS;
|
||||
ESP_LOGV(TAG, "Clear SRP");
|
||||
otInstance *instance = lock.get_instance();
|
||||
otSrpClientClearHostAndServices(instance);
|
||||
otSrpClientBuffersFreeAllServices(instance);
|
||||
if (otThreadSetEnabled(instance, false) != OT_ERROR_NONE) {
|
||||
ESP_LOGW(TAG, "Failed to disable Thread during teardown");
|
||||
}
|
||||
if (otIp6SetEnabled(instance, false) != OT_ERROR_NONE) {
|
||||
ESP_LOGW(TAG, "Failed to disable IPv6 during teardown");
|
||||
}
|
||||
// Stop OpenThread
|
||||
global_openthread_component = nullptr;
|
||||
ESP_LOGV(TAG, "Stop OpenThread");
|
||||
int error = this->openthread_stop_();
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "Failed attempt to stop OpenThread %d", error);
|
||||
this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED;
|
||||
}
|
||||
} break;
|
||||
case TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS:
|
||||
// Waiting on OpenThread stop
|
||||
break;
|
||||
case TeardownStage::TEARDOWN_STAGE_COMPLETED:
|
||||
ESP_LOGV(TAG, "OpenThreadComponent Teardown Complete");
|
||||
break;
|
||||
}
|
||||
return this->teardown_complete_;
|
||||
return this->teardown_stage_ == TeardownStage::TEARDOWN_STAGE_COMPLETED;
|
||||
}
|
||||
|
||||
void OpenThreadComponent::on_factory_reset(std::function<void()> callback) {
|
||||
|
||||
@@ -19,6 +19,12 @@ namespace esphome::openthread {
|
||||
|
||||
class InstanceLock;
|
||||
|
||||
enum class TeardownStage : uint8_t {
|
||||
TEARDOWN_STAGE_NOT_STARTED = 0,
|
||||
TEARDOWN_STAGE_STOP_IN_PROCESS,
|
||||
TEARDOWN_STAGE_COMPLETED,
|
||||
};
|
||||
|
||||
template<typename... Ts> class OpenThreadComponentPollPeriodAction;
|
||||
|
||||
class OpenThreadComponent final : public Component {
|
||||
@@ -71,9 +77,8 @@ class OpenThreadComponent final : public Component {
|
||||
#endif
|
||||
std::optional<int8_t> output_power_{};
|
||||
std::atomic<bool> lock_initialized_{false};
|
||||
bool teardown_started_{false};
|
||||
bool teardown_complete_{false};
|
||||
bool connected_{false};
|
||||
std::atomic<TeardownStage> teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED};
|
||||
std::atomic<bool> connected_{false};
|
||||
|
||||
private:
|
||||
// Stores a pointer to a string literal (static storage duration).
|
||||
|
||||
@@ -168,7 +168,7 @@ void OpenThreadComponent::ot_main() {
|
||||
esp_netif_destroy(openthread_netif);
|
||||
|
||||
esp_vfs_eventfd_unregister();
|
||||
this->teardown_complete_ = true;
|
||||
this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,10 +90,8 @@ void OpenThreadComponent::ot_main() {}
|
||||
otInstance *OpenThreadComponent::get_openthread_instance_() { return openthread_get_default_instance(); }
|
||||
|
||||
int OpenThreadComponent::openthread_stop_() {
|
||||
// OT stack is intentionally left running — no Zephyr stop API. The state callback stays
|
||||
// registered but is safe (null-checks global_openthread_component). nRF52840 never
|
||||
// re-enters setup() after teardown so this is functionally correct.
|
||||
this->teardown_complete_ = true;
|
||||
// Zephyr has no stack-stop API, so stop is synchronous here; mark complete immediately.
|
||||
this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ class ProvisioningData:
|
||||
sources: set[str] = field(default_factory=set)
|
||||
# Names of source components that have their credentials set in the config.
|
||||
hardcoded_credentials: set[str] = field(default_factory=set)
|
||||
# True when WiFi is configured with an access point but no station credentials.
|
||||
ap_without_sta: bool = False
|
||||
|
||||
|
||||
def _get_data() -> ProvisioningData:
|
||||
@@ -56,6 +58,17 @@ def report_hardcoded_credentials(name: str) -> None:
|
||||
_get_data().hardcoded_credentials.add(name)
|
||||
|
||||
|
||||
def report_ap_without_sta() -> None:
|
||||
"""Record that WiFi runs an access point with no station credentials.
|
||||
|
||||
The access point (and captive portal) shut down when the provisioning window
|
||||
closes. On a device where that access point is the only network connection,
|
||||
closing the window makes the device unreachable until it is power-cycled, so
|
||||
`provisioning:` warns about this combination.
|
||||
"""
|
||||
_get_data().ap_without_sta = True
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ProvisioningManager),
|
||||
@@ -89,6 +102,13 @@ def _final_validate(config: ConfigType) -> None:
|
||||
"hardcoding them makes the window pointless.",
|
||||
", ".join(sorted(data.hardcoded_credentials)),
|
||||
)
|
||||
if data.ap_without_sta:
|
||||
_LOGGER.warning(
|
||||
"'provisioning' is configured with a WiFi access point and no station "
|
||||
"credentials. The access point shuts down when the provisioning window "
|
||||
"closes, so if it is the device's only network connection, the device "
|
||||
"will be unreachable until it is power-cycled."
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
@@ -28,6 +28,7 @@ ImageDecoder = runtime_image_ns.class_("ImageDecoder")
|
||||
BmpDecoder = runtime_image_ns.class_("BmpDecoder", ImageDecoder)
|
||||
JpegDecoder = runtime_image_ns.class_("JpegDecoder", ImageDecoder)
|
||||
PngDecoder = runtime_image_ns.class_("PngDecoder", ImageDecoder)
|
||||
QoiDecoder = runtime_image_ns.class_("QoiDecoder", ImageDecoder)
|
||||
|
||||
# Runtime image class
|
||||
RuntimeImage = runtime_image_ns.class_(
|
||||
@@ -37,9 +38,10 @@ RuntimeImage = runtime_image_ns.class_(
|
||||
# Image format enum
|
||||
ImageFormat = runtime_image_ns.enum("ImageFormat")
|
||||
IMAGE_FORMAT_AUTO = ImageFormat.AUTO
|
||||
IMAGE_FORMAT_BMP = ImageFormat.BMP
|
||||
IMAGE_FORMAT_JPEG = ImageFormat.JPEG
|
||||
IMAGE_FORMAT_PNG = ImageFormat.PNG
|
||||
IMAGE_FORMAT_BMP = ImageFormat.BMP
|
||||
IMAGE_FORMAT_QOI = ImageFormat.QOI
|
||||
|
||||
# Export enum for decode errors
|
||||
DecodeError = runtime_image_ns.enum("DecodeError")
|
||||
@@ -115,14 +117,27 @@ class PNGFormat(Format):
|
||||
cg.add_library("pngle", "1.1.0")
|
||||
|
||||
|
||||
class QOIFormat(Format):
|
||||
"""QOI format decoder configuration."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("QOI", QoiDecoder)
|
||||
|
||||
def actions(self) -> None:
|
||||
cg.add_define("USE_RUNTIME_IMAGE_QOI")
|
||||
|
||||
|
||||
# Decodable formats only; platforms that support runtime detection accept
|
||||
# "AUTO" in their own schema and get_format() resolves it
|
||||
_JPEG_FORMAT = JPEGFormat()
|
||||
|
||||
# Registry of available formats
|
||||
IMAGE_FORMATS = {
|
||||
"BMP": BMPFormat(),
|
||||
"JPEG": _JPEG_FORMAT,
|
||||
"JPG": _JPEG_FORMAT, # Alias for JPEG
|
||||
"PNG": PNGFormat(),
|
||||
"QOI": QOIFormat(),
|
||||
}
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
|
||||
@@ -21,6 +21,9 @@ static constexpr MimeLookup MIME_LOOKUP_TABLE[] = {
|
||||
#ifdef USE_RUNTIME_IMAGE_PNG
|
||||
{"image/png", ImageFormat::PNG}, {"image/x-png", ImageFormat::PNG},
|
||||
#endif
|
||||
#ifdef USE_RUNTIME_IMAGE_QOI
|
||||
{"image/qoi", ImageFormat::QOI}, {"image/x-qoi", ImageFormat::QOI},
|
||||
#endif
|
||||
};
|
||||
|
||||
const char *get_mime_type_for_format(ImageFormat format) {
|
||||
|
||||
@@ -11,12 +11,14 @@ enum ImageFormat {
|
||||
/** Format is supplied per decode, e.g. detected from the Content-Type header
|
||||
* by online_image; sniffing the image data is not implemented. */
|
||||
AUTO,
|
||||
/** BMP format. */
|
||||
BMP,
|
||||
/** JPEG format. */
|
||||
JPEG,
|
||||
/** PNG format. */
|
||||
PNG,
|
||||
/** BMP format. */
|
||||
BMP,
|
||||
/** QOI format. */
|
||||
QOI,
|
||||
};
|
||||
|
||||
/// Canonical MIME type for a format; "image/*" for AUTO/unknown
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#include "qoi_decoder.h"
|
||||
|
||||
#ifdef USE_RUNTIME_IMAGE_QOI
|
||||
|
||||
#include "esphome/components/display/display.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cinttypes>
|
||||
|
||||
namespace esphome::runtime_image {
|
||||
|
||||
static const char *const TAG = "image_decoder.qoi";
|
||||
|
||||
constexpr uint8_t QOI_OP_RGB = 0b11111110;
|
||||
constexpr uint8_t QOI_OP_RGBA = 0b11111111;
|
||||
constexpr uint8_t QOI_OP_INDEX = 0b00000000; // 00xxxxxx
|
||||
constexpr uint8_t QOI_OP_DIFF = 0b01000000; // 01xxxxxx
|
||||
constexpr uint8_t QOI_OP_LUMA = 0b10000000; // 10xxxxxx
|
||||
constexpr uint8_t QOI_OP_RUN = 0b11000000; // 11xxxxxx
|
||||
|
||||
constexpr uint8_t QOI_RGB_CHUNK_SIZE = 4;
|
||||
constexpr uint8_t QOI_RGBA_CHUNK_SIZE = 5;
|
||||
constexpr uint8_t QOI_LUMA_CHUNK_SIZE = 2;
|
||||
|
||||
constexpr uint8_t QOI_MASK_OP = 0b11000000;
|
||||
constexpr uint8_t QOI_MASK_VALUE = 0b00111111;
|
||||
|
||||
constexpr size_t QOI_HEADER_SIZE = 14;
|
||||
constexpr size_t QOI_COLOR_TABLE_SIZE = 64;
|
||||
|
||||
inline size_t qoi_color_table_index(const Color &color) {
|
||||
// QOI color hash function: (r * 3 + g * 5 + b * 7 + a * 11) % 64
|
||||
return (color.r * 3 + color.g * 5 + color.b * 7 + color.w * 11) &
|
||||
63; // modulo 64 is equivalent to bitwise AND with 63 (0b00111111)
|
||||
}
|
||||
|
||||
void QoiDecoder::reset() {
|
||||
ImageDecoder::reset();
|
||||
this->current_index_ = 0;
|
||||
this->paint_index_ = 0;
|
||||
this->width_ = 0;
|
||||
this->height_ = 0;
|
||||
this->bits_per_pixel_ = 0;
|
||||
this->last_pixel_ = Color(0, 0, 0, 255);
|
||||
if (this->color_table_) {
|
||||
std::fill_n(this->color_table_.get(), QOI_COLOR_TABLE_SIZE, Color());
|
||||
}
|
||||
}
|
||||
|
||||
int HOT QoiDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
size_t index = 0;
|
||||
if (this->current_index_ == 0) {
|
||||
if (size < QOI_HEADER_SIZE) {
|
||||
return 0; // Need more data for file header
|
||||
}
|
||||
|
||||
/** QOI Header definition, for reference:
|
||||
char magic[4]; // magic bytes "qoif"
|
||||
uint32_t width; // image width in pixels (BE)
|
||||
uint32_t height; // image height in pixels (BE)
|
||||
uint8_t channels; // 3 = RGB, 4 = RGBA
|
||||
uint8_t colorspace; // 0 = sRGB with linear alpha, 1 = all channels linear
|
||||
*/
|
||||
// Check if the file is a QOI image
|
||||
if (buffer[0] != 'q' || buffer[1] != 'o' || buffer[2] != 'i' || buffer[3] != 'f') {
|
||||
ESP_LOGE(TAG, "Not a QOI file");
|
||||
return DECODE_ERROR_INVALID_TYPE;
|
||||
}
|
||||
|
||||
this->width_ = encode_uint32(buffer[4], buffer[5], buffer[6], buffer[7]);
|
||||
this->height_ = encode_uint32(buffer[8], buffer[9], buffer[10], buffer[11]);
|
||||
if (this->width_ == 0 || this->height_ == 0) {
|
||||
ESP_LOGE(TAG, "Invalid image dimensions: (%zux%zu)", this->width_, this->height_);
|
||||
return DECODE_ERROR_INVALID_TYPE;
|
||||
}
|
||||
uint8_t channels = buffer[12];
|
||||
if (channels < 3 || channels > 4) {
|
||||
ESP_LOGE(TAG, "Unsupported number of channels: %d", channels);
|
||||
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
||||
}
|
||||
this->bits_per_pixel_ = channels * 8;
|
||||
uint8_t colorspace = buffer[13];
|
||||
if (colorspace > 1) {
|
||||
ESP_LOGE(TAG, "Unsupported colorspace value: %d", colorspace);
|
||||
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
||||
}
|
||||
ESP_LOGD(TAG, "QOI image header: width=%zu, height=%zu, channels=%d, colorspace=%d", this->width_, this->height_,
|
||||
channels, colorspace);
|
||||
|
||||
if (!this->color_table_) {
|
||||
this->color_table_ = std::make_unique<Color[]>(QOI_COLOR_TABLE_SIZE);
|
||||
}
|
||||
|
||||
if (!this->set_size(this->width_, this->height_)) {
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
|
||||
this->current_index_ = QOI_HEADER_SIZE;
|
||||
index = QOI_HEADER_SIZE;
|
||||
} // Current_index == 0
|
||||
|
||||
Color color;
|
||||
const size_t total_pixels = this->width_ * this->height_;
|
||||
while (index < size && this->paint_index_ < total_pixels) {
|
||||
color = this->last_pixel_;
|
||||
uint8_t byte = buffer[index];
|
||||
if (byte == QOI_OP_RGB) {
|
||||
if (size < index + QOI_RGB_CHUNK_SIZE) {
|
||||
return index; // Need more data for RGB chunk
|
||||
}
|
||||
index++;
|
||||
color.r = buffer[index++];
|
||||
color.g = buffer[index++];
|
||||
color.b = buffer[index++];
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
} else if (byte == QOI_OP_RGBA) {
|
||||
if (size < index + QOI_RGBA_CHUNK_SIZE) {
|
||||
return index; // Need more data for RGBA chunk
|
||||
}
|
||||
index++;
|
||||
color.r = buffer[index++];
|
||||
color.g = buffer[index++];
|
||||
color.b = buffer[index++];
|
||||
color.w = buffer[index++];
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
} else if ((byte & QOI_MASK_OP) == QOI_OP_RUN) {
|
||||
// QOI run chunk
|
||||
size_t run_length = (byte & QOI_MASK_VALUE) + 1; // run length is encoded in the lower 6 bits, plus one
|
||||
for (size_t i = 0; i < run_length; i++) {
|
||||
// TODO: optimize by drawing runs of pixels at once instead of one by one
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
}
|
||||
index++;
|
||||
} else if ((byte & QOI_MASK_OP) == QOI_OP_LUMA) {
|
||||
if (size < index + QOI_LUMA_CHUNK_SIZE) {
|
||||
return index; // Need more data for LUMA chunk
|
||||
}
|
||||
index++;
|
||||
uint8_t byte2 = buffer[index++];
|
||||
uint8_t delta_g = (byte & QOI_MASK_VALUE) - 32;
|
||||
color.r += delta_g - 8 + ((byte2 >> 4) & 0x0f);
|
||||
color.g += delta_g;
|
||||
color.b += delta_g - 8 + (byte2 & 0x0f);
|
||||
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
} else if ((byte & QOI_MASK_OP) == QOI_OP_DIFF) {
|
||||
color.r += ((byte >> 4) & 0x03) - 2;
|
||||
color.g += ((byte >> 2) & 0x03) - 2;
|
||||
color.b += (byte & 0x03) - 2;
|
||||
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
index++;
|
||||
} else if ((byte & QOI_MASK_OP) == QOI_OP_INDEX) {
|
||||
color = this->color_table_[byte];
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
index++;
|
||||
}
|
||||
this->last_pixel_ = color;
|
||||
this->color_table_[qoi_color_table_index(color)] = color;
|
||||
}
|
||||
this->decoded_bytes_ += size;
|
||||
return size;
|
||||
}
|
||||
|
||||
} // namespace esphome::runtime_image
|
||||
|
||||
#endif // USE_RUNTIME_IMAGE_QOI
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_RUNTIME_IMAGE_QOI
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "image_decoder.h"
|
||||
#include "runtime_image.h"
|
||||
|
||||
namespace esphome::runtime_image {
|
||||
|
||||
/**
|
||||
* @brief Image decoder specialization for QOI images.
|
||||
*/
|
||||
class QoiDecoder : public ImageDecoder {
|
||||
public:
|
||||
/**
|
||||
* @brief Construct a new QOI decoder object.
|
||||
*
|
||||
* @param image The RuntimeImage to decode the stream into.
|
||||
*/
|
||||
QoiDecoder(RuntimeImage *image) : ImageDecoder(image, QOI) {}
|
||||
|
||||
void reset() override;
|
||||
int HOT decode(uint8_t *buffer, size_t size) override;
|
||||
|
||||
bool is_finished() const override {
|
||||
if (this->bits_per_pixel_ == 0) {
|
||||
// header not yet received, so dimensions not yet determined
|
||||
return false;
|
||||
}
|
||||
// QOI is finished when we've decoded all pixel data
|
||||
return this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
|
||||
}
|
||||
|
||||
protected:
|
||||
std::unique_ptr<Color[]> color_table_;
|
||||
size_t current_index_{0};
|
||||
size_t paint_index_{0};
|
||||
size_t width_{0};
|
||||
size_t height_{0};
|
||||
Color last_pixel_{0, 0, 0, 255}; // QOI spec defines initial previous pixel as opaque black
|
||||
uint16_t bits_per_pixel_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::runtime_image
|
||||
|
||||
#endif // USE_RUNTIME_IMAGE_QOI
|
||||
@@ -15,6 +15,9 @@
|
||||
#ifdef USE_RUNTIME_IMAGE_PNG
|
||||
#include "png_decoder.h"
|
||||
#endif
|
||||
#ifdef USE_RUNTIME_IMAGE_QOI
|
||||
#include "qoi_decoder.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::runtime_image {
|
||||
|
||||
@@ -367,6 +370,10 @@ std::unique_ptr<ImageDecoder> RuntimeImage::create_decoder_(ImageFormat format)
|
||||
#ifdef USE_RUNTIME_IMAGE_PNG
|
||||
case PNG:
|
||||
return make_unique<PngDecoder>(this);
|
||||
#endif
|
||||
#ifdef USE_RUNTIME_IMAGE_QOI
|
||||
case QOI:
|
||||
return make_unique<QoiDecoder>(this);
|
||||
#endif
|
||||
case AUTO:
|
||||
ESP_LOGE(TAG, "Image format could not be determined; set `format:` explicitly in the configuration");
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import water_heater
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SUPPORTED_MODES, CONF_SWITCH_DATAPOINT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_TUYA_ID, Tuya, tuya_ns
|
||||
|
||||
DEPENDENCIES = ["tuya"]
|
||||
CODEOWNERS = ["@iago-veiga"]
|
||||
|
||||
CONF_TARGET_TEMPERATURE_DATAPOINT = "target_temperature_datapoint"
|
||||
CONF_CURRENT_TEMPERATURE_DATAPOINT = "current_temperature_datapoint"
|
||||
CONF_TARGET_TEMPERATURE_MULTIPLIER = "target_temperature_multiplier"
|
||||
CONF_CURRENT_TEMPERATURE_MULTIPLIER = "current_temperature_multiplier"
|
||||
CONF_MODE_DATAPOINT = "mode_datapoint"
|
||||
|
||||
# Optional enum values that map a Tuya mode datapoint value to a WaterHeaterMode.
|
||||
# Mirrors the "*_value" style used by the tuya climate fan modes.
|
||||
CONF_ECO_VALUE = "eco_value"
|
||||
CONF_ELECTRIC_VALUE = "electric_value"
|
||||
CONF_PERFORMANCE_VALUE = "performance_value"
|
||||
CONF_HIGH_DEMAND_VALUE = "high_demand_value"
|
||||
CONF_HEAT_PUMP_VALUE = "heat_pump_value"
|
||||
CONF_GAS_VALUE = "gas_value"
|
||||
|
||||
# Map of config key -> C++ setter name, one per non-OFF WaterHeaterMode. OFF is not an enum
|
||||
# value: it is represented by the switch datapoint being off, just like the tuya climate.
|
||||
MODE_VALUES = {
|
||||
CONF_ECO_VALUE: "set_eco_value",
|
||||
CONF_ELECTRIC_VALUE: "set_electric_value",
|
||||
CONF_PERFORMANCE_VALUE: "set_performance_value",
|
||||
CONF_HIGH_DEMAND_VALUE: "set_high_demand_value",
|
||||
CONF_HEAT_PUMP_VALUE: "set_heat_pump_value",
|
||||
CONF_GAS_VALUE: "set_gas_value",
|
||||
}
|
||||
|
||||
TuyaWaterHeater = tuya_ns.class_(
|
||||
"TuyaWaterHeater", water_heater.WaterHeater, cg.Component
|
||||
)
|
||||
|
||||
|
||||
def _validate(config: ConfigType) -> ConfigType:
|
||||
# A mode datapoint is only useful if at least one mode value is mapped, and mode values
|
||||
# only make sense together with a mode datapoint.
|
||||
has_mode_values = any(key in config for key in MODE_VALUES)
|
||||
if CONF_MODE_DATAPOINT in config and not has_mode_values:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_MODE_DATAPOINT}' requires at least one mode value "
|
||||
f"(e.g. '{CONF_ECO_VALUE}' or '{CONF_ELECTRIC_VALUE}')"
|
||||
)
|
||||
if has_mode_values and CONF_MODE_DATAPOINT not in config:
|
||||
raise cv.Invalid(f"Mode values require '{CONF_MODE_DATAPOINT}' to be set")
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
water_heater.water_heater_schema(TuyaWaterHeater)
|
||||
.extend(
|
||||
{
|
||||
cv.GenerateID(CONF_TUYA_ID): cv.use_id(Tuya),
|
||||
cv.Required(CONF_SWITCH_DATAPOINT): cv.uint8_t,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_DATAPOINT): cv.uint8_t,
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE_DATAPOINT): cv.uint8_t,
|
||||
cv.Optional(
|
||||
CONF_TARGET_TEMPERATURE_MULTIPLIER, default=1.0
|
||||
): cv.positive_float,
|
||||
cv.Optional(
|
||||
CONF_CURRENT_TEMPERATURE_MULTIPLIER, default=1.0
|
||||
): cv.positive_float,
|
||||
cv.Optional(CONF_MODE_DATAPOINT): cv.uint8_t,
|
||||
cv.Optional(CONF_ECO_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_ELECTRIC_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_PERFORMANCE_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_HIGH_DEMAND_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_HEAT_PUMP_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_GAS_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(
|
||||
water_heater.validate_water_heater_mode
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
_validate,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await water_heater.new_water_heater(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
paren = await cg.get_variable(config[CONF_TUYA_ID])
|
||||
cg.add(var.set_tuya_parent(paren))
|
||||
|
||||
cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT]))
|
||||
|
||||
if (target_temp_dp := config.get(CONF_TARGET_TEMPERATURE_DATAPOINT)) is not None:
|
||||
cg.add(var.set_target_temperature_id(target_temp_dp))
|
||||
if (current_temp_dp := config.get(CONF_CURRENT_TEMPERATURE_DATAPOINT)) is not None:
|
||||
cg.add(var.set_current_temperature_id(current_temp_dp))
|
||||
|
||||
cg.add(
|
||||
var.set_target_temperature_multiplier(
|
||||
config[CONF_TARGET_TEMPERATURE_MULTIPLIER]
|
||||
)
|
||||
)
|
||||
cg.add(
|
||||
var.set_current_temperature_multiplier(
|
||||
config[CONF_CURRENT_TEMPERATURE_MULTIPLIER]
|
||||
)
|
||||
)
|
||||
|
||||
if (mode_dp := config.get(CONF_MODE_DATAPOINT)) is not None:
|
||||
cg.add(var.set_mode_id(mode_dp))
|
||||
for key, setter in MODE_VALUES.items():
|
||||
if (value := config.get(key)) is not None:
|
||||
cg.add(getattr(var, setter)(value))
|
||||
|
||||
if (supported_modes := config.get(CONF_SUPPORTED_MODES)) is not None:
|
||||
cg.add(var.set_supported_modes(supported_modes))
|
||||
@@ -0,0 +1,190 @@
|
||||
#include "tuya_water_heater.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::tuya {
|
||||
|
||||
static const char *const TAG = "tuya.water_heater";
|
||||
|
||||
void TuyaWaterHeater::setup() {
|
||||
if (this->switch_id_.has_value()) {
|
||||
this->parent_->register_listener(*this->switch_id_, [this](const TuyaDatapoint &datapoint) {
|
||||
ESP_LOGV(TAG, "MCU reported switch is: %s", ONOFF(datapoint.value_bool));
|
||||
this->is_on_ = datapoint.value_bool;
|
||||
this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, this->is_on_);
|
||||
if (!this->is_on_) {
|
||||
this->set_mode_(water_heater::WATER_HEATER_MODE_OFF);
|
||||
} else {
|
||||
// Turned on: use the last mode reported by the mode datapoint if we have one, otherwise
|
||||
// fall back to a supported mode. Datapoints can arrive in any order, so the mode enum may
|
||||
// have been reported before this switch update.
|
||||
this->set_mode_(this->last_reported_mode_.value_or(this->default_on_mode_()));
|
||||
}
|
||||
this->publish_state();
|
||||
});
|
||||
}
|
||||
|
||||
if (this->mode_id_.has_value()) {
|
||||
this->parent_->register_listener(*this->mode_id_, [this](const TuyaDatapoint &datapoint) {
|
||||
ESP_LOGV(TAG, "MCU reported mode value is: %u", datapoint.value_enum);
|
||||
water_heater::WaterHeaterMode mode;
|
||||
if (!this->mode_from_value_(datapoint.value_enum, mode)) {
|
||||
return;
|
||||
}
|
||||
// Always remember the reported mode; only surface it while the heater is on (OFF is driven
|
||||
// by the switch datapoint, not the mode enum).
|
||||
this->last_reported_mode_ = mode;
|
||||
if (this->is_on_ && this->mode_ != mode) {
|
||||
this->set_mode_(mode);
|
||||
this->publish_state();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this->target_temperature_id_.has_value()) {
|
||||
this->parent_->register_listener(*this->target_temperature_id_, [this](const TuyaDatapoint &datapoint) {
|
||||
float value = datapoint.value_int * this->target_temperature_multiplier_;
|
||||
ESP_LOGV(TAG, "MCU reported target temperature is: %.1f", value);
|
||||
this->set_target_temperature_(value);
|
||||
this->publish_state();
|
||||
});
|
||||
}
|
||||
|
||||
if (this->current_temperature_id_.has_value()) {
|
||||
this->parent_->register_listener(*this->current_temperature_id_, [this](const TuyaDatapoint &datapoint) {
|
||||
float value = datapoint.value_int * this->current_temperature_multiplier_;
|
||||
ESP_LOGV(TAG, "MCU reported current temperature is: %.1f", value);
|
||||
this->set_current_temperature(value);
|
||||
this->publish_state();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
water_heater::WaterHeaterCallInternal TuyaWaterHeater::make_call() {
|
||||
return water_heater::WaterHeaterCallInternal(this);
|
||||
}
|
||||
|
||||
void TuyaWaterHeater::control(const water_heater::WaterHeaterCall &call) {
|
||||
auto mode_val = call.get_mode();
|
||||
auto on_val = call.get_on();
|
||||
|
||||
// Determine the desired on/off state. An explicit on/off request wins; otherwise a mode of
|
||||
// OFF means off and any other mode means on.
|
||||
optional<bool> want_on = on_val;
|
||||
if (mode_val.has_value() && !want_on.has_value()) {
|
||||
want_on = *mode_val != water_heater::WATER_HEATER_MODE_OFF;
|
||||
}
|
||||
|
||||
if (want_on.has_value() && this->switch_id_.has_value()) {
|
||||
ESP_LOGV(TAG, "Setting switch: %s", ONOFF(*want_on));
|
||||
this->parent_->set_boolean_datapoint_value(*this->switch_id_, *want_on);
|
||||
}
|
||||
|
||||
if (mode_val.has_value() && *mode_val != water_heater::WATER_HEATER_MODE_OFF && this->mode_id_.has_value()) {
|
||||
uint8_t value;
|
||||
if (this->value_from_mode_(*mode_val, value)) {
|
||||
ESP_LOGV(TAG, "Setting mode value: %u", value);
|
||||
this->parent_->set_enum_datapoint_value(*this->mode_id_, value);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No mode value configured for requested mode");
|
||||
}
|
||||
}
|
||||
|
||||
auto target_temp = call.get_target_temperature();
|
||||
if (!std::isnan(target_temp) && this->target_temperature_id_.has_value()) {
|
||||
ESP_LOGV(TAG, "Setting target temperature: %.1f", target_temp);
|
||||
this->parent_->set_integer_datapoint_value(*this->target_temperature_id_,
|
||||
(int) (target_temp / this->target_temperature_multiplier_));
|
||||
}
|
||||
}
|
||||
|
||||
water_heater::WaterHeaterTraits TuyaWaterHeater::traits() {
|
||||
water_heater::WaterHeaterTraits traits;
|
||||
|
||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_ON_OFF);
|
||||
if (this->current_temperature_id_.has_value()) {
|
||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
}
|
||||
if (this->target_temperature_id_.has_value()) {
|
||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
|
||||
}
|
||||
if (!this->supported_modes_.empty()) {
|
||||
traits.set_supported_modes(this->supported_modes_);
|
||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_OPERATION_MODE);
|
||||
}
|
||||
return traits;
|
||||
}
|
||||
|
||||
bool TuyaWaterHeater::mode_from_value_(uint8_t value, water_heater::WaterHeaterMode &mode) const {
|
||||
if (this->eco_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_ECO;
|
||||
} else if (this->electric_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_ELECTRIC;
|
||||
} else if (this->performance_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_PERFORMANCE;
|
||||
} else if (this->high_demand_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_HIGH_DEMAND;
|
||||
} else if (this->heat_pump_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_HEAT_PUMP;
|
||||
} else if (this->gas_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_GAS;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TuyaWaterHeater::value_from_mode_(water_heater::WaterHeaterMode mode, uint8_t &value) const {
|
||||
optional<uint8_t> mapped;
|
||||
switch (mode) {
|
||||
case water_heater::WATER_HEATER_MODE_ECO:
|
||||
mapped = this->eco_value_;
|
||||
break;
|
||||
case water_heater::WATER_HEATER_MODE_ELECTRIC:
|
||||
mapped = this->electric_value_;
|
||||
break;
|
||||
case water_heater::WATER_HEATER_MODE_PERFORMANCE:
|
||||
mapped = this->performance_value_;
|
||||
break;
|
||||
case water_heater::WATER_HEATER_MODE_HIGH_DEMAND:
|
||||
mapped = this->high_demand_value_;
|
||||
break;
|
||||
case water_heater::WATER_HEATER_MODE_HEAT_PUMP:
|
||||
mapped = this->heat_pump_value_;
|
||||
break;
|
||||
case water_heater::WATER_HEATER_MODE_GAS:
|
||||
mapped = this->gas_value_;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (mapped.has_value()) {
|
||||
value = *mapped;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
water_heater::WaterHeaterMode TuyaWaterHeater::default_on_mode_() const {
|
||||
// Prefer the first configured supported non-OFF mode so we never surface a mode the user
|
||||
// cannot control. Fall back to ELECTRIC when no supported modes are configured.
|
||||
for (water_heater::WaterHeaterMode mode : this->supported_modes_) {
|
||||
if (mode != water_heater::WATER_HEATER_MODE_OFF) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
return water_heater::WATER_HEATER_MODE_ELECTRIC;
|
||||
}
|
||||
|
||||
void TuyaWaterHeater::dump_config() {
|
||||
LOG_WATER_HEATER("", "Tuya Water Heater", this);
|
||||
if (this->switch_id_.has_value())
|
||||
ESP_LOGCONFIG(TAG, " Switch has datapoint ID %u", *this->switch_id_);
|
||||
if (this->mode_id_.has_value())
|
||||
ESP_LOGCONFIG(TAG, " Mode has datapoint ID %u", *this->mode_id_);
|
||||
if (this->target_temperature_id_.has_value())
|
||||
ESP_LOGCONFIG(TAG, " Target Temperature has datapoint ID %u", *this->target_temperature_id_);
|
||||
if (this->current_temperature_id_.has_value())
|
||||
ESP_LOGCONFIG(TAG, " Current Temperature has datapoint ID %u", *this->current_temperature_id_);
|
||||
}
|
||||
|
||||
} // namespace esphome::tuya
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/tuya/tuya.h"
|
||||
#include "esphome/components/water_heater/water_heater.h"
|
||||
|
||||
namespace esphome::tuya {
|
||||
|
||||
class TuyaWaterHeater final : public water_heater::WaterHeater, public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_tuya_parent(Tuya *parent) { this->parent_ = parent; }
|
||||
|
||||
void set_switch_id(uint8_t switch_id) { this->switch_id_ = switch_id; }
|
||||
void set_target_temperature_id(uint8_t target_temperature_id) {
|
||||
this->target_temperature_id_ = target_temperature_id;
|
||||
}
|
||||
void set_current_temperature_id(uint8_t current_temperature_id) {
|
||||
this->current_temperature_id_ = current_temperature_id;
|
||||
}
|
||||
void set_target_temperature_multiplier(float multiplier) { this->target_temperature_multiplier_ = multiplier; }
|
||||
void set_current_temperature_multiplier(float multiplier) { this->current_temperature_multiplier_ = multiplier; }
|
||||
|
||||
void set_mode_id(uint8_t mode_id) { this->mode_id_ = mode_id; }
|
||||
void set_eco_value(uint8_t value) { this->eco_value_ = value; }
|
||||
void set_electric_value(uint8_t value) { this->electric_value_ = value; }
|
||||
void set_performance_value(uint8_t value) { this->performance_value_ = value; }
|
||||
void set_high_demand_value(uint8_t value) { this->high_demand_value_ = value; }
|
||||
void set_heat_pump_value(uint8_t value) { this->heat_pump_value_ = value; }
|
||||
void set_gas_value(uint8_t value) { this->gas_value_ = value; }
|
||||
|
||||
void set_supported_modes(const std::initializer_list<water_heater::WaterHeaterMode> &modes) {
|
||||
this->supported_modes_ = modes;
|
||||
}
|
||||
|
||||
water_heater::WaterHeaterCallInternal make_call() override;
|
||||
|
||||
protected:
|
||||
void control(const water_heater::WaterHeaterCall &call) override;
|
||||
water_heater::WaterHeaterTraits traits() override;
|
||||
|
||||
/// Map a Tuya mode datapoint enum value to a WaterHeaterMode. Returns true when a mapping
|
||||
/// exists, writing the result to \p mode.
|
||||
bool mode_from_value_(uint8_t value, water_heater::WaterHeaterMode &mode) const;
|
||||
/// Map a WaterHeaterMode to its configured Tuya enum value. Returns true when a mapping exists.
|
||||
bool value_from_mode_(water_heater::WaterHeaterMode mode, uint8_t &value) const;
|
||||
|
||||
Tuya *parent_{nullptr};
|
||||
optional<uint8_t> switch_id_{};
|
||||
optional<uint8_t> target_temperature_id_{};
|
||||
optional<uint8_t> current_temperature_id_{};
|
||||
optional<uint8_t> mode_id_{};
|
||||
optional<uint8_t> eco_value_{};
|
||||
optional<uint8_t> electric_value_{};
|
||||
optional<uint8_t> performance_value_{};
|
||||
optional<uint8_t> high_demand_value_{};
|
||||
optional<uint8_t> heat_pump_value_{};
|
||||
optional<uint8_t> gas_value_{};
|
||||
float target_temperature_multiplier_{1.0f};
|
||||
float current_temperature_multiplier_{1.0f};
|
||||
water_heater::WaterHeaterModeMask supported_modes_;
|
||||
/// Last non-OFF mode reported by the mode datapoint, applied when the heater turns on.
|
||||
optional<water_heater::WaterHeaterMode> last_reported_mode_{};
|
||||
bool is_on_{false};
|
||||
|
||||
/// The mode to show when the heater is on but no mode datapoint value is known yet: the last
|
||||
/// reported mode, else the first configured supported non-OFF mode, else ELECTRIC.
|
||||
water_heater::WaterHeaterMode default_on_mode_() const;
|
||||
};
|
||||
|
||||
} // namespace esphome::tuya
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -445,10 +445,15 @@ def _report_provisioning_credentials(config):
|
||||
about this, since a device that uses a provisioning window should get its
|
||||
credentials on first connection instead.
|
||||
"""
|
||||
if config.get(CONF_NETWORKS):
|
||||
from esphome.components import provisioning
|
||||
from esphome.components import provisioning
|
||||
|
||||
if config.get(CONF_NETWORKS):
|
||||
provisioning.report_hardcoded_credentials("wifi")
|
||||
elif CONF_AP in config:
|
||||
# An access point with no station credentials: the AP shuts down when the
|
||||
# provisioning window closes, so `provisioning:` warns that the device may
|
||||
# become unreachable until power-cycled.
|
||||
provisioning.report_ap_without_sta()
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -633,6 +633,21 @@ void WiFiComponent::setup() {
|
||||
this->configured_power_save_ = this->power_save_;
|
||||
#endif
|
||||
|
||||
#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP)
|
||||
// The access point is a provisioning surface: once the provisioning window has
|
||||
// closed, shut it down (mirrors the teardown done on a successful connection).
|
||||
// The captive portal registers its own closed-callback, and the fallback block
|
||||
// in loop() is gated so neither is started again afterwards.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
if (this->ap_setup_) {
|
||||
ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
|
||||
this->wifi_mode_({}, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
#endif
|
||||
|
||||
if (this->enable_on_boot_) {
|
||||
#ifdef USE_ESP32
|
||||
this->wifi_lazy_init_();
|
||||
@@ -854,7 +869,15 @@ void WiFiComponent::loop() {
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP
|
||||
if (this->has_ap() && !this->ap_setup_) {
|
||||
bool provisioning_closed = false;
|
||||
#ifdef USE_PROVISIONING
|
||||
// Once the provisioning window has closed, don't bring up the fallback AP (or
|
||||
// the captive portal on it) - the device must stay unprovisionable until it is
|
||||
// power-cycled.
|
||||
provisioning_closed =
|
||||
provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed();
|
||||
#endif
|
||||
if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) {
|
||||
if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_)) {
|
||||
ESP_LOGI(TAG, "Starting fallback AP");
|
||||
this->setup_ap_config_();
|
||||
|
||||
@@ -1462,7 +1462,7 @@ def hostname(value):
|
||||
Maximum length is 63 characters per RFC 1035.
|
||||
|
||||
Note: If this limit is changed, update MAX_NAME_WITH_SUFFIX_SIZE in
|
||||
esphome/core/helpers.cpp to accommodate the new maximum length.
|
||||
esphome/core/helpers.h to accommodate the new maximum length.
|
||||
"""
|
||||
value = string(value)
|
||||
if re.match(r"^[a-z0-9-]{1,63}$", value, re.IGNORECASE) is not None:
|
||||
|
||||
+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()
|
||||
|
||||
@@ -81,8 +81,6 @@
|
||||
#define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT
|
||||
#define USE_I2S_AUDIO_SPDIF_MODE
|
||||
#define USE_IMAGE
|
||||
#define USE_IMPROV_SERIAL
|
||||
#define USE_IMPROV_SERIAL_NEXT_URL
|
||||
#define USE_INFRARED
|
||||
#define USE_IR_RF
|
||||
#define USE_JSON
|
||||
@@ -223,6 +221,9 @@
|
||||
#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
#define API_MAX_SEND_QUEUE 8
|
||||
#define MAX_API_CONNECTIONS 6
|
||||
// The Improv library is not in the Zephyr tidy environment
|
||||
#define USE_IMPROV_SERIAL
|
||||
#define USE_IMPROV_SERIAL_NEXT_URL
|
||||
#define USE_MD5
|
||||
#define USE_NOISE
|
||||
#define USE_SHA256
|
||||
@@ -233,8 +234,9 @@
|
||||
#endif
|
||||
#define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
|
||||
#define USE_RUNTIME_IMAGE_BMP
|
||||
#define USE_RUNTIME_IMAGE_PNG
|
||||
#define USE_RUNTIME_IMAGE_JPEG
|
||||
#define USE_RUNTIME_IMAGE_PNG
|
||||
#define USE_RUNTIME_IMAGE_QOI
|
||||
#define USE_RUNTIME_STATS
|
||||
#define USE_OTA
|
||||
#define USE_OTA_PASSWORD
|
||||
@@ -537,6 +539,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
|
||||
|
||||
@@ -268,9 +268,6 @@ char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) {
|
||||
|
||||
// str_sanitize, str_snprintf, str_sprintf moved to alloc_helpers.cpp
|
||||
|
||||
// Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term)
|
||||
static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128;
|
||||
|
||||
size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep,
|
||||
const char *suffix_ptr, size_t suffix_len) {
|
||||
size_t total_len = name_len + 1 + suffix_len;
|
||||
@@ -291,17 +288,6 @@ size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *na
|
||||
return total_len;
|
||||
}
|
||||
|
||||
std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr,
|
||||
size_t suffix_len) {
|
||||
char buffer[MAX_NAME_WITH_SUFFIX_SIZE];
|
||||
size_t len = make_name_with_suffix_to(buffer, sizeof(buffer), name, name_len, sep, suffix_ptr, suffix_len);
|
||||
return std::string(buffer, len);
|
||||
}
|
||||
|
||||
std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len) {
|
||||
return make_name_with_suffix(name.c_str(), name.size(), sep, suffix_ptr, suffix_len);
|
||||
}
|
||||
|
||||
// Parsing & formatting
|
||||
|
||||
size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) {
|
||||
|
||||
+3
-21
@@ -1153,28 +1153,10 @@ inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Concatenate a name with a separator and suffix using an efficient stack-based approach.
|
||||
/// This avoids multiple heap allocations during string construction.
|
||||
/// Maximum name length supported is 120 characters for friendly names.
|
||||
/// @param name The base name string
|
||||
/// @param sep The separator character (e.g., '-', ' ', or '.')
|
||||
/// @param suffix_ptr Pointer to the suffix characters
|
||||
/// @param suffix_len Length of the suffix
|
||||
/// @return The concatenated string: name + sep + suffix
|
||||
std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len);
|
||||
/// Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term)
|
||||
static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128;
|
||||
|
||||
/// Optimized string concatenation: name + separator + suffix (const char* overload)
|
||||
/// Uses a fixed stack buffer to avoid heap allocations.
|
||||
/// @param name The base name string
|
||||
/// @param name_len Length of the name
|
||||
/// @param sep Single character separator
|
||||
/// @param suffix_ptr Pointer to the suffix characters
|
||||
/// @param suffix_len Length of the suffix
|
||||
/// @return The concatenated string: name + sep + suffix
|
||||
std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr,
|
||||
size_t suffix_len);
|
||||
|
||||
/// Zero-allocation version: format name + separator + suffix directly into buffer.
|
||||
/// Format name + separator + suffix directly into buffer without heap allocation.
|
||||
/// @param buffer Output buffer (must have space for result + null terminator)
|
||||
/// @param buffer_size Size of the output buffer
|
||||
/// @param name The base name string
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,16 +9,19 @@ byte-identical to PlatformIO's output:
|
||||
Flash: [=== ] 48.4% (used 888511 bytes from 1835008 bytes)
|
||||
|
||||
The format matches ``script/ci_memory_impact_extract.py`` so CI memory
|
||||
analysis works unchanged on native ESP-IDF builds. RAM total is the
|
||||
DRAM region size from the linker map; Flash total is taken from
|
||||
analysis works unchanged on native ESP-IDF builds. RAM usage comes from
|
||||
the DRAM (or unified DIRAM) region of the linker map. Flash used is the
|
||||
exact image size matching the ``Total image size`` line: json2
|
||||
``total_size`` when present, otherwise derived from the ELF (see
|
||||
``_image_size_from_elf``). Flash total is taken from
|
||||
``partitions.csv`` using PlatformIO's rule (first app partition whose
|
||||
subtype is ``factory`` or ``ota_0``; see
|
||||
``platform-espressif32/builder/main.py::_update_max_upload_size``).
|
||||
|
||||
Structured size data is produced at link time by a CMake POST_BUILD
|
||||
custom command (see ``build_gen/espidf.py``) which writes
|
||||
``esp_idf_size.json`` next to the ELF. We read that file here rather
|
||||
than re-running ``esp_idf_size`` from Python.
|
||||
``esp_idf_size.json`` (``--format=json2``, a per-memory-type summary)
|
||||
next to the ELF; we read that rather than re-running ``esp_idf_size``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +30,7 @@ import csv
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
from esphome.build_helpers.size_summary import print_size_line
|
||||
|
||||
@@ -69,11 +73,43 @@ def _find_app_partition_size(partitions_csv: Path) -> int:
|
||||
raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}")
|
||||
|
||||
|
||||
def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
|
||||
def _image_size_from_elf(elf: Path) -> int:
|
||||
"""Sum the allocated PROGBITS section sizes from an ELF32 file.
|
||||
|
||||
Matches ``esp_idf_size.ng.memorymap._get_image_size`` byte for byte;
|
||||
esptool's ``ELFFile`` filters sections differently and would not.
|
||||
Raises ``ValueError`` for anything but a well-formed ELF32 LE file.
|
||||
"""
|
||||
with elf.open("rb") as f:
|
||||
header = f.read(52) # ELF32 header
|
||||
if len(header) < 52 or header[:6] != b"\x7fELF\x01\x01":
|
||||
raise ValueError(f"{elf} is not a 32-bit little-endian ELF")
|
||||
(e_shoff,) = struct.unpack_from("<I", header, 0x20) # e_shoff
|
||||
e_shentsize, e_shnum = struct.unpack_from("<HH", header, 0x2E)
|
||||
if e_shentsize < 40: # sizeof(Elf32_Shdr)
|
||||
raise ValueError(f"{elf} has an invalid section header size")
|
||||
f.seek(e_shoff)
|
||||
table = f.read(e_shnum * e_shentsize)
|
||||
if len(table) < e_shnum * e_shentsize:
|
||||
raise ValueError(f"{elf} has a truncated section header table")
|
||||
total = 0
|
||||
for off in range(0, e_shnum * e_shentsize, e_shentsize):
|
||||
sh_type, sh_flags = struct.unpack_from("<II", table, off + 4)
|
||||
(sh_size,) = struct.unpack_from("<I", table, off + 20)
|
||||
if sh_type == 1 and sh_flags & 0x2: # SHT_PROGBITS with SHF_ALLOC
|
||||
total += sh_size
|
||||
if total == 0:
|
||||
# A used-0-bytes Flash line would read as a real measurement
|
||||
raise ValueError(f"{elf} has no allocated PROGBITS sections")
|
||||
return total
|
||||
|
||||
|
||||
def print_summary(size_json: Path, partitions_csv: Path, firmware_elf: Path) -> None:
|
||||
"""Print PlatformIO-shaped RAM and Flash one-liners.
|
||||
|
||||
Failures are non-fatal: the build has already succeeded, we just couldn't
|
||||
summarize. Logs the cause at debug level.
|
||||
summarize. Anomalies (missing region, unreadable ELF) warn; expected
|
||||
optional inputs (no size json, no partitions.csv) log at debug.
|
||||
"""
|
||||
if not size_json.is_file():
|
||||
_LOGGER.debug("Skipping size summary: %s not found", size_json)
|
||||
@@ -83,20 +119,49 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
_LOGGER.debug("Skipping size summary: %s", e)
|
||||
return
|
||||
|
||||
memory_types = data.get("memory_types", {})
|
||||
ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {}
|
||||
ram_used = ram_region.get("used")
|
||||
ram_total = ram_region.get("size")
|
||||
if ram_total and ram_used is not None:
|
||||
print_size_line("RAM", ram_used, ram_total)
|
||||
|
||||
image_size = data.get("image_size")
|
||||
if image_size is None or partitions_csv is None:
|
||||
if not isinstance(data, dict):
|
||||
_LOGGER.warning("Skipping size summary: unexpected json shape in %s", size_json)
|
||||
return
|
||||
|
||||
layout = data.get("layout")
|
||||
regions = {
|
||||
entry.get("name"): entry
|
||||
for entry in (layout if isinstance(layout, list) else [])
|
||||
if isinstance(entry, dict)
|
||||
}
|
||||
# Every chip has a DRAM or DIRAM region, so a warning here usually
|
||||
# means the esp_idf_size json schema changed
|
||||
ram_region = regions.get("DRAM") or regions.get("DIRAM")
|
||||
if ram_region is None:
|
||||
_LOGGER.warning("Skipping RAM summary: no DRAM/DIRAM region in %s", size_json)
|
||||
elif (
|
||||
isinstance(ram_total := ram_region.get("total"), int)
|
||||
and ram_total > 0
|
||||
and isinstance(ram_used := ram_region.get("used"), int)
|
||||
):
|
||||
print_size_line("RAM", ram_used, ram_total)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Skipping RAM summary: unusable region %s in %s", ram_region, size_json
|
||||
)
|
||||
|
||||
# esp-idf-size >= 2.1 (IDF >= 6.0) reports the exact image size in
|
||||
# json2; older 1.x omits it, so derive the same figure from the ELF.
|
||||
flash_used = data.get("total_size")
|
||||
if not (isinstance(flash_used, int) and flash_used > 0):
|
||||
_LOGGER.debug("No total_size in %s, deriving from %s", size_json, firmware_elf)
|
||||
try:
|
||||
flash_used = _image_size_from_elf(firmware_elf)
|
||||
except (OSError, ValueError) as e:
|
||||
# The ELF must be present and well formed after a successful build
|
||||
_LOGGER.warning("Skipping Flash summary: %s", e)
|
||||
return
|
||||
try:
|
||||
app_size = _find_app_partition_size(partitions_csv)
|
||||
except ValueError as e:
|
||||
except (OSError, ValueError) as e:
|
||||
_LOGGER.debug("Skipping Flash summary: %s", e)
|
||||
return
|
||||
print_size_line("Flash", image_size, app_size)
|
||||
if app_size <= 0:
|
||||
_LOGGER.debug("Skipping Flash summary: app partition size is 0")
|
||||
return
|
||||
print_size_line("Flash", flash_used, app_size)
|
||||
|
||||
@@ -542,7 +542,7 @@ def run_compile(config, verbose: bool) -> int:
|
||||
if rc == 0:
|
||||
size_json = CORE.relative_build_path("build", "esp_idf_size.json")
|
||||
partitions = CORE.relative_build_path("partitions.csv")
|
||||
print_summary(size_json, partitions if partitions.is_file() else None)
|
||||
print_summary(size_json, partitions, get_built_elf_path())
|
||||
return rc
|
||||
|
||||
|
||||
@@ -579,6 +579,16 @@ def get_ota_firmware_path() -> Path:
|
||||
return build_dir / "firmware.ota.bin"
|
||||
|
||||
|
||||
def get_built_elf_path() -> Path:
|
||||
"""Path to the ELF idf.py writes directly, ``<build>/<name>.elf``.
|
||||
|
||||
Exists as soon as the build finishes, unlike the ``firmware.elf``
|
||||
copy that ``create_elf_copy`` makes later.
|
||||
"""
|
||||
build_dir = CORE.relative_build_path("build")
|
||||
return build_dir / f"{CORE.name}.elf"
|
||||
|
||||
|
||||
def get_elf_path() -> Path:
|
||||
"""Get the path to the firmware ELF file.
|
||||
|
||||
@@ -706,8 +716,7 @@ def create_elf_copy() -> bool:
|
||||
"download ELF" link requests the literal filename ``firmware.elf``
|
||||
(PlatformIO convention), so copy it to that name.
|
||||
"""
|
||||
build_dir = CORE.relative_build_path("build")
|
||||
src_elf = build_dir / f"{CORE.name}.elf"
|
||||
src_elf = get_built_elf_path()
|
||||
dst_elf = get_elf_path()
|
||||
|
||||
if not src_elf.is_file():
|
||||
|
||||
@@ -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__)
|
||||
@@ -977,14 +978,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]}"]
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ bleak==3.0.2
|
||||
smpclient==7.2.0
|
||||
requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.11.3 # native esp-idf toolchain global cache dir
|
||||
platformdirs==4.11.4 # native esp-idf toolchain global cache dir
|
||||
ninja==1.13.0 # native esp8266 arduino toolchain build driver
|
||||
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# Prepare the dev environment for a new checkout or worktree.
|
||||
#
|
||||
# Installed into the git hooks directory by script/setup. Deliberately tiny and
|
||||
# self-contained: it stays valid on branches where script/setup does not exist,
|
||||
# and simply does nothing there.
|
||||
|
||||
# $3 is 1 for a branch checkout, 0 for a file checkout.
|
||||
[ "$3" = "1" ] || exit 0
|
||||
|
||||
top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
|
||||
# This also runs on ordinary branch switches, where there is nothing to do.
|
||||
[ -x "$top/venv/bin/python" ] && exit 0
|
||||
[ -x "$top/script/setup" ] || exit 0
|
||||
|
||||
# Clear VIRTUAL_ENV so a checkout made from a shell with an environment already
|
||||
# activated still gets its own, rather than having the active one repointed at
|
||||
# this working tree.
|
||||
exec env -u VIRTUAL_ENV "$top/script/setup"
|
||||
@@ -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()
|
||||
|
||||
+43
-16
@@ -7,13 +7,19 @@ cd "$(dirname "$0")/.."
|
||||
if [ -n "$VIRTUAL_ENV" ]; then
|
||||
# A virtual environment is already active (e.g. the devcontainer's pre-provisioned
|
||||
# esphome-venv). Install into it rather than creating a ./venv in the workspace.
|
||||
created_venv=false
|
||||
venv_state=active
|
||||
elif [ -x venv/bin/python ]; then
|
||||
# Reuse the environment from an earlier run, so this script can be run again
|
||||
# at any time to pick up dependency changes.
|
||||
venv_state=reused
|
||||
source venv/bin/activate
|
||||
else
|
||||
created_venv=true
|
||||
venv_state=created
|
||||
# --clear replaces a partial environment left behind by an interrupted run.
|
||||
if [ -x "$(command -v uv)" ]; then
|
||||
uv venv --seed venv
|
||||
uv venv --clear --seed venv
|
||||
else
|
||||
python3 -m venv venv
|
||||
python3 -m venv --clear venv
|
||||
fi
|
||||
source venv/bin/activate
|
||||
fi
|
||||
@@ -25,20 +31,41 @@ fi
|
||||
uv pip install setuptools wheel
|
||||
uv pip install -e ".[dev,test]" --config-settings editable_mode=compat
|
||||
|
||||
# --overwrite replaces any hook already in place. Without it, prek finds a
|
||||
# previously installed pre-commit hook, moves it aside to
|
||||
# .git/hooks/pre-commit.legacy and keeps calling it, so every commit would
|
||||
# run both tools.
|
||||
prek install --overwrite
|
||||
# A worktree shares one git hooks directory with the main checkout it was
|
||||
# created from, so hooks are installed from the main checkout only. Installing
|
||||
# from a worktree would point the shared hook at that worktree's virtual
|
||||
# environment, breaking it for everyone once the worktree is removed.
|
||||
git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null || true)"
|
||||
common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
|
||||
if [ -n "$common_dir" ] && [ "$git_dir" = "$common_dir" ]; then
|
||||
# --overwrite replaces any hook already in place. Without it, prek finds a
|
||||
# previously installed pre-commit hook, moves it aside to
|
||||
# .git/hooks/pre-commit.legacy and keeps calling it, so every commit would
|
||||
# run both tools.
|
||||
prek install --overwrite
|
||||
|
||||
# Prepares the virtual environment for new checkouts and worktrees. Installed
|
||||
# once here, it covers every worktree created from this checkout.
|
||||
if [ -d "$common_dir/hooks" ]; then
|
||||
cp script/git-hooks/post-checkout "$common_dir/hooks/post-checkout"
|
||||
chmod +x "$common_dir/hooks/post-checkout"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p .temp
|
||||
|
||||
echo
|
||||
echo
|
||||
if [ "$created_venv" = true ]; then
|
||||
echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it."
|
||||
else
|
||||
echo "Dependencies installed into the active virtual environment:"
|
||||
echo " $VIRTUAL_ENV"
|
||||
echo "It is already active in this shell, so no 'source venv/bin/activate' is needed."
|
||||
fi
|
||||
case "$venv_state" in
|
||||
created)
|
||||
echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it."
|
||||
;;
|
||||
reused)
|
||||
echo "Dependencies updated in the existing ./venv. Run 'source venv/bin/activate' to use it."
|
||||
;;
|
||||
active)
|
||||
echo "Dependencies installed into the active virtual environment:"
|
||||
echo " $VIRTUAL_ENV"
|
||||
echo "It is already active in this shell, so no 'source venv/bin/activate' is needed."
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -363,4 +363,47 @@ static void Snprintf_Uint32_Large(benchmark::State &state) {
|
||||
}
|
||||
BENCHMARK(Snprintf_Uint32_Large);
|
||||
|
||||
// --- step_to_accuracy_decimals() ---
|
||||
// Called from climate traits and web_server for every number/climate step.
|
||||
|
||||
static void StepToAccuracyDecimals_Tenth(benchmark::State &state) {
|
||||
for (auto _ : state) {
|
||||
int result = 0;
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
result += step_to_accuracy_decimals(0.1f);
|
||||
}
|
||||
benchmark::DoNotOptimize(result);
|
||||
}
|
||||
state.SetItemsProcessed(state.iterations() * kInnerIterations);
|
||||
}
|
||||
BENCHMARK(StepToAccuracyDecimals_Tenth);
|
||||
|
||||
static void StepToAccuracyDecimals_Whole(benchmark::State &state) {
|
||||
for (auto _ : state) {
|
||||
int result = 0;
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
result += step_to_accuracy_decimals(1.0f);
|
||||
}
|
||||
benchmark::DoNotOptimize(result);
|
||||
}
|
||||
state.SetItemsProcessed(state.iterations() * kInnerIterations);
|
||||
}
|
||||
BENCHMARK(StepToAccuracyDecimals_Whole);
|
||||
|
||||
static void StepToAccuracyDecimals_Mixed(benchmark::State &state) {
|
||||
static constexpr float steps[] = {
|
||||
0.001f, 0.01f, 0.05f, 0.1f, 0.25f, 0.5f, 1.0f, 2.5f, 5.0f, 10.0f,
|
||||
};
|
||||
static constexpr int num_steps = sizeof(steps) / sizeof(steps[0]);
|
||||
for (auto _ : state) {
|
||||
int result = 0;
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
result += step_to_accuracy_decimals(steps[i % num_steps]);
|
||||
}
|
||||
benchmark::DoNotOptimize(result);
|
||||
}
|
||||
state.SetItemsProcessed(state.iterations() * kInnerIterations);
|
||||
}
|
||||
BENCHMARK(StepToAccuracyDecimals_Mixed);
|
||||
|
||||
} // namespace esphome::benchmarks
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Config validation for the byte offset on holding-register write entities.
|
||||
|
||||
A 16-bit register write cannot target half a register, so an odd offset (or byte_offset) is
|
||||
rejected for holding-register switches and outputs; even offsets and coil offsets pass.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid, MultipleInvalid
|
||||
|
||||
from esphome.components.modbus_controller.output import (
|
||||
CONFIG_SCHEMA as OUTPUT_CONFIG_SCHEMA,
|
||||
)
|
||||
from esphome.components.modbus_controller.switch import (
|
||||
CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA,
|
||||
)
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_NAME, CONF_OFFSET
|
||||
|
||||
|
||||
def _switch_config(register_type: str, offset: int) -> dict:
|
||||
return {
|
||||
CONF_NAME: "test switch",
|
||||
CONF_ADDRESS: 0x10,
|
||||
"register_type": register_type,
|
||||
CONF_OFFSET: offset,
|
||||
}
|
||||
|
||||
|
||||
def _output_config(register_type: str, offset: int) -> dict:
|
||||
return {
|
||||
CONF_ID: "test_output",
|
||||
CONF_ADDRESS: 0x10,
|
||||
"register_type": register_type,
|
||||
CONF_OFFSET: offset,
|
||||
}
|
||||
|
||||
|
||||
def test_odd_offset_on_holding_switch_rejected() -> None:
|
||||
with pytest.raises((Invalid, MultipleInvalid), match="odd"):
|
||||
SWITCH_CONFIG_SCHEMA(_switch_config("holding", 3))
|
||||
|
||||
|
||||
def test_even_offset_on_holding_switch_accepted() -> None:
|
||||
config = SWITCH_CONFIG_SCHEMA(_switch_config("holding", 2))
|
||||
assert config[CONF_OFFSET] == 2
|
||||
|
||||
|
||||
def test_odd_offset_on_coil_switch_accepted() -> None:
|
||||
"""A coil offset is a coil count, so odd values are fine."""
|
||||
config = SWITCH_CONFIG_SCHEMA(_switch_config("coil", 3))
|
||||
assert config[CONF_OFFSET] == 3
|
||||
|
||||
|
||||
def test_odd_byte_offset_on_holding_switch_rejected() -> None:
|
||||
"""byte_offset is the alias the validator must also catch."""
|
||||
config = _switch_config("holding", 0)
|
||||
del config[CONF_OFFSET]
|
||||
config["byte_offset"] = 3
|
||||
with pytest.raises((Invalid, MultipleInvalid), match="byte_offset"):
|
||||
SWITCH_CONFIG_SCHEMA(config)
|
||||
|
||||
|
||||
def test_odd_offset_on_holding_output_rejected() -> None:
|
||||
with pytest.raises((Invalid, MultipleInvalid), match="odd"):
|
||||
OUTPUT_CONFIG_SCHEMA(_output_config("holding", 3))
|
||||
|
||||
|
||||
def test_even_offset_on_holding_output_accepted() -> None:
|
||||
config = OUTPUT_CONFIG_SCHEMA(_output_config("holding", 2))
|
||||
assert config[CONF_OFFSET] == 2
|
||||
|
||||
|
||||
def test_odd_offset_on_coil_output_accepted() -> None:
|
||||
config = OUTPUT_CONFIG_SCHEMA(_output_config("coil", 3))
|
||||
assert config[CONF_OFFSET] == 3
|
||||
@@ -11,6 +11,7 @@ from esphome.components.provisioning import (
|
||||
CONFIG_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA,
|
||||
register_source,
|
||||
report_ap_without_sta,
|
||||
report_hardcoded_credentials,
|
||||
)
|
||||
from esphome.const import CONF_TIMEOUT, PlatformFramework
|
||||
@@ -66,6 +67,32 @@ def test_provisioning_no_warning_without_hardcoded_credentials(
|
||||
assert "credentials" not in caplog.text
|
||||
|
||||
|
||||
def test_provisioning_warns_on_ap_without_sta(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An access point with no station credentials triggers a reachability warning."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
register_source("network")
|
||||
report_ap_without_sta()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
FINAL_VALIDATE_SCHEMA({})
|
||||
assert "access point" in caplog.text
|
||||
assert "unreachable" in caplog.text
|
||||
|
||||
|
||||
def test_provisioning_no_warning_without_ap(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""No reachability warning when no AP-without-station setup is reported."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
register_source("network")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
FINAL_VALIDATE_SCHEMA({})
|
||||
assert "access point" not in caplog.text
|
||||
|
||||
|
||||
def test_provisioning_rejects_zero_timeout(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
|
||||
@@ -54,7 +54,7 @@ static void verify_mac(uint64_t mac, size_t expected_bytes) {
|
||||
size_t ref_len = reference_encode(mac, ref_buf);
|
||||
|
||||
APIBuffer api_buf;
|
||||
api_buf.resize(16);
|
||||
ASSERT_TRUE(api_buf.resize(16));
|
||||
uint8_t *pos = api_buf.data();
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/alloc_helpers.h"
|
||||
@@ -280,4 +281,71 @@ TEST(Base64, Rfc4648Vectors) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- step_to_accuracy_decimals() ---
|
||||
|
||||
TEST(StepToAccuracyDecimals, TypicalSteps) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.001f), 3);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.005f), 3);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.01f), 2);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.025f), 3);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.05f), 2);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.1f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.25f), 2);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.5f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1.5f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(2.5f), 1);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, WholeSteps) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(2.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(5.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(10.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(100.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1000.0f), 0);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, FiveSignificantDigits) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1.23456f), 4);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(12.345f), 3);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(123.45f), 2);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1234.5f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(12345.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.33333f), 5);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.0001f), 4);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, TrailingZerosDropped) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.3f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.7f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.125f), 3);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.0625f), 4);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, RoundsUpToWholeNumber) {
|
||||
// Rounds to five significant digits first, so this becomes 10 with no decimals.
|
||||
EXPECT_EQ(step_to_accuracy_decimals(9.999999f), 0);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, OutsideFixedNotationRange) {
|
||||
// %.5g prints these in exponent form, so the count comes from parsing "1e-05" or "1.2346e+05".
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.00001f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.000125f), 6);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(123456.0f), 8);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1000000.0f), 0);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, SignIgnored) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(-0.1f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(-0.25f), 2);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(-1.0f), 0);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, NonFiniteAndZero) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(NAN), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(INFINITY), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# The builder test compares against the Improv library's build_rpc_response,
|
||||
# so the library must be part of the unit test build.
|
||||
# Keep the version in sync with the pin in esphome/components/improv_base/__init__.py.
|
||||
esphome:
|
||||
libraries:
|
||||
- improv/Improv@1.2.7
|
||||
@@ -0,0 +1,102 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <improv.h>
|
||||
|
||||
namespace esphome::improv_base::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<uint8_t> build_with_builder(improv::Command command, const std::vector<std::string> &datum,
|
||||
bool add_checksum) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, command);
|
||||
for (const auto &str : datum) {
|
||||
EXPECT_TRUE(builder.add_string(str.c_str(), str.size()));
|
||||
}
|
||||
auto out = builder.finish(add_checksum);
|
||||
return {out.begin(), out.end()};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// The serial path sends builder output where build_rpc_response bytes went before,
|
||||
// so the two must match exactly, including the trailing 0x00 when checksums are off.
|
||||
TEST(RpcResponseBuilder, ByteIdenticalToBuildRpcResponse) {
|
||||
const std::vector<std::string> device_info = {"ESPHome", "2026.9.0", "ESP32", "test-device"};
|
||||
const std::vector<std::string> network = {"MySSID", "-67", "YES"};
|
||||
const std::vector<std::string> empty = {};
|
||||
const std::vector<std::string> max_payload = {std::string(254, 'x')};
|
||||
|
||||
for (bool add_checksum : {false, true}) {
|
||||
for (const auto *datum : {&device_info, &network, &empty, &max_payload}) {
|
||||
EXPECT_EQ(build_with_builder(improv::GET_DEVICE_INFO, *datum, add_checksum),
|
||||
improv::build_rpc_response(improv::GET_DEVICE_INFO, *datum, add_checksum));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Golden bytes independent of the library: command, data length, string entries,
|
||||
// then the trailing byte (0x00 without checksum, additive checksum with).
|
||||
TEST(RpcResponseBuilder, GoldenBytes) {
|
||||
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {}, false), (std::vector<uint8_t>{0x04, 0x00, 0x00}));
|
||||
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {"ab"}, false),
|
||||
(std::vector<uint8_t>{0x04, 0x03, 0x02, 'a', 'b', 0x00}));
|
||||
// Checksum: 0x04 + 0x03 + 0x02 + 'a' + 'b' = 0xCC
|
||||
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {"ab"}, true),
|
||||
(std::vector<uint8_t>{0x04, 0x03, 0x02, 'a', 'b', 0xCC}));
|
||||
}
|
||||
|
||||
// esp32_improv calls finish() and build_rpc_response() with no checksum flag,
|
||||
// so the two defaults must agree
|
||||
TEST(RpcResponseBuilder, DefaultChecksumFlagMatches) {
|
||||
const std::vector<std::string> urls = {"https://example.com"};
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS);
|
||||
for (const auto &str : urls) {
|
||||
EXPECT_TRUE(builder.add_string(str.c_str(), str.size()));
|
||||
}
|
||||
auto out = builder.finish();
|
||||
EXPECT_EQ(std::vector<uint8_t>(out.begin(), out.end()), improv::build_rpc_response(improv::WIFI_SETTINGS, urls));
|
||||
}
|
||||
|
||||
TEST(RpcResponseBuilder, PayloadBudget) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
|
||||
// 254 byte string fills the payload exactly; a second entry no longer fits
|
||||
improv::RpcResponseBuilder full(buf, improv::GET_DEVICE_INFO);
|
||||
const std::string big(254, 'x');
|
||||
EXPECT_TRUE(full.add_string(big.c_str(), big.size()));
|
||||
EXPECT_FALSE(full.add_string("y", 1));
|
||||
|
||||
// 255 byte string can never fit (its length byte would exceed the budget)
|
||||
improv::RpcResponseBuilder over(buf, improv::GET_DEVICE_INFO);
|
||||
const std::string too_big(255, 'y');
|
||||
EXPECT_FALSE(over.add_string(too_big.c_str(), too_big.size()));
|
||||
// A wildly out of range length must not wrap the position arithmetic
|
||||
EXPECT_FALSE(over.add_string("z", static_cast<size_t>(-1)));
|
||||
auto out = over.finish(false);
|
||||
EXPECT_EQ(std::vector<uint8_t>(out.begin(), out.end()), (std::vector<uint8_t>{0x03, 0x00, 0x00}));
|
||||
}
|
||||
|
||||
TEST(RpcResponseBuilder, FinishIsIdempotent) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, improv::GET_DEVICE_INFO);
|
||||
EXPECT_TRUE(builder.add_string("abc", 3));
|
||||
auto first = builder.finish(true);
|
||||
const std::vector<uint8_t> expected(first.begin(), first.end());
|
||||
|
||||
EXPECT_FALSE(builder.add_string("late", 4));
|
||||
auto again = builder.finish(true);
|
||||
EXPECT_EQ(std::vector<uint8_t>(again.begin(), again.end()), expected);
|
||||
// The checksum flag on a later call is ignored
|
||||
auto no_checksum = builder.finish(false);
|
||||
EXPECT_EQ(std::vector<uint8_t>(no_checksum.begin(), no_checksum.end()), expected);
|
||||
}
|
||||
|
||||
} // namespace esphome::improv_base::testing
|
||||
@@ -0,0 +1,11 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
# Serial logging off; on a dedicated UART bus improv_serial must not
|
||||
# require the logger's serial settings
|
||||
logger:
|
||||
baud_rate: 0
|
||||
|
||||
improv_serial:
|
||||
uart_id: uart_bus
|
||||
@@ -5,4 +5,6 @@ wifi:
|
||||
logger:
|
||||
hardware_uart: UART0
|
||||
|
||||
# next_url compiles the USE_IMPROV_SERIAL_NEXT_URL branch and add_next_url_
|
||||
improv_serial:
|
||||
next_url: https://example.com/?device_name={{device_name}}&ip_address={{ip_address}}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
improv_serial: !include common-uart-bus.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
|
||||
improv_serial: !include common-uart-bus.yaml
|
||||
@@ -5,6 +5,11 @@
|
||||
|
||||
#include "esphome/components/modbus_controller/modbus_controller.h"
|
||||
|
||||
// These tests pin the behaviour of the deprecated ModbusCommandItem until its removal.
|
||||
// Remove with ModbusCommandItem before 2027.3.0.
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
|
||||
namespace esphome::modbus_controller::testing {
|
||||
|
||||
// The coil write factory packs into an exact-size payload. Pinned at one past the protocol maximum
|
||||
@@ -29,3 +34,5 @@ TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) {
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus_controller::testing
|
||||
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
@@ -62,6 +62,12 @@ image:
|
||||
url: http://www.faqs.org/images/library.jpg
|
||||
format: AUTO
|
||||
type: RGB565
|
||||
- platform: online_image
|
||||
id: online_qoi_image
|
||||
url: https://www.example.org/image.qoi
|
||||
format: QOI
|
||||
type: RGB
|
||||
transparency: alpha_channel
|
||||
|
||||
# Check the set_url action
|
||||
esphome:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Exercises the provisioning window: api registers as a provisioning source
|
||||
# (encryption enabled, no key), the on_timeout automation, and the wifi +
|
||||
# esp32_improv cross-component guards. improv_serial is intentionally NOT gated.
|
||||
# (encryption enabled, no key), the on_timeout automation, and the wifi (AP +
|
||||
# captive portal) and esp32_improv cross-component guards. improv_serial is
|
||||
# intentionally NOT gated.
|
||||
provisioning:
|
||||
timeout: 1min
|
||||
on_timeout:
|
||||
@@ -13,6 +14,10 @@ api:
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
ap:
|
||||
ssid: MyAP
|
||||
|
||||
captive_portal:
|
||||
|
||||
improv_serial:
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source
|
||||
# and the wifi reboot guard. improv_serial is present and intentionally NOT gated.
|
||||
# and the wifi (AP + captive portal) guards. improv_serial is present and
|
||||
# intentionally NOT gated.
|
||||
provisioning:
|
||||
timeout: 1min
|
||||
on_timeout:
|
||||
@@ -12,5 +13,9 @@ api:
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
ap:
|
||||
ssid: MyAP
|
||||
|
||||
captive_portal:
|
||||
|
||||
improv_serial:
|
||||
|
||||
@@ -9,7 +9,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# tests have two decoder types and every retained decoder is under test.
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
enable_format("BMP")
|
||||
enable_format("PNG")
|
||||
enable_format("JPEG")
|
||||
enable_format("PNG")
|
||||
enable_format("QOI")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
|
||||
@@ -70,11 +70,36 @@ static const uint8_t PNG_RGB_EXPECTED[4][4][3] = {
|
||||
{{0x12, 0x34, 0x56}, {0x65, 0x43, 0x21}, {0xFE, 0xDC, 0xBA}, {0xAB, 0xCD, 0xEF}},
|
||||
};
|
||||
|
||||
// 3x3 QOI, exercising all possible chunk types
|
||||
static const uint8_t QOI_RGBA[] = {
|
||||
0x71, 0x6F, 0x69, 0x66, // Header: 'qoif'
|
||||
0x00, 0x00, 0x00, 0x03, // Width: 3
|
||||
0x00, 0x00, 0x00, 0x03, // Height: 3
|
||||
0x04, // Channels: 4 (RGBA)
|
||||
0x00, // Colorspace: 0 (SRGB)
|
||||
0xC1, // 1. QOI_OP_RUN
|
||||
0x79, // 2. QOI_OP_DIFF
|
||||
0xAA, 0x79, // 3. QOI_OP_LUMA
|
||||
0xFE, 0xC8, 0x64, 0x32, // 4. QOI_OP_RGB
|
||||
0xFF, 0x78, 0x50, 0x28,
|
||||
0x64, // 5. QOI_OP_RGBA
|
||||
0x31, // 6. QOI_OP_INDEX
|
||||
0xC1, // 7. QOI_OP_RUN
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 // End Marker
|
||||
};
|
||||
|
||||
static const uint8_t QOI_EXPECTED_RGBA[3][3][4] = {
|
||||
{{0x00, 0x00, 0x00, 0xFF}, {0x00, 0x00, 0x00, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}},
|
||||
{{0x0A, 0x0A, 0x0A, 0xFF}, {0xC8, 0x64, 0x32, 0xFF}, {0x78, 0x50, 0x28, 0x64}},
|
||||
{{0x01, 0x00, 0xFF, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}}
|
||||
|
||||
};
|
||||
|
||||
/// Exposes the protected decoder machinery so reuse and eviction can be observed directly.
|
||||
class TestableRuntimeImage : public RuntimeImage {
|
||||
public:
|
||||
explicit TestableRuntimeImage(ImageFormat format)
|
||||
: RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {}
|
||||
explicit TestableRuntimeImage(ImageFormat format, image::Transparency transparency = image::TRANSPARENCY_OPAQUE)
|
||||
: RuntimeImage(format, image::IMAGE_TYPE_RGB, transparency, nullptr, false, 0, 0) {}
|
||||
|
||||
ImageDecoder *decoder() { return this->decoder_.get(); }
|
||||
};
|
||||
@@ -132,6 +157,19 @@ template<size_t H, size_t W> static void expect_pixels(TestableRuntimeImage &img
|
||||
}
|
||||
}
|
||||
|
||||
template<size_t H, size_t W>
|
||||
static void expect_pixels_rgba(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][4]) {
|
||||
ASSERT_EQ(img.get_width(), static_cast<int>(W));
|
||||
ASSERT_EQ(img.get_height(), static_cast<int>(H));
|
||||
for (size_t y = 0; y < H; y++) {
|
||||
for (size_t x = 0; x < W; x++) {
|
||||
SCOPED_TRACE(::testing::Message() << "pixel (" << x << "," << y << ")");
|
||||
Color color = img.get_pixel(x, y);
|
||||
EXPECT_THAT((std::array<uint8_t, 4>{color.r, color.g, color.b, color.w}),
|
||||
::testing::ElementsAreArray(expected[y][x]));
|
||||
}
|
||||
}
|
||||
}
|
||||
TEST(RuntimeImageDecoder, DecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
|
||||
@@ -337,6 +375,33 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
|
||||
}
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
|
||||
TEST(RuntimeImageDecoder, QoiDecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(QOI, image::TRANSPARENCY_ALPHA_CHANNEL);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, QOI_RGBA, sizeof(QOI_RGBA)));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
ImageDecoder *first = img.decoder();
|
||||
ASSERT_NE(first, nullptr);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, QOI_RGBA, sizeof(QOI_RGBA)));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
EXPECT_EQ(img.decoder(), first) << "decoder must be reused, not reallocated";
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, QoiChunkedFeedDecodesLikeDownloadLoop) {
|
||||
TestableRuntimeImage img(QOI, image::TRANSPARENCY_ALPHA_CHANNEL);
|
||||
|
||||
ASSERT_TRUE(decode_chunked(img, QOI_RGBA, sizeof(QOI_RGBA), 10));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
ImageDecoder *first = img.decoder();
|
||||
|
||||
// Chunked again on the warm decoder: the cross-call resume state
|
||||
// (current_index_ / paint_index_) must have been fully reset.
|
||||
ASSERT_TRUE(decode_chunked(img, QOI_RGBA, sizeof(QOI_RGBA), 10));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
EXPECT_EQ(img.decoder(), first);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
std::vector<uint8_t> buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
|
||||
|
||||
@@ -10,12 +10,14 @@ TEST(RuntimeImageMime, FormatForKnownMimeTypes) {
|
||||
EXPECT_EQ(get_format_for_mime_type("image/bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-ms-bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_EQ(get_format_for_mime_type("image/jpeg"), JPEG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/jpg"), JPEG);
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/qoi"), QOI);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-qoi"), QOI);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageMime, FormatMatchingIsCaseInsensitive) {
|
||||
@@ -39,20 +41,22 @@ TEST(RuntimeImageMime, UnknownMimeTypeHasNoFormat) {
|
||||
|
||||
TEST(RuntimeImageMime, MimeTypeForFormatRoundTrip) {
|
||||
EXPECT_STREQ(get_mime_type_for_format(BMP), "image/bmp");
|
||||
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_STREQ(get_mime_type_for_format(JPEG), "image/jpeg");
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
|
||||
EXPECT_STREQ(get_mime_type_for_format(QOI), "image/qoi");
|
||||
// AUTO has no single MIME type and falls back to the wildcard
|
||||
EXPECT_STREQ(get_mime_type_for_format(AUTO), "image/*");
|
||||
|
||||
// Every decodable format must resolve back to itself through its MIME type
|
||||
for (ImageFormat format : {
|
||||
BMP,
|
||||
PNG,
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
JPEG,
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
PNG,
|
||||
QOI,
|
||||
}) {
|
||||
EXPECT_EQ(get_format_for_mime_type(get_mime_type_for_format(format)), format) << format;
|
||||
}
|
||||
|
||||
@@ -80,3 +80,24 @@ switch:
|
||||
- platform: tuya
|
||||
id: tuya_switch
|
||||
switch_datapoint: 1
|
||||
|
||||
water_heater:
|
||||
- platform: tuya
|
||||
id: tuya_water_heater
|
||||
name: Tuya Water Heater
|
||||
switch_datapoint: 1
|
||||
current_temperature_datapoint: 3
|
||||
target_temperature_datapoint: 2
|
||||
current_temperature_multiplier: 0.5
|
||||
target_temperature_multiplier: 0.5
|
||||
mode_datapoint: 4
|
||||
eco_value: 0
|
||||
electric_value: 2
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- ECO
|
||||
- ELECTRIC
|
||||
visual:
|
||||
min_temperature: 30
|
||||
max_temperature: 75
|
||||
target_temperature_step: 1
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Host-only stub of the wifi component for integration tests.
|
||||
|
||||
HOST-ONLY TEST COMPONENT: this shadows the real wifi component for EVERY
|
||||
fixture that uses the shared external_components directory. Any host fixture
|
||||
with a wifi block gets this stub, not the real component: fixed scan results,
|
||||
is_connected() hardwired true, and save_wifi_sta that only logs. See
|
||||
wifi_component.h for the full behavior.
|
||||
"""
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_PASSWORD, CONF_SSID, CONF_USE_ADDRESS
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/tests"]
|
||||
|
||||
wifi_ns = cg.esphome_ns.namespace("wifi")
|
||||
WiFiComponent = wifi_ns.class_("WiFiComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(WiFiComponent),
|
||||
# Accepted for fixture realism; the stub ignores them
|
||||
cv.Optional(CONF_SSID): cv.string,
|
||||
cv.Optional(CONF_PASSWORD): cv.string,
|
||||
# Read by StorageJSON via CORE.address whenever a wifi block exists
|
||||
cv.Optional(CONF_USE_ADDRESS, default="localhost"): cv.string,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
def check_placeholder_credentials(config: ConfigType) -> None:
|
||||
"""Compile-time hook the esphome CLI imports from the wifi module; no-op here."""
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add_define("USE_WIFI")
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../esphome/components/wifi/scan_list.h
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "wifi_component.h"
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::wifi {
|
||||
|
||||
static const char *const TAG = "wifi_stub";
|
||||
|
||||
WiFiComponent *global_wifi_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
WiFiComponent::WiFiComponent() { global_wifi_component = this; }
|
||||
|
||||
void WiFiComponent::setup() { ESP_LOGI(TAG, "Stub wifi ready"); }
|
||||
|
||||
void WiFiComponent::dump_config() { ESP_LOGCONFIG(TAG, "Stub wifi"); }
|
||||
|
||||
void WiFiComponent::start_scanning() {
|
||||
// Duplicate TestNet entry (weaker) and a hidden entry exercise the
|
||||
// should_show_scan_entry dedup and filtering logic
|
||||
this->scan_result_.clear();
|
||||
this->scan_result_.emplace_back("TestNet", -50, true, false);
|
||||
this->scan_result_.emplace_back("TestNet", -60, true, false);
|
||||
this->scan_result_.emplace_back("OpenNet", -70, false, false);
|
||||
this->scan_result_.emplace_back("", -40, false, true);
|
||||
ESP_LOGI(TAG, "Scan complete with %zu results", this->scan_result_.size());
|
||||
}
|
||||
|
||||
void WiFiComponent::set_sta(const WiFiAP &ap) { ESP_LOGI(TAG, "set_sta ssid=%s", ap.get_ssid().c_str()); }
|
||||
|
||||
void WiFiComponent::start_connecting(const WiFiAP &ap) {
|
||||
ESP_LOGI(TAG, "start_connecting ssid=%s", ap.get_ssid().c_str());
|
||||
}
|
||||
|
||||
void WiFiComponent::clear_sta() { ESP_LOGI(TAG, "clear_sta"); }
|
||||
|
||||
void WiFiComponent::save_wifi_sta(StringRef ssid, StringRef password) {
|
||||
ESP_LOGI(TAG, "save_wifi_sta ssid=%s password_len=%zu", ssid.c_str(), password.size());
|
||||
}
|
||||
|
||||
} // namespace esphome::wifi
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
|
||||
// ============================================================================
|
||||
// HOST-ONLY TEST COMPONENT — DO NOT COPY TO PRODUCTION CODE
|
||||
//
|
||||
// Stub of the real wifi component with just enough API surface for
|
||||
// improv_serial to build and run on the host platform. Scan results are
|
||||
// fixed, "connecting" succeeds immediately, and save_wifi_sta only logs so
|
||||
// tests can assert on the log output.
|
||||
// ============================================================================
|
||||
|
||||
#include "esphome/components/network/ip_address.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::wifi {
|
||||
|
||||
class WiFiAP {
|
||||
public:
|
||||
void set_ssid(const char *ssid) { this->ssid_ = ssid; }
|
||||
void set_password(const char *password) { this->password_ = password; }
|
||||
StringRef get_ssid() const { return StringRef(this->ssid_); }
|
||||
StringRef get_password() const { return StringRef(this->password_); }
|
||||
|
||||
protected:
|
||||
std::string ssid_;
|
||||
std::string password_;
|
||||
};
|
||||
|
||||
class WiFiScanResult {
|
||||
public:
|
||||
WiFiScanResult(const char *ssid, int8_t rssi, bool with_auth, bool hidden)
|
||||
: ssid_(ssid), rssi_(rssi), with_auth_(with_auth), hidden_(hidden) {}
|
||||
StringRef get_ssid() const { return StringRef(this->ssid_); }
|
||||
int8_t get_rssi() const { return this->rssi_; }
|
||||
bool get_with_auth() const { return this->with_auth_; }
|
||||
bool get_is_hidden() const { return this->hidden_; }
|
||||
bool ssid_equals(const WiFiScanResult &other) const { return this->ssid_ == other.ssid_; }
|
||||
|
||||
protected:
|
||||
std::string ssid_;
|
||||
int8_t rssi_;
|
||||
bool with_auth_;
|
||||
bool hidden_;
|
||||
};
|
||||
|
||||
class WiFiComponent : public Component {
|
||||
public:
|
||||
WiFiComponent();
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::WIFI; }
|
||||
|
||||
bool has_sta() const { return false; }
|
||||
bool is_disabled() const { return false; }
|
||||
// Always connected so network::is_connected() keeps the API server accepting clients
|
||||
bool is_connected() const { return true; }
|
||||
void start_scanning();
|
||||
const std::vector<WiFiScanResult> &get_scan_result() const { return this->scan_result_; }
|
||||
void set_sta(const WiFiAP &ap);
|
||||
void start_connecting(const WiFiAP &ap);
|
||||
void clear_sta();
|
||||
void save_wifi_sta(StringRef ssid, StringRef password);
|
||||
// Called by network::util on any USE_WIFI build
|
||||
const char *get_use_address() const { return "localhost"; }
|
||||
network::IPAddresses get_ip_addresses() { return {}; }
|
||||
|
||||
protected:
|
||||
std::vector<WiFiScanResult> scan_result_;
|
||||
};
|
||||
|
||||
extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
} // namespace esphome::wifi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user