Compare commits

...
Author SHA1 Message Date
J. Nick Koston e5632aa20e Simulate the unreadable partitions.csv instead of chmod 2026-08-28 11:58:13 -05:00
J. Nick Koston c75e3898e4 Harden the summary against foreign json shapes and partition edge cases 2026-08-28 11:44:48 -05:00
J. Nick Koston b66990fbff Update the print_summary docstring 2026-08-28 11:36:30 -05:00
J. Nick Koston 05ec260ff3 Split the summary skip diagnostics and validate total_size 2026-08-28 10:39:37 -05:00
J. Nick Koston ad40853283 Pin the size command format in the template test, warn on schema drift 2026-08-28 09:15:35 -05:00
J. Nick Koston 52ce6668d6 Reject ELFs with no allocated PROGBITS sections 2026-08-28 00:51:02 -05:00
J. Nick Koston 2808837743 Parametrize the bad input cases as data 2026-08-28 00:22:45 -05:00
J. Nick Koston 4caf7bafb8 Trim comments and docstrings 2026-08-28 00:21:18 -05:00
J. Nick Koston 22e29df396 Tighten the ELF parser and log skipped summary lines 2026-08-28 00:19:54 -05:00
J. Nick Koston cafc09bdca Cover create_elf_copy 2026-08-28 00:13:15 -05:00
J. Nick Koston e632661adf Derive the exact image size from the ELF when json2 lacks total_size 2026-08-28 00:08:42 -05:00
J. Nick Koston 8d0f3ea2bb Use json2 total_size when present, bin size as fallback 2026-08-28 00:01:13 -05:00
J. Nick Koston 8ee2f4247e Document bin padding and pin the print_summary wiring 2026-08-27 23:59:10 -05:00
J. Nick Koston edf2f7c62a Let print_summary own missing input handling 2026-08-27 23:54:39 -05:00
J. Nick Koston 1fc7a0798d Cover the unreadable firmware bin branch 2026-08-27 23:50:51 -05:00
J. Nick Koston 39092a791a [espidf] Emit json2 size data so the link edge is not blocked 2026-08-27 23:44:45 -05:00
Jesse Hills 2031be0c23 [core] Make script/setup idempotent and prepare new worktrees (#18843) 2026-08-28 14:11:38 +12:00
esphome[bot] 708b7bfb53 Update webserver local assets to 20260824-021242 (#18704) 2026-08-28 11:39:30 +12:00
J. Nick Koston 7782bc11c6 [core] Remove make_name_with_suffix std::string overloads (#18828) 2026-08-27 15:29:43 -05:00
J. Nick Koston a9a66baeba [ci] Compress the compile-test image with zstd (#18812) 2026-08-27 15:29:23 -05:00
J. Nick Koston 16d0fa1165 [core] Add step_to_accuracy_decimals benchmarks (#18826) 2026-08-27 15:28:04 -05:00
25d5cd3e14 [modbus_controller] Poll through PollingDevice; deprecate ModbusCommandItem (#18071)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-27 14:38:13 -05:00
dependabot[bot] 9598449b6b Bump CodSpeedHQ/action from 5.0.3 to 5.2.1 (#18834)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 14:31:11 -05:00
dependabot[bot] a8c7279b3b Bump platformdirs from 4.11.3 to 4.11.4 (#18835)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 14:30:53 -05:00
rwrozellepre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>Jonathan SwobodaClaude Sonnet 4.6
c3c69c730c [openthread] fix shutdown (#16332)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-27 12:59:17 -05:00
Bonne EgglestonandJ. Nick Koston a8c8827a5b [modbus_controller] Switch write_lambda return value is the wire value only (#18788)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-27 10:33:16 -05:00
Bonne EgglestonandJ. Nick Koston 18b7002604 [modbus_controller] Apply the write offset byte-accurately on switch and output (#18787)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-27 08:43:51 -05:00
51105a25db [tuya] Add water_heater platform (#17323)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-08-27 20:55:51 +12:00
J. Nick Koston fb132b4421 [improv_serial][esp32_improv] Use buffer based RPC response builder (#18793) 2026-08-27 01:01:35 -05:00
guillempages 20b38ac8b0 [runtime_image] Add support for QOI images (#16945) 2026-08-27 16:37:20 +12:00
Keith BurzinskiandJ. Nick Koston cdd892a526 [provisioning] Shut down the Wi-Fi AP and captive portal when the window closes (#17466)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-27 15:16:25 +12:00
J. Nick Koston b99e7f5ae2 [core] Install prefetched PlatformIO packages with parallel extraction (#18775) 2026-08-27 03:02:02 +00:00
J. Nick Koston e55a8aeabe [api] Drop connection instead of crashing when buffer allocation fails (#18803) 2026-08-27 14:49:12 +12:00
J. Nick Koston a8094ed548 [docker] Parallelize the image's PlatformIO library preinstall (#18777) 2026-08-27 14:45:47 +12:00
J. Nick Koston 2104096f02 [core] Prefetch PlatformIO packages in parallel (#18769) 2026-08-27 14:42:30 +12:00
J. Nick Koston e53870085b [improv_serial] Add uart bus support and host integration test (#18794) 2026-08-26 21:13:47 -05:00
116 changed files with 10816 additions and 9778 deletions
+16
View File
@@ -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
}
]
}
]
}
}
+13 -7
View File
@@ -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 \
+1 -1
View File
@@ -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
+1
View File
@@ -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
+8 -5
View File
@@ -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}}
+9 -2
View File
@@ -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
+11 -21
View File
@@ -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};
+38 -11
View File
@@ -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
+8 -14
View File
@@ -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
+23 -3
View File
@@ -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
+137 -137
View File
@@ -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_();
+1
View File
@@ -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
};
+36 -5
View File
@@ -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,
+44 -28
View File
@@ -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;
+5 -1
View File
@@ -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
+2
View File
@@ -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);
+37 -19
View File
@@ -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) {
+8 -3
View File
@@ -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
+16 -1
View File
@@ -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
+7 -2
View File
@@ -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
+24 -1
View File
@@ -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_();
+1 -1
View File
@@ -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
View File
@@ -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()
+7 -3
View File
@@ -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
-14
View File
@@ -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
View File
@@ -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
+9 -19
View File
@@ -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
+83 -18
View File
@@ -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)
+12 -3
View File
@@ -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():
+41 -3
View 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)
+9
View File
@@ -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)``."""
+5 -8
View File
@@ -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
+968
View File
@@ -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:]))
+12 -5
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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:
+20
View File
@@ -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"
+364 -47
View File
@@ -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
View File
@@ -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++) {
+43
View File
@@ -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();
+68
View File
@@ -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:
+2 -1
View File
@@ -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;
}
+21
View File
@@ -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