mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d6d8f5ca | ||
|
|
e2ca80ec41 | ||
|
|
920ff9c25f | ||
|
|
56a90d2b25 | ||
|
|
b4166a883e | ||
|
|
b8703a8a1b | ||
|
|
57bb4e4e77 | ||
|
|
de93815c6b | ||
|
|
d4372ed008 | ||
|
|
a199ac41ee | ||
|
|
c0f494450d | ||
|
|
6b6903e568 | ||
|
|
d0f68802b9 | ||
|
|
7862520450 | ||
|
|
cbdddc8020 | ||
|
|
7d1317ad53 | ||
|
|
985a08e247 | ||
|
|
4f3db4c15a | ||
|
|
56512abb7e | ||
|
|
71349a6feb | ||
|
|
489e3d17ca | ||
|
|
d93772fed6 | ||
|
|
50f02aa523 | ||
|
|
5a87ad8fc0 | ||
|
|
e40579ad93 | ||
|
|
37fe59dc37 | ||
|
|
3829d368ff | ||
|
|
013c5d7217 | ||
|
|
e38ae51de2 |
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.7.2
|
||||
PROJECT_NUMBER = 2026.7.4
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.6.9
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.2
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -210,15 +210,27 @@ def get_component_cmakelists() -> str:
|
||||
if(CMAKE_SCRIPT_MODE_FILE)
|
||||
file(GLOB_RECURSE app_sources
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
else()
|
||||
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
endif()
|
||||
|
||||
@@ -12,6 +12,22 @@ APIOverflowBuffer::~APIOverflowBuffer() {
|
||||
}
|
||||
|
||||
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
|
||||
// socket->write() can re-enter this function: a log message emitted from an
|
||||
// lwip callback during the write goes out over the API and lands back in the
|
||||
// frame helper's write/drain path. If a nested drain ran here it would send
|
||||
// and free the entry the outer drain is still holding, causing a double free.
|
||||
// Report "no progress" instead; the outer drain keeps draining, and the
|
||||
// nested send is enqueued behind the existing backlog.
|
||||
if (this->draining_)
|
||||
return 0;
|
||||
|
||||
// RAII so the flag is cleared on every return path
|
||||
struct DrainGuard {
|
||||
explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; }
|
||||
~DrainGuard() { this->flag_ = false; }
|
||||
bool &flag_;
|
||||
} guard(this->draining_);
|
||||
|
||||
while (this->count_ > 0) {
|
||||
Entry *front = this->queue_[this->head_];
|
||||
|
||||
@@ -29,11 +45,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
|
||||
return sent;
|
||||
}
|
||||
|
||||
// Entry fully sent — free it and advance
|
||||
Entry::destroy(front);
|
||||
// Entry fully sent — unlink it before freeing so a freed pointer is never
|
||||
// reachable from the queue
|
||||
this->queue_[this->head_] = nullptr;
|
||||
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
|
||||
this->count_--;
|
||||
Entry::destroy(front);
|
||||
}
|
||||
|
||||
return 0; // All drained
|
||||
|
||||
@@ -69,6 +69,10 @@ class APIOverflowBuffer {
|
||||
uint8_t head_{0};
|
||||
uint8_t tail_{0};
|
||||
uint8_t count_{0};
|
||||
// Guards against re-entrant drains: socket->write() can re-enter the API
|
||||
// send path (e.g. a log message emitted from an lwip callback), and a nested
|
||||
// drain would free the entry the outer drain is still holding.
|
||||
bool draining_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import web_server_base
|
||||
from esphome.components import web_server_base, wifi
|
||||
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
@@ -101,6 +101,9 @@ async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID], paren)
|
||||
await cg.register_component(var, config)
|
||||
cg.add_define("USE_CAPTIVE_PORTAL")
|
||||
# The portal reads wifi scan results from the web server task; this makes the
|
||||
# wifi component guard them with a lock on multi-threaded platforms.
|
||||
wifi.request_wifi_scan_results_lock()
|
||||
|
||||
if config[CONF_COMPRESSION] == "gzip":
|
||||
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
|
||||
|
||||
@@ -7,145 +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, 0x7e,
|
||||
0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36,
|
||||
0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf,
|
||||
0x7e, 0xa0, 0x24, 0x7b, 0x9d, 0x45, 0x73, 0xb8, 0xb3, 0x60, 0x61, 0x38, 0xef, 0x19, 0xcd, 0x83, 0xc5, 0xdf, 0x2a,
|
||||
0xc5, 0xec, 0xbe, 0x03, 0xd4, 0xd8, 0x56, 0x94, 0x85, 0x7b, 0x23, 0x41, 0xe5, 0x8a, 0x80, 0x2c, 0x8b, 0x06, 0x68,
|
||||
0x55, 0x16, 0x2d, 0x58, 0x8a, 0x58, 0x43, 0xb5, 0x01, 0x4b, 0xfe, 0xbc, 0xff, 0x39, 0xb8, 0x2d, 0x0b, 0xc1, 0xe5,
|
||||
0x1a, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0x24, 0x22,
|
||||
0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0x04, 0x6f, 0x79, 0x65, 0x1b, 0x52,
|
||||
0xc1, 0x86, 0x33, 0x08, 0x86, 0xc3, 0x35, 0x97, 0xdc, 0x72, 0x2a, 0x02, 0xc3, 0xa8, 0x00, 0x92, 0x5c, 0xf7, 0x06,
|
||||
0xf4, 0x70, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0x5c, 0x16, 0x86, 0x69, 0xde, 0x59, 0xe4, 0x5c, 0x25, 0xad, 0xaa, 0x7a,
|
||||
0x01, 0x65, 0x14, 0x51, 0x63, 0xc0, 0x9a, 0x88, 0xcb, 0x0a, 0x76, 0xe1, 0x32, 0x66, 0x2c, 0x86, 0xdb, 0xdb, 0xf0,
|
||||
0xb3, 0x79, 0x56, 0x29, 0xd6, 0xb7, 0x20, 0x6d, 0x28, 0x14, 0xa3, 0x96, 0x2b, 0x19, 0x1a, 0xa0, 0x9a, 0x35, 0x84,
|
||||
0x10, 0xfc, 0xa3, 0xa1, 0x1b, 0xc0, 0xdf, 0x7f, 0xef, 0x9d, 0x99, 0x56, 0x60, 0xff, 0x21, 0xc0, 0x81, 0xe6, 0xa7,
|
||||
0xfd, 0x3d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x30, 0x35, 0xbc, 0x02, 0xec, 0x7f, 0x8c, 0x3f, 0x85, 0xc6, 0xee, 0x05,
|
||||
0x84, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x82, 0x97, 0x42, 0xb1, 0x35, 0xf6, 0xf3, 0xba, 0x97, 0xcc, 0x29, 0x47, 0xc6,
|
||||
0x03, 0xff, 0x20, 0xc0, 0x22, 0x4b, 0xde, 0x51, 0xdb, 0x84, 0x2d, 0xdd, 0x79, 0x23, 0xc0, 0xa5, 0x97, 0xfe, 0xe0,
|
||||
0xc1, 0xcb, 0x24, 0x8e, 0xfd, 0xeb, 0xe1, 0x15, 0xfb, 0x51, 0x12, 0xc7, 0xb9, 0x06, 0xdb, 0x6b, 0x89, 0xa8, 0xf7,
|
||||
0x50, 0x74, 0xd4, 0x36, 0xa8, 0x22, 0xf8, 0x5d, 0x92, 0xa2, 0xe4, 0x75, 0x98, 0xce, 0x7f, 0x0b, 0x5f, 0xa1, 0x9b,
|
||||
0x30, 0x9d, 0xb3, 0x57, 0xc1, 0x1c, 0x25, 0x37, 0xc1, 0x1c, 0xa5, 0x69, 0x38, 0x47, 0xf1, 0x17, 0x8c, 0x6a, 0x2e,
|
||||
0x04, 0xc1, 0x52, 0x49, 0xc0, 0xc8, 0x58, 0xad, 0xd6, 0x40, 0x30, 0xeb, 0xb5, 0x06, 0x69, 0xdf, 0x2a, 0xa1, 0x34,
|
||||
0x8e, 0xca, 0x67, 0xff, 0x97, 0x42, 0xab, 0xa9, 0x34, 0xb5, 0xd2, 0x2d, 0xc1, 0x43, 0xf6, 0xbd, 0x17, 0x07, 0x7b,
|
||||
0x44, 0xee, 0xe5, 0x5f, 0x10, 0x03, 0xa5, 0xf9, 0x8a, 0x4b, 0x82, 0x9d, 0xc6, 0x5b, 0x1c, 0x95, 0x0f, 0xfe, 0xf1,
|
||||
0x1c, 0x3d, 0x75, 0xd1, 0x4f, 0xf1, 0x28, 0xef, 0xe3, 0x43, 0x61, 0x36, 0x2b, 0xb4, 0x6b, 0x85, 0x34, 0x04, 0x37,
|
||||
0xd6, 0x76, 0x59, 0x14, 0x6d, 0xb7, 0xdb, 0x70, 0x3b, 0x0b, 0x95, 0x5e, 0x45, 0x69, 0x1c, 0xc7, 0x91, 0xd9, 0xac,
|
||||
0x30, 0x1a, 0x0b, 0x01, 0xa7, 0x37, 0x18, 0x35, 0xc0, 0x57, 0x8d, 0x1d, 0xe0, 0xf2, 0xc5, 0x01, 0x8e, 0x85, 0xe3,
|
||||
0x28, 0x1f, 0x3e, 0x5d, 0x58, 0xe1, 0x17, 0x56, 0xe0, 0x47, 0xea, 0xe1, 0x53, 0x98, 0x57, 0x43, 0x98, 0xaf, 0x68,
|
||||
0x8a, 0x52, 0x14, 0x0f, 0x4f, 0x1a, 0x38, 0x78, 0x3a, 0x05, 0x4f, 0x4e, 0xe8, 0xe2, 0xe4, 0xa0, 0x76, 0x11, 0xbc,
|
||||
0x3e, 0xcb, 0x26, 0x0e, 0xb3, 0x49, 0xe2, 0x47, 0x84, 0x13, 0xf8, 0x65, 0x71, 0x79, 0x0e, 0xd2, 0x0f, 0x97, 0x0c,
|
||||
0xce, 0x5a, 0x93, 0x7c, 0x58, 0xd0, 0x39, 0x9a, 0x4f, 0x98, 0x79, 0xe0, 0xe0, 0xf3, 0x09, 0xcd, 0x37, 0x69, 0x93,
|
||||
0xb4, 0xc1, 0x22, 0x98, 0xd3, 0x19, 0x9a, 0x4d, 0x8e, 0xcc, 0xd0, 0x6c, 0x93, 0x36, 0x8b, 0x0f, 0x8b, 0x4b, 0x5c,
|
||||
0x30, 0xfb, 0x72, 0x15, 0x95, 0xd8, 0xcf, 0x30, 0x7e, 0x8c, 0x5c, 0x5d, 0x46, 0x1e, 0x7e, 0x56, 0x5c, 0x7a, 0x18,
|
||||
0xfb, 0xc7, 0x1a, 0x2c, 0x6b, 0x3c, 0x1c, 0x31, 0x25, 0x6b, 0xbe, 0x0a, 0x3f, 0x1b, 0x25, 0xb1, 0x1f, 0xda, 0x06,
|
||||
0xa4, 0x77, 0x12, 0x75, 0x82, 0x30, 0x50, 0xbc, 0xa7, 0x14, 0xeb, 0x1f, 0xce, 0xf5, 0x6f, 0xb9, 0x15, 0x40, 0x6c,
|
||||
0xe8, 0x1a, 0xf6, 0xfa, 0x8c, 0x5d, 0xaa, 0x6a, 0xff, 0x8d, 0xd6, 0x68, 0x92, 0xb1, 0x2f, 0xb8, 0x94, 0xa0, 0xef,
|
||||
0x61, 0x67, 0x09, 0x7e, 0xf7, 0xe6, 0x2d, 0x7a, 0x53, 0x55, 0x1a, 0x8c, 0xc9, 0x10, 0x7e, 0x69, 0xc3, 0x96, 0xb2,
|
||||
0xff, 0x5d, 0x57, 0xf2, 0x95, 0xae, 0x7f, 0xf2, 0x9f, 0x39, 0xfa, 0x1d, 0xec, 0x56, 0xe9, 0xf5, 0xa4, 0xcd, 0xb9,
|
||||
0x96, 0xbb, 0x0e, 0xd3, 0xc4, 0x86, 0xb4, 0x33, 0xa1, 0x11, 0x9c, 0x81, 0x97, 0xf8, 0x61, 0x4b, 0xbb, 0xc7, 0xa8,
|
||||
0xe4, 0x29, 0x51, 0x0f, 0x45, 0xc5, 0x37, 0x88, 0x09, 0x6a, 0x0c, 0xc1, 0x72, 0x54, 0x85, 0xd1, 0x33, 0x34, 0xfc,
|
||||
0x94, 0x64, 0x82, 0xb3, 0x35, 0xc1, 0x7f, 0x31, 0x01, 0x7e, 0xda, 0xff, 0x5a, 0x79, 0x57, 0xc6, 0xf0, 0xea, 0xca,
|
||||
0x0f, 0x37, 0x54, 0xf4, 0x80, 0x08, 0xb2, 0x0d, 0x37, 0x8f, 0x0e, 0xe6, 0xdf, 0x14, 0xeb, 0xcc, 0xfa, 0xca, 0x0f,
|
||||
0x6b, 0xc5, 0x7a, 0xe3, 0xf9, 0xb8, 0x9c, 0xcc, 0x15, 0x74, 0x1c, 0x90, 0xf8, 0x39, 0x7e, 0xe2, 0x51, 0x20, 0xa0,
|
||||
0xb6, 0x67, 0x3e, 0x84, 0x5e, 0x1c, 0x8c, 0x27, 0x43, 0x6d, 0x0c, 0xf7, 0x8f, 0x67, 0x64, 0x61, 0x3a, 0x2a, 0x9f,
|
||||
0x0a, 0x3a, 0x07, 0x5d, 0xab, 0xc8, 0xd0, 0x41, 0xae, 0x5f, 0x3a, 0x2a, 0xcf, 0x06, 0x23, 0x7a, 0x02, 0x5f, 0x1c,
|
||||
0xb8, 0x27, 0xdd, 0x14, 0x5c, 0x9f, 0x35, 0x16, 0x51, 0xc5, 0x37, 0xe5, 0xc3, 0xd1, 0x7f, 0x8c, 0xe3, 0x5f, 0x3d,
|
||||
0xe8, 0xfd, 0x1d, 0x08, 0x60, 0x56, 0x69, 0x0f, 0x3f, 0x97, 0x60, 0xb1, 0x3f, 0x06, 0xfc, 0xcb, 0xfd, 0xbb, 0xdf,
|
||||
0x88, 0xf2, 0xb4, 0x7f, 0xfd, 0x2d, 0x6e, 0xb7, 0x0a, 0x3e, 0x6a, 0x10, 0xff, 0x26, 0x57, 0x6e, 0x19, 0x5c, 0x7d,
|
||||
0xc2, 0x7e, 0x38, 0xc4, 0xfb, 0xf0, 0xb8, 0x11, 0x5c, 0x3b, 0xbf, 0xdc, 0xb5, 0xe2, 0xda, 0x45, 0x18, 0x2c, 0xe6,
|
||||
0xfe, 0xf1, 0xe1, 0xe8, 0x1f, 0xfd, 0xbc, 0x88, 0xc6, 0xb9, 0x5e, 0x16, 0xc3, 0x88, 0x2d, 0x7f, 0x38, 0x2c, 0xd5,
|
||||
0x2e, 0x30, 0xfc, 0x0b, 0x97, 0xab, 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0xae, 0xb9, 0xec, 0x7a, 0x7b,
|
||||
0xe8, 0x68, 0x55, 0x39, 0xca, 0xbc, 0xdb, 0xe5, 0xb5, 0x92, 0xd6, 0x71, 0x42, 0x96, 0x40, 0x7b, 0x1c, 0xe9, 0xc3,
|
||||
0x44, 0xc9, 0x5e, 0xcf, 0xbf, 0x3b, 0xba, 0x82, 0x3b, 0x58, 0xd8, 0xd9, 0x80, 0x0a, 0xbe, 0x92, 0x19, 0x03, 0x69,
|
||||
0x41, 0x8f, 0x42, 0x35, 0x6d, 0xb9, 0xd8, 0x67, 0x86, 0x4a, 0x13, 0x18, 0xd0, 0xbc, 0x3e, 0x2e, 0x7b, 0x6b, 0x95,
|
||||
0x3c, 0x2c, 0x95, 0xae, 0x40, 0x67, 0x71, 0x3e, 0x02, 0x81, 0xa6, 0x15, 0xef, 0x4d, 0x16, 0xce, 0x34, 0xb4, 0xf9,
|
||||
0x92, 0xb2, 0xf5, 0x4a, 0xab, 0x5e, 0x56, 0x01, 0x73, 0x93, 0x36, 0x7b, 0x9e, 0xd4, 0x74, 0x06, 0x2c, 0x9f, 0x4e,
|
||||
0x75, 0x5d, 0xe7, 0x82, 0x4b, 0x08, 0xc6, 0x59, 0x96, 0xa5, 0xe1, 0x8d, 0x13, 0xbb, 0x70, 0x33, 0x4c, 0x1d, 0x62,
|
||||
0xf4, 0x31, 0x89, 0xe3, 0xef, 0xf2, 0x53, 0x38, 0x71, 0xce, 0x7a, 0x6d, 0x94, 0xce, 0x3a, 0xc5, 0x9d, 0x9b, 0xc7,
|
||||
0x96, 0x72, 0x79, 0xe9, 0xbd, 0x2b, 0x93, 0x7c, 0x5a, 0x3f, 0x19, 0x97, 0x83, 0x99, 0x61, 0x09, 0xe5, 0x2d, 0x97,
|
||||
0xe3, 0x0e, 0xcd, 0xd2, 0x45, 0xdc, 0xed, 0x8e, 0xe1, 0x54, 0x20, 0x87, 0x13, 0x77, 0x2d, 0x60, 0x97, 0x7f, 0xee,
|
||||
0x8d, 0xe5, 0xf5, 0x3e, 0x98, 0x76, 0x70, 0x66, 0x3a, 0xca, 0x20, 0x58, 0x82, 0xdd, 0x02, 0xc8, 0x7c, 0xb0, 0x11,
|
||||
0x70, 0x0b, 0xad, 0x99, 0xf2, 0x74, 0x56, 0x33, 0x14, 0xe8, 0xd7, 0xba, 0xfe, 0x1b, 0xb7, 0xab, 0xc5, 0x43, 0x4b,
|
||||
0xf5, 0x8a, 0xcb, 0x60, 0xa9, 0xac, 0x55, 0x6d, 0x16, 0xbc, 0xea, 0x76, 0xf9, 0x84, 0x72, 0xca, 0xb2, 0xc4, 0xb9,
|
||||
0x39, 0xec, 0xd6, 0x53, 0xbe, 0x93, 0x6e, 0x87, 0x8c, 0x12, 0xbc, 0x9a, 0xf8, 0x06, 0x16, 0x14, 0x9f, 0xd3, 0x93,
|
||||
0xcc, 0xbb, 0x1d, 0x72, 0xb8, 0x53, 0xaa, 0x6f, 0xea, 0x5b, 0x9a, 0xc4, 0x7f, 0xf1, 0x45, 0xaa, 0xba, 0x4e, 0x97,
|
||||
0xf5, 0x39, 0x53, 0x6e, 0x4d, 0xba, 0xd6, 0x18, 0x4a, 0xab, 0x88, 0xc6, 0xdb, 0x8c, 0xab, 0x8c, 0xb2, 0x70, 0x19,
|
||||
0x2e, 0x8b, 0x26, 0x41, 0xbc, 0x22, 0x2d, 0x65, 0xe5, 0xc5, 0xf8, 0x2a, 0xa2, 0x26, 0x39, 0x91, 0x9a, 0xa4, 0xfc,
|
||||
0x6a, 0x18, 0x8d, 0xb4, 0xc1, 0xfb, 0xf2, 0xad, 0x92, 0x12, 0x98, 0xe5, 0x72, 0x85, 0xac, 0x42, 0x53, 0x0a, 0xc2,
|
||||
0x30, 0x2c, 0x96, 0xba, 0x7c, 0x2f, 0x80, 0x1a, 0x40, 0x5b, 0xca, 0x6d, 0x58, 0x44, 0x23, 0xff, 0xd8, 0xc7, 0xbc,
|
||||
0x22, 0x12, 0x6c, 0x39, 0x35, 0x6c, 0xd1, 0xcc, 0x46, 0x03, 0x77, 0x60, 0x9d, 0x26, 0x67, 0x60, 0x56, 0x16, 0x6e,
|
||||
0xe5, 0x22, 0x3a, 0x8c, 0x34, 0x12, 0x6d, 0x79, 0xcd, 0xdd, 0x95, 0xa5, 0x2c, 0x86, 0x22, 0x77, 0x1a, 0x5c, 0x9e,
|
||||
0xc7, 0xeb, 0xd5, 0x00, 0x09, 0x90, 0x2b, 0xdb, 0x90, 0x59, 0x8a, 0x3a, 0x41, 0x19, 0x34, 0x4a, 0x54, 0xa0, 0xc9,
|
||||
0xdd, 0xdd, 0xaf, 0x7f, 0x2f, 0x9d, 0x33, 0x8f, 0x72, 0x9d, 0x59, 0x8f, 0x62, 0x0e, 0x98, 0xa4, 0x16, 0x37, 0xe3,
|
||||
0xa5, 0xaa, 0xa3, 0xc6, 0x6c, 0x95, 0xae, 0xbe, 0xd2, 0xf1, 0x7e, 0x42, 0x8e, 0x7a, 0x86, 0xff, 0xd0, 0x2a, 0xe5,
|
||||
0x1d, 0xdd, 0x40, 0x11, 0x4d, 0x87, 0x22, 0x72, 0x0e, 0x8f, 0xf4, 0x66, 0xe2, 0x6b, 0x92, 0xf2, 0x8f, 0xfb, 0x37,
|
||||
0xe8, 0xcf, 0xae, 0xa2, 0x16, 0xc6, 0xb4, 0x0d, 0x51, 0xb5, 0x60, 0x1b, 0x55, 0x91, 0xf7, 0x7f, 0xdc, 0xdd, 0x9f,
|
||||
0x23, 0xec, 0x07, 0x26, 0x04, 0x92, 0x8d, 0xd7, 0xbb, 0x5e, 0x58, 0xde, 0x51, 0x6d, 0x07, 0xb5, 0x81, 0x9b, 0x22,
|
||||
0xa7, 0x18, 0x06, 0x7a, 0xcd, 0x05, 0x8c, 0x61, 0x8c, 0x82, 0x25, 0x3a, 0x79, 0x75, 0xb2, 0xf6, 0xc4, 0xaf, 0x68,
|
||||
0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00};
|
||||
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};
|
||||
|
||||
#else // Brotli (default, smaller)
|
||||
constexpr uint8_t INDEX_BR[] PROGMEM = {
|
||||
0x1b, 0xf8, 0x0a, 0x00, 0x64, 0x5a, 0xd3, 0xfa, 0xe7, 0xf3, 0x62, 0xd8, 0x06, 0x1b, 0xe9, 0x6a, 0x8a, 0x81, 0x2b,
|
||||
0xb5, 0x49, 0x14, 0x37, 0xdc, 0x9e, 0x1a, 0xcb, 0x56, 0x87, 0xfb, 0xff, 0xf7, 0x73, 0x75, 0x12, 0x0a, 0xd6, 0x48,
|
||||
0x84, 0xc6, 0x21, 0xa4, 0x6d, 0xb5, 0x71, 0xef, 0x13, 0xbe, 0x4e, 0x54, 0xf1, 0x64, 0x8f, 0x3f, 0xcc, 0x9a, 0x78,
|
||||
0xa5, 0x89, 0x25, 0xb3, 0xda, 0x2c, 0xa2, 0x32, 0x9c, 0x57, 0x07, 0x56, 0xbc, 0x34, 0x13, 0xff, 0x5c, 0x0a, 0xa1,
|
||||
0x67, 0x82, 0xb8, 0x6b, 0x4c, 0x76, 0x31, 0x6c, 0xe3, 0x40, 0x46, 0xea, 0xb0, 0xd4, 0xf4, 0x3b, 0x02, 0x65, 0x18,
|
||||
0xa4, 0xaf, 0xac, 0x6d, 0x55, 0xd6, 0xbe, 0x59, 0x66, 0x7a, 0x7c, 0x60, 0xb2, 0x83, 0x33, 0x23, 0xc9, 0x79, 0x82,
|
||||
0x47, 0xb4, 0x28, 0xf4, 0x24, 0xb5, 0x23, 0x5a, 0x44, 0xe1, 0xc3, 0x27, 0x04, 0xe8, 0x0c, 0xdd, 0xb4, 0xd0, 0x8c,
|
||||
0xfb, 0x10, 0x39, 0x93, 0x04, 0x2a, 0x66, 0x18, 0x4b, 0x74, 0xca, 0x31, 0x7f, 0xb2, 0xe5, 0x45, 0xc1, 0xdd, 0x72,
|
||||
0x49, 0xff, 0x0e, 0xb3, 0xf0, 0x93, 0x18, 0xab, 0x68, 0xad, 0xe1, 0x9d, 0xe4, 0x29, 0xc0, 0xe3, 0x63, 0x54, 0x61,
|
||||
0x1b, 0x45, 0xb9, 0x6c, 0x23, 0x0f, 0x99, 0x7f, 0x8e, 0x69, 0xaa, 0xc1, 0xb8, 0x4e, 0x42, 0x9c, 0xc5, 0x6e, 0x69,
|
||||
0x40, 0x0e, 0x4f, 0x97, 0xd3, 0x23, 0x18, 0xf5, 0xc8, 0x75, 0x73, 0xb5, 0xbd, 0x46, 0x8a, 0x97, 0x7d, 0x83, 0xe4,
|
||||
0x29, 0x72, 0x73, 0xc1, 0x39, 0x8e, 0x7e, 0x84, 0x39, 0x66, 0x57, 0xc6, 0x85, 0x19, 0x8b, 0xf2, 0x4d, 0xd9, 0xfe,
|
||||
0x75, 0xa9, 0xe1, 0x2b, 0x21, 0x81, 0x58, 0x51, 0x99, 0xbc, 0xa4, 0x0b, 0x10, 0x6f, 0x86, 0x17, 0x0b, 0x92, 0x00,
|
||||
0x11, 0x6f, 0x3b, 0xa4, 0xa4, 0x11, 0x7e, 0x0b, 0x97, 0x85, 0x23, 0x0c, 0x01, 0x6f, 0x2a, 0x18, 0xc6, 0xbe, 0x3d,
|
||||
0x77, 0x1a, 0xe6, 0x00, 0x5c, 0x1a, 0x14, 0x47, 0xc6, 0xcc, 0xcc, 0x52, 0xbe, 0x04, 0x19, 0x31, 0x05, 0x46, 0xa0,
|
||||
0xc3, 0x69, 0x0c, 0x60, 0xb7, 0x14, 0x57, 0xa0, 0x92, 0xbf, 0xb7, 0x0c, 0xd8, 0x3a, 0x79, 0x09, 0x99, 0xc9, 0x71,
|
||||
0x88, 0x01, 0x8b, 0xa5, 0x61, 0x0a, 0xb5, 0xe8, 0xc7, 0x71, 0xe7, 0x70, 0x79, 0xb6, 0xe4, 0x01, 0xfc, 0x1a, 0x4a,
|
||||
0x7b, 0x60, 0x6e, 0xef, 0x95, 0x62, 0x59, 0x28, 0xb5, 0x25, 0x56, 0x15, 0xe7, 0xca, 0xad, 0x32, 0xe6, 0xf7, 0x01,
|
||||
0x31, 0x34, 0x87, 0x93, 0x0b, 0x9b, 0x9d, 0x26, 0xff, 0xe5, 0x92, 0xad, 0x6f, 0xb8, 0x3b, 0x16, 0xc1, 0xa0, 0x5a,
|
||||
0x4f, 0x52, 0x0b, 0x2b, 0xc1, 0xa7, 0x95, 0x7b, 0x24, 0x51, 0xd3, 0xb3, 0x23, 0x62, 0x0b, 0xcc, 0xa0, 0x58, 0xa7,
|
||||
0x64, 0x45, 0x2f, 0x0b, 0xdd, 0x1d, 0x97, 0x82, 0x1f, 0xcc, 0x64, 0xdb, 0xd3, 0xf4, 0xb0, 0x8b, 0xc8, 0xcf, 0x15,
|
||||
0x81, 0x8b, 0xa1, 0x9d, 0xf8, 0xfc, 0xec, 0x49, 0x40, 0x12, 0x01, 0x09, 0x51, 0xf3, 0x73, 0x18, 0x24, 0x97, 0x55,
|
||||
0x85, 0x6a, 0x92, 0x1a, 0xf5, 0x5a, 0x05, 0x54, 0x1f, 0x27, 0x0a, 0xa8, 0xa1, 0x94, 0x58, 0x78, 0x7d, 0x87, 0xa8,
|
||||
0xdb, 0x13, 0x66, 0x20, 0x5e, 0x43, 0x18, 0x7a, 0xbb, 0x16, 0x16, 0x07, 0xc8, 0xab, 0x10, 0xe2, 0x50, 0xb9, 0xb1,
|
||||
0xd8, 0x21, 0xc8, 0x4a, 0x2e, 0x99, 0x0e, 0x23, 0x52, 0xc6, 0xcb, 0x29, 0x84, 0x91, 0x03, 0xb1, 0xe2, 0x4c, 0x1d,
|
||||
0x22, 0xd3, 0xc8, 0x79, 0x00, 0x8b, 0x8b, 0x88, 0x1e, 0x29, 0xd3, 0xae, 0x10, 0x15, 0x22, 0x6d, 0xb0, 0x87, 0x6f,
|
||||
0x27, 0x2e, 0x7c, 0xc2, 0x7a, 0x61, 0xbd, 0x22, 0xe5, 0x5f, 0xdd, 0x7b, 0x00, 0x04, 0xf2, 0x7d, 0x5a, 0x03, 0x38,
|
||||
0x1f, 0x69, 0x6d, 0x0b, 0xfb, 0xec, 0x45, 0xfe, 0x8b, 0x7f, 0xec, 0x7b, 0xad, 0xc2, 0x33, 0xf1, 0x9e, 0x9c, 0x71,
|
||||
0xd9, 0xe8, 0x5e, 0x8f, 0xd4, 0xee, 0x87, 0x45, 0x6c, 0xe2, 0x12, 0xf8, 0xb8, 0xc5, 0xee, 0x43, 0xa6, 0x37, 0x91,
|
||||
0xb5, 0x2c, 0x2f, 0xe9, 0xe8, 0x24, 0xd0, 0x45, 0xc1, 0x0c, 0x7c, 0xf0, 0xb2, 0xb5, 0x2d, 0x10, 0x36, 0x7e, 0x18,
|
||||
0x7c, 0x79, 0x82, 0x69, 0x3d, 0x35, 0xca, 0x52, 0xee, 0xc9, 0xb5, 0x65, 0xa4, 0xa1, 0xfd, 0x70, 0x7e, 0xe0, 0x7d,
|
||||
0x67, 0xf9, 0xa1, 0x71, 0xd2, 0x08, 0x74, 0x33, 0x5f, 0x69, 0xa4, 0x59, 0x03, 0xfd, 0xf8, 0xf0, 0x70, 0x1a, 0x50,
|
||||
0x43, 0xfb, 0x61, 0xf0, 0x38, 0x18, 0x88, 0x85, 0x36, 0x23, 0x06, 0x4f, 0x02, 0xbb, 0x78, 0x1a, 0xaa, 0xd2, 0x02,
|
||||
0x5e, 0xa0, 0x74, 0x30, 0xc8, 0x7a, 0x66, 0xab, 0xd9, 0x43, 0x99, 0x45, 0xb7, 0x0c, 0x5c, 0xec, 0xc8, 0x03, 0x0e,
|
||||
0x0b, 0xca, 0x4a, 0x22, 0x48, 0xfb, 0xb7, 0x3d, 0x82, 0x07, 0x8d, 0x1b, 0x21, 0x87, 0x4d, 0x57, 0xa4, 0x5b, 0xd4,
|
||||
0xe3, 0x88, 0x02, 0xc4, 0x81, 0xf9, 0x47, 0xe4, 0xbf, 0x3e, 0x39, 0xbb, 0x4f, 0x7e, 0x91, 0x63, 0x98, 0x97, 0xe4,
|
||||
0x52, 0x01, 0x58, 0xba, 0x32, 0xbf, 0xae, 0xff, 0x45, 0xa1, 0xbc, 0x9b, 0xa4, 0x09, 0x0e, 0x79, 0xc0, 0x41, 0x86,
|
||||
0x52, 0x88, 0x55, 0x39, 0x9d, 0xb6, 0xed, 0x35, 0x68, 0x29, 0xfa, 0xe6, 0x6c, 0x3d, 0x0a, 0xcd, 0x6a, 0x28, 0xfd,
|
||||
0x65, 0x24, 0xce, 0x38, 0x98, 0x01, 0xd9, 0x3f, 0x1b, 0x4c, 0xc4, 0x5c, 0x1d, 0xaa, 0x21, 0x78, 0x67, 0xaf, 0x55,
|
||||
0x72, 0x34, 0xf8, 0x1b, 0x03, 0x21, 0x27, 0x08, 0xbd, 0x59, 0x60, 0x48, 0x0d, 0xe2, 0x56, 0x9b, 0x30, 0x92, 0x8f,
|
||||
0x67, 0x8a, 0x7f, 0x20, 0xbd, 0x2d, 0xfd, 0xc5, 0xb0, 0xa6, 0xaa, 0x77, 0x75, 0x26, 0x33, 0x2f, 0x20, 0x2a, 0xab,
|
||||
0x5c, 0xd1, 0x3b, 0xda, 0xb2, 0x4c, 0xa4, 0x86, 0x25, 0x8d, 0x49, 0x05, 0xaf, 0x7a, 0xa8, 0xd4, 0x9c, 0x0d, 0xd3,
|
||||
0x38, 0xa6, 0x5c, 0x29, 0x6b, 0x16, 0x27, 0x07, 0xf1, 0xbe, 0xe2, 0x24, 0xc1, 0x8d, 0x25, 0x76, 0xbc, 0xf6, 0x0d,
|
||||
0xc2, 0x94, 0x25, 0xb8, 0xf3, 0x07, 0x9a, 0x49, 0xf4, 0x89, 0x82, 0x4d, 0x51, 0xb1, 0x96, 0x61, 0x62, 0x8d, 0xc8,
|
||||
0x61, 0x65, 0x0d, 0x14, 0x34, 0x02, 0x65, 0x94, 0xcc, 0x1d, 0x85, 0x00, 0x0f, 0x1a, 0x57, 0x68, 0x15, 0xcf, 0xa4,
|
||||
0xa2, 0x7d, 0x6d, 0x53, 0x60, 0xce, 0x5c, 0x61, 0x82, 0x17, 0x32, 0xc1, 0x87, 0x02, 0x0c, 0x91, 0x85, 0x57, 0x51,
|
||||
0xbe, 0xb2, 0x38, 0x9f, 0x3d, 0x2a, 0x52, 0x5a, 0xad, 0xba, 0x46, 0x9e, 0x3c, 0x8a, 0xa0, 0x46, 0x15, 0xf4, 0x59,
|
||||
0x74, 0x5f, 0x2a, 0xae, 0x96, 0x56, 0xf0, 0x54, 0x39, 0xaf, 0xac, 0x2a, 0xb9, 0xad, 0x32, 0x50, 0xc9, 0xc1, 0xee,
|
||||
0xd2, 0x0d, 0x34, 0xaa, 0x98, 0x4d, 0x6d, 0x3d, 0xc6, 0xb9, 0x5b, 0x00, 0x5f, 0xea, 0xda, 0x16, 0xa6, 0x08, 0x43,
|
||||
0x58, 0x4d, 0x8d, 0x07, 0x55, 0x62, 0x81, 0x44, 0xcc, 0x31, 0x04, 0x4b, 0x4c, 0x8b, 0x3e, 0xff, 0xd8, 0xf6, 0x65,
|
||||
0x19, 0xa1, 0x94, 0x62, 0x65, 0x0a, 0xdd, 0x60, 0x38, 0xd3, 0xbe, 0x0d, 0xa3, 0x99, 0xd5, 0x37, 0x68, 0xa1, 0x71,
|
||||
0xa3, 0x41, 0xe7, 0xbe, 0x9d, 0x72, 0x84, 0x75, 0xb6, 0x8d, 0x98, 0xd6, 0xb8, 0x2d, 0x43, 0x85, 0x5d, 0xf9, 0xca,
|
||||
0xc3, 0x96, 0xa5, 0xa6, 0xe7, 0x50, 0x88, 0x6b, 0x84, 0x58, 0x44, 0x45, 0x20, 0xdf, 0x1e, 0x5a, 0xc9, 0xce, 0x42,
|
||||
0x2a, 0x1f, 0x3e, 0x3c, 0x7b, 0x68, 0x3c, 0x34, 0x8b, 0x36, 0xba, 0x1f, 0xce, 0x0f, 0xa0, 0x60, 0x37, 0x5f, 0x1a,
|
||||
0x03, 0x2b, 0x86, 0x29, 0x45, 0x7b, 0xb4, 0xb7, 0x06, 0x68, 0x17, 0x7e, 0x13, 0x76, 0x91, 0x4d, 0x27, 0xee, 0xbc,
|
||||
0x7e, 0x80, 0xc2, 0x66, 0xac, 0xc6, 0xbf, 0xeb, 0x7f, 0xd7, 0x84, 0x79, 0xf3, 0xf1, 0xde, 0xec, 0xa6, 0x93, 0xa8,
|
||||
0x13, 0x3b, 0x4a, 0x81, 0xfa, 0x11, 0x1e, 0x4a, 0xd2, 0x50, 0x2a, 0xea, 0x9a, 0xc2, 0x37, 0x08, 0xed, 0x01, 0xf5,
|
||||
0xa2, 0xd5, 0x32, 0x29, 0x49, 0xc4, 0x1a, 0x11, 0xc0, 0xda, 0x24, 0x28, 0x84, 0x38, 0x60, 0x80, 0xcf, 0xd0, 0x45,
|
||||
0x83, 0xa7, 0xca, 0x52, 0x5c, 0xac, 0x23, 0x01};
|
||||
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};
|
||||
|
||||
// Backwards compatibility alias
|
||||
#define INDEX_GZ INDEX_BR
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#include "captive_index.h"
|
||||
#include "json_escape.h"
|
||||
|
||||
namespace esphome::captive_portal {
|
||||
|
||||
@@ -24,23 +25,30 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) {
|
||||
stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str());
|
||||
#endif
|
||||
|
||||
for (auto &scan : wifi::global_wifi_component->get_scan_result()) {
|
||||
if (scan.get_is_hidden())
|
||||
continue;
|
||||
// An SSID can contain a " or \ that would break the JSON, so escape it before writing it out. An SSID is at most
|
||||
// 32 bytes (IEEE 802.11), so this is large enough that nothing is ever dropped. Reused for every scan result.
|
||||
char escaped_ssid[32 * JSON_ESCAPE_MAX_EXPANSION + 1];
|
||||
{
|
||||
// Invariant: only bounded in-memory work under the lock; the network send
|
||||
// happens later in request->send()
|
||||
wifi::ScanResultsLock lock(wifi::global_wifi_component);
|
||||
for (const auto &scan : wifi::global_wifi_component->get_scan_result()) {
|
||||
if (scan.get_is_hidden())
|
||||
continue;
|
||||
|
||||
// Assumes no " in ssid, possible unicode isses?
|
||||
json_escape_into_buffer(escaped_ssid, scan.get_ssid());
|
||||
#ifdef USE_ESP8266
|
||||
stream->print(ESPHOME_F(",{\"ssid\":\""));
|
||||
stream->print(scan.get_ssid().c_str());
|
||||
stream->print(ESPHOME_F("\",\"rssi\":"));
|
||||
stream->print(scan.get_rssi());
|
||||
stream->print(ESPHOME_F(",\"lock\":"));
|
||||
stream->print(scan.get_with_auth());
|
||||
stream->print(ESPHOME_F("}"));
|
||||
stream->print(ESPHOME_F(",{\"ssid\":\""));
|
||||
stream->print(escaped_ssid);
|
||||
stream->print(ESPHOME_F("\",\"rssi\":"));
|
||||
stream->print(scan.get_rssi());
|
||||
stream->print(ESPHOME_F(",\"lock\":"));
|
||||
stream->print(scan.get_with_auth());
|
||||
stream->print(ESPHOME_F("}"));
|
||||
#else
|
||||
stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(),
|
||||
scan.get_with_auth());
|
||||
stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth());
|
||||
#endif
|
||||
}
|
||||
}
|
||||
stream->print(ESPHOME_F("]}"));
|
||||
request->send(stream);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::captive_portal {
|
||||
|
||||
/// Largest number of output bytes a single input byte can expand to (a \u00XX sequence).
|
||||
static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6;
|
||||
|
||||
/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal.
|
||||
///
|
||||
/// Escapes " and \ along with the control characters below 0x20, using the short forms where JSON defines one and
|
||||
/// \u00XX otherwise. Bytes >= 0x20 are copied verbatim, so text containing valid UTF-8 survives intact. The result is
|
||||
/// always null terminated; anything that would not fit is dropped rather than written partially. Returns buf so the
|
||||
/// call can be used directly as an argument.
|
||||
///
|
||||
/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for
|
||||
/// the null terminator.
|
||||
inline const char *json_escape_into_buffer(std::span<char> buf, StringRef value) {
|
||||
if (buf.empty())
|
||||
return "";
|
||||
// Reserve one byte for the null terminator.
|
||||
const size_t limit = buf.size() - 1;
|
||||
size_t pos = 0;
|
||||
for (char ch : value) {
|
||||
auto c = static_cast<unsigned char>(ch);
|
||||
// Every short form is a backslash followed by a single character, so only that character is needed here. Keeping
|
||||
// it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266.
|
||||
char escape = '\0';
|
||||
switch (c) {
|
||||
case '"':
|
||||
escape = '"';
|
||||
break;
|
||||
case '\\':
|
||||
escape = '\\';
|
||||
break;
|
||||
case '\n':
|
||||
escape = 'n';
|
||||
break;
|
||||
case '\r':
|
||||
escape = 'r';
|
||||
break;
|
||||
case '\t':
|
||||
escape = 't';
|
||||
break;
|
||||
case '\b':
|
||||
escape = 'b';
|
||||
break;
|
||||
case '\f':
|
||||
escape = 'f';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (escape != '\0') {
|
||||
if (pos + 2 > limit)
|
||||
break;
|
||||
buf[pos++] = '\\';
|
||||
buf[pos++] = escape;
|
||||
} else if (c < 0x20) {
|
||||
// Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so
|
||||
// the two high hex digits are always zero.
|
||||
if (pos + JSON_ESCAPE_MAX_EXPANSION > limit)
|
||||
break;
|
||||
buf[pos++] = '\\';
|
||||
buf[pos++] = 'u';
|
||||
buf[pos++] = '0';
|
||||
buf[pos++] = '0';
|
||||
buf[pos++] = format_hex_char(static_cast<uint8_t>(c >> 4));
|
||||
buf[pos++] = format_hex_char(static_cast<uint8_t>(c & 0x0F));
|
||||
} else {
|
||||
if (pos + 1 > limit)
|
||||
break;
|
||||
buf[pos++] = static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
buf[pos] = '\0';
|
||||
return buf.data();
|
||||
}
|
||||
|
||||
} // namespace esphome::captive_portal
|
||||
@@ -15,7 +15,7 @@ class EpaperModel:
|
||||
self,
|
||||
name: str,
|
||||
class_name: str,
|
||||
initsequence=None,
|
||||
initsequence=(),
|
||||
**defaults,
|
||||
):
|
||||
name = name.upper()
|
||||
|
||||
@@ -628,7 +628,6 @@ class NetworkSdkconfigData:
|
||||
wifi_ap: bool = False # WiFi AP mode configured
|
||||
ethernet: bool = False # Ethernet component active
|
||||
bluetooth: bool = False # any BLE component active
|
||||
ble_42: bool = False # BLE 4.2 features needed
|
||||
software_coexistence: bool = False # WiFi/BT software coexistence requested
|
||||
# esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset)
|
||||
enable_lwip_dhcp_server: bool | None = None
|
||||
@@ -654,12 +653,10 @@ def request_ethernet() -> None:
|
||||
_network_sdkconfig().ethernet = True
|
||||
|
||||
|
||||
def request_bluetooth(ble_42: bool = False) -> None:
|
||||
"""Request the Bluetooth controller. Pass ble_42=True for 4.2 features."""
|
||||
def request_bluetooth() -> None:
|
||||
"""Request the Bluetooth controller."""
|
||||
net = _network_sdkconfig()
|
||||
net.bluetooth = True
|
||||
if ble_42:
|
||||
net.ble_42 = True
|
||||
|
||||
|
||||
def request_software_coexistence() -> None:
|
||||
@@ -1029,7 +1026,9 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
|
||||
|
||||
|
||||
def _validate_toolchain(value) -> Toolchain:
|
||||
return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value))
|
||||
return Toolchain(
|
||||
cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_toolchain(value: ConfigType) -> ConfigType:
|
||||
@@ -2044,12 +2043,12 @@ async def _reconcile_network_sdkconfig() -> None:
|
||||
if name not in opts:
|
||||
add_idf_sdkconfig_option(name, value)
|
||||
|
||||
# Bluetooth: only ever enable when requested. The IDF default is off and
|
||||
# nothing sets these False today, so never write False here.
|
||||
# Bluetooth: only ever enable when requested. The IDF default is off.
|
||||
# According to the IDF docs, only one of 4.2 or 5.0 should be enabled.
|
||||
if net.bluetooth:
|
||||
set_opt("CONFIG_BT_ENABLED", True)
|
||||
if net.ble_42:
|
||||
set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
|
||||
set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
|
||||
set_opt("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", False)
|
||||
|
||||
# WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi
|
||||
# relies on the IDF default (enabled), so it is never written True here.
|
||||
|
||||
@@ -604,7 +604,7 @@ async def to_code(config):
|
||||
max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS)
|
||||
cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections)
|
||||
|
||||
request_bluetooth(ble_42=True)
|
||||
request_bluetooth()
|
||||
|
||||
# When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for
|
||||
# heap allocations and use dynamic (heap-based) environment memory tables
|
||||
|
||||
@@ -58,6 +58,7 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000;
|
||||
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: \
|
||||
case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: \
|
||||
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: \
|
||||
case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: \
|
||||
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: \
|
||||
case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ class BLEEvent {
|
||||
StatusOnlyData scan_complete; // 1 byte
|
||||
// Advertising complete events all have same structure
|
||||
// Used by: esp32_ble_beacon, esp32_ble server components
|
||||
// ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, ADV_START, ADV_STOP
|
||||
// ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, SCAN_RSP_DATA_RAW_SET, ADV_START, ADV_STOP
|
||||
StatusOnlyData adv_complete; // 1 byte
|
||||
// RSSI complete event
|
||||
// Used by: ble_client (ble_rssi_sensor component)
|
||||
@@ -324,6 +324,9 @@ class BLEEvent {
|
||||
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: // Used by: esp32_ble_beacon
|
||||
this->event_.gap.adv_complete.status = p->adv_data_raw_cmpl.status;
|
||||
break;
|
||||
case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: // Used by: raw advertisers with scan response
|
||||
this->event_.gap.adv_complete.status = p->scan_rsp_data_raw_cmpl.status;
|
||||
break;
|
||||
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: // Used by: esp32_ble_beacon
|
||||
this->event_.gap.adv_complete.status = p->adv_start_cmpl.status;
|
||||
break;
|
||||
|
||||
@@ -86,4 +86,4 @@ async def to_code(config):
|
||||
|
||||
cg.add_define("USE_ESP32_BLE_ADVERTISING")
|
||||
|
||||
request_bluetooth(ble_42=True)
|
||||
request_bluetooth()
|
||||
|
||||
@@ -433,37 +433,48 @@ GENERIC_SCHEMA = cv.All(
|
||||
cv.only_on([Platform.ESP32]),
|
||||
)
|
||||
|
||||
SPI_SCHEMA = cv.All(
|
||||
BASE_SCHEMA.extend(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number,
|
||||
cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number,
|
||||
cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.SplitDefault(CONF_CLOCK_SPEED, esp32="26.67MHz"): cv.All(
|
||||
cv.only_on_esp32,
|
||||
cv.frequency,
|
||||
cv.int_range(int(8e6), int(80e6)),
|
||||
),
|
||||
cv.Optional(CONF_INTERFACE): cv.All(
|
||||
cv.only_on_esp32,
|
||||
cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True),
|
||||
),
|
||||
# Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate()
|
||||
cv.Optional(CONF_POLLING_INTERVAL): cv.All(
|
||||
cv.only_on_esp32,
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=TimePeriodMilliseconds(milliseconds=1)),
|
||||
),
|
||||
}
|
||||
|
||||
def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)):
|
||||
return cv.All(
|
||||
BASE_SCHEMA.extend(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number,
|
||||
cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Optional(
|
||||
CONF_INTERRUPT_PIN
|
||||
): pins.internal_gpio_input_pin_number,
|
||||
cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.SplitDefault(CONF_CLOCK_SPEED, esp32=default_clock): cv.All(
|
||||
cv.only_on_esp32,
|
||||
cv.frequency,
|
||||
cv.int_range(int(8e6), max_clock),
|
||||
),
|
||||
cv.Optional(CONF_INTERFACE): cv.All(
|
||||
cv.only_on_esp32,
|
||||
cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True),
|
||||
),
|
||||
# Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate()
|
||||
cv.Optional(CONF_POLLING_INTERVAL): cv.All(
|
||||
cv.only_on_esp32,
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=TimePeriodMilliseconds(milliseconds=1)),
|
||||
),
|
||||
}
|
||||
),
|
||||
),
|
||||
),
|
||||
cv.only_on([Platform.ESP32, Platform.RP2]),
|
||||
_validate_spi_interface,
|
||||
)
|
||||
cv.only_on([Platform.ESP32, Platform.RP2]),
|
||||
_validate_spi_interface,
|
||||
)
|
||||
|
||||
|
||||
SPI_SCHEMA = _spi_schema()
|
||||
|
||||
# The ENC28J60's SCK maximum is 20 MHz, so the shared 26.67 MHz default is out
|
||||
# of spec for it and makes the driver's CS hold time helper compute no hold
|
||||
SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6))
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.typed_schema(
|
||||
@@ -479,7 +490,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
"W5500": SPI_SCHEMA,
|
||||
"OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])),
|
||||
"DM9051": SPI_SCHEMA,
|
||||
"ENC28J60": SPI_SCHEMA,
|
||||
"ENC28J60": SPI_SCHEMA_ENC28J60,
|
||||
"W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
|
||||
"W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
|
||||
"LAN8670": RMII_SCHEMA,
|
||||
|
||||
@@ -232,8 +232,10 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
dm9051_config.poll_period_ms = this->polling_interval_;
|
||||
#endif
|
||||
#elif defined(USE_ETHERNET_ENC28J60)
|
||||
// ENC28J60 does not support poll_period_ms. CS must stay asserted for the chip's CS hold
|
||||
// time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID")
|
||||
enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000);
|
||||
enc28j60_config.int_gpio_num = this->interrupt_pin_;
|
||||
// ENC28J60 does not support poll_period_ms
|
||||
#endif
|
||||
|
||||
phy_config.phy_addr = this->phy_addr_spi_;
|
||||
|
||||
@@ -219,6 +219,18 @@ LightColorValues LightCall::validate_() {
|
||||
this->set_flag_(FLAG_HAS_STATE);
|
||||
}
|
||||
|
||||
// A light without brightness control has no way to represent "on but dark", so zero
|
||||
// brightness -- how effects encode their dark phase -- means the light is off. Clear the
|
||||
// brightness as well, so a zero can't linger in remote_values and leave the light stuck
|
||||
// off: a later turn-on can't heal it, because the capability check below drops any
|
||||
// brightness this mode doesn't support. explicit_turn_off_request was captured above, so
|
||||
// a running effect is not stopped by this.
|
||||
if (this->has_brightness() && this->brightness_ == 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) {
|
||||
this->state_ = false;
|
||||
this->set_flag_(FLAG_HAS_STATE);
|
||||
this->clear_flag_(FLAG_HAS_BRIGHTNESS);
|
||||
}
|
||||
|
||||
// Make sure a simple (no specific brightness) turn-on makes the light visible
|
||||
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() &&
|
||||
this->parent_->remote_values.get_brightness() == 0.0f) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from collections import UserDict
|
||||
from collections.abc import Callable
|
||||
from functools import reduce
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -35,6 +36,8 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = CONF_PACKAGES
|
||||
# Guard against infinite include chains (e.g. A includes B includes A).
|
||||
MAX_INCLUDE_DEPTH = 20
|
||||
@@ -267,8 +270,23 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
|
||||
# If loading fails, the cached checkout may be stale — revert and retry once.
|
||||
try:
|
||||
return {CONF_PACKAGES: get_packages(files)}
|
||||
except cv.Invalid:
|
||||
revert()
|
||||
except cv.Invalid as err:
|
||||
if not revert():
|
||||
# The pre-update content is out of reach (lock timeout, the
|
||||
# checkout moved, or the reset failed; see the log), so a
|
||||
# retry could not see it.
|
||||
raise cv.Invalid(
|
||||
f"Failed to load packages and could not revert the cached "
|
||||
f"checkout to retry. {err}",
|
||||
path=err.path,
|
||||
) from err
|
||||
# If the retry succeeds this is the only trace that the
|
||||
# refreshed upstream content was broken.
|
||||
_LOGGER.warning(
|
||||
"Loading packages failed (%s), reverted the cached checkout "
|
||||
"and retrying",
|
||||
err,
|
||||
)
|
||||
try:
|
||||
return {CONF_PACKAGES: get_packages(files)}
|
||||
except cv.Invalid as err:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -818,6 +818,7 @@ IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners"
|
||||
SCAN_RESULTS_LISTENERS_KEY = "wifi_scan_results_listeners"
|
||||
CONNECT_STATE_LISTENERS_KEY = "wifi_connect_state_listeners"
|
||||
POWER_SAVE_LISTENERS_KEY = "wifi_power_save_listeners"
|
||||
SCAN_RESULTS_LOCK_KEY = "wifi_scan_results_lock"
|
||||
|
||||
|
||||
def request_wifi_scan_results():
|
||||
@@ -830,6 +831,19 @@ def request_wifi_scan_results():
|
||||
CORE.data[KEEP_SCAN_RESULTS_KEY] = True
|
||||
|
||||
|
||||
def request_wifi_scan_results_lock() -> None:
|
||||
"""Request that scan results be guarded by a lock for cross-task readers.
|
||||
|
||||
Components that read WiFi scan results from a task other than the main loop
|
||||
(for example a web server handler) must call this function during their code
|
||||
generation, and their C++ code must hold a wifi::ScanResultsLock while
|
||||
iterating get_scan_result(). On multi-threaded platforms this compiles in a
|
||||
lock that scan result writers hold; on single-threaded platforms it compiles
|
||||
to nothing.
|
||||
"""
|
||||
CORE.data[SCAN_RESULTS_LOCK_KEY] = True
|
||||
|
||||
|
||||
def enable_runtime_power_save_control():
|
||||
"""Enable runtime WiFi power save control.
|
||||
|
||||
@@ -891,6 +905,8 @@ async def final_step():
|
||||
cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE")
|
||||
if CORE.data.get(RUNTIME_ROAMING_SUPPRESSION_KEY, False):
|
||||
cg.add_define("USE_WIFI_RUNTIME_ROAMING_SUPPRESSION")
|
||||
if CORE.data.get(SCAN_RESULTS_LOCK_KEY):
|
||||
cg.add_define("USE_WIFI_SCAN_RESULTS_LOCK")
|
||||
|
||||
# Generate listener defines - each listener type has its own #ifdef
|
||||
ip_state_count = CORE.data.get(IP_STATE_LISTENERS_KEY, 0)
|
||||
|
||||
@@ -1483,23 +1483,26 @@ void WiFiComponent::check_scanning_finished() {
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Found networks:");
|
||||
for (auto &res : this->scan_result_) {
|
||||
for (auto &ap : this->sta_) {
|
||||
if (res.matches(ap)) {
|
||||
res.set_matches(true);
|
||||
// Cache priority lookup - do single search instead of 2 separate searches
|
||||
const bssid_t &bssid = res.get_bssid();
|
||||
if (!this->has_sta_priority(bssid)) {
|
||||
this->set_sta_priority(bssid, ap.get_priority());
|
||||
{
|
||||
ScanResultsLock lock(this);
|
||||
for (auto &res : this->scan_result_) {
|
||||
for (auto &ap : this->sta_) {
|
||||
if (res.matches(ap)) {
|
||||
res.set_matches(true);
|
||||
// Cache priority lookup - do single search instead of 2 separate searches
|
||||
const bssid_t &bssid = res.get_bssid();
|
||||
if (!this->has_sta_priority(bssid)) {
|
||||
this->set_sta_priority(bssid, ap.get_priority());
|
||||
}
|
||||
res.set_priority(this->get_sta_priority(bssid));
|
||||
break;
|
||||
}
|
||||
res.set_priority(this->get_sta_priority(bssid));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort scan results using insertion sort for better memory efficiency
|
||||
insertion_sort_scan_results(this->scan_result_);
|
||||
// Sort scan results using insertion sort for better memory efficiency
|
||||
insertion_sort_scan_results(this->scan_result_);
|
||||
}
|
||||
|
||||
// Log matching networks (non-matching already logged at VERBOSE in scan callback)
|
||||
for (auto &res : this->scan_result_) {
|
||||
@@ -1885,11 +1888,13 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
|
||||
// Phase-specific setup
|
||||
switch (new_phase) {
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
|
||||
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: {
|
||||
// Move to next configured AP - clear old scan data so new AP is tried with config only
|
||||
this->selected_sta_index_++;
|
||||
ScanResultsLock lock(this);
|
||||
this->scan_result_.clear();
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
|
||||
case WiFiRetryPhase::EXPLICIT_HIDDEN:
|
||||
@@ -2404,6 +2409,7 @@ void WiFiComponent::clear_roaming_state_() {
|
||||
|
||||
void WiFiComponent::release_scan_results_() {
|
||||
if (!this->keep_scan_results_) {
|
||||
ScanResultsLock lock(this);
|
||||
#if defined(USE_RP2) || defined(USE_ESP32)
|
||||
// std::vector - use swap trick since shrink_to_fit is non-binding
|
||||
decltype(this->scan_result_)().swap(this->scan_result_);
|
||||
|
||||
@@ -187,6 +187,13 @@ template<typename T> using wifi_scan_vector_t = std::vector<T>;
|
||||
template<typename T> using wifi_scan_vector_t = FixedVector<T>;
|
||||
#endif
|
||||
|
||||
// A consumer component (e.g. the captive portal) reads scan results from another
|
||||
// task; guard them with a real lock only on platforms that actually run multiple
|
||||
// threads. See ScanResultsLock below the WiFiComponent class.
|
||||
#if defined(USE_WIFI_SCAN_RESULTS_LOCK) && !defined(ESPHOME_THREAD_SINGLE)
|
||||
#define WIFI_SCAN_RESULTS_LOCK_ENABLED
|
||||
#endif
|
||||
|
||||
/// 20-byte string: 18 chars inline + null, heap for longer. Always null-terminated.
|
||||
/// Used internally for WiFi SSID/password storage to reduce heap fragmentation.
|
||||
class CompactString {
|
||||
@@ -506,6 +513,9 @@ class WiFiComponent final : public Component {
|
||||
const char *get_use_address() const { return this->use_address_; }
|
||||
void set_use_address(const char *use_address) { this->use_address_ = use_address; }
|
||||
|
||||
/// Main-loop callers may read this directly. Callers on any other task must
|
||||
/// hold a ScanResultsLock for the whole iteration and must call
|
||||
/// wifi.request_wifi_scan_results_lock() from their code generation.
|
||||
const wifi_scan_vector_t<WiFiScanResult> &get_scan_result() const { return scan_result_; }
|
||||
|
||||
network::IPAddress wifi_soft_ap_ip();
|
||||
@@ -817,6 +827,8 @@ class WiFiComponent final : public Component {
|
||||
friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data);
|
||||
#endif
|
||||
|
||||
friend class ScanResultsLock;
|
||||
|
||||
#ifdef USE_RP2
|
||||
static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result);
|
||||
void wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result);
|
||||
@@ -831,7 +843,11 @@ class WiFiComponent final : public Component {
|
||||
// Large/pointer-aligned members first
|
||||
FixedVector<WiFiAP> sta_;
|
||||
std::vector<WiFiSTAPriority> sta_priorities_;
|
||||
// Guarded by ScanResultsLock (see below this class)
|
||||
wifi_scan_vector_t<WiFiScanResult> scan_result_;
|
||||
#ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED
|
||||
Mutex scan_result_lock_;
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP
|
||||
WiFiAP ap_;
|
||||
#endif
|
||||
@@ -1003,5 +1019,25 @@ class WiFiComponent final : public Component {
|
||||
|
||||
extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
/// Guards WiFiComponent::scan_result_. Invariant: every mutation and every read
|
||||
/// from outside the main loop holds this lock, and holders only do bounded work
|
||||
/// (never unbounded waits or network sends). On every platform where the lock is
|
||||
/// enabled (ESP32, LibreTiny) scan-done events are drained from the event queue
|
||||
/// on the main loop, so all writers are main-loop there and main-loop reads take
|
||||
/// no lock. Single-threaded platforms write from driver context and the lock is
|
||||
/// a no-op. Compiles to nothing unless a cross-task reader is in the build and
|
||||
/// the platform is multi-threaded (WIFI_SCAN_RESULTS_LOCK_ENABLED).
|
||||
class ScanResultsLock {
|
||||
public:
|
||||
#ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED
|
||||
ScanResultsLock(WiFiComponent *parent) : guard_(parent->scan_result_lock_) {}
|
||||
|
||||
private:
|
||||
LockGuard guard_;
|
||||
#else
|
||||
ScanResultsLock(WiFiComponent *) {}
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::wifi
|
||||
#endif
|
||||
|
||||
@@ -733,6 +733,8 @@ void WiFiComponent::s_wifi_scan_done_callback(void *arg, STATUS status) {
|
||||
}
|
||||
|
||||
void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
|
||||
// Compiles to nothing here; kept so every scan_result_ mutation holds the lock
|
||||
ScanResultsLock lock(this);
|
||||
this->scan_result_.clear();
|
||||
|
||||
if (status != OK) {
|
||||
|
||||
@@ -891,65 +891,65 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
|
||||
const auto &it = data->data.sta_scan_done;
|
||||
ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id);
|
||||
|
||||
scan_result_.clear();
|
||||
this->scan_done_ = true;
|
||||
if (it.status != 0) {
|
||||
// scan error
|
||||
return;
|
||||
}
|
||||
|
||||
if (it.number == 0) {
|
||||
// no results
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t number = it.number;
|
||||
bool needs_full = this->needs_full_scan_results_();
|
||||
{
|
||||
// Mutate in place under the lock; blocking a portal request is fine and
|
||||
// avoids scratch buffers
|
||||
ScanResultsLock lock(this);
|
||||
this->scan_result_.clear();
|
||||
this->scan_done_ = true;
|
||||
if (it.status != 0) {
|
||||
// scan error
|
||||
return;
|
||||
}
|
||||
|
||||
// Smart reserve: full capacity if needed, small reserve otherwise
|
||||
if (needs_full) {
|
||||
this->scan_result_.reserve(number);
|
||||
} else {
|
||||
this->scan_result_.reserve(WIFI_SCAN_RESULT_FILTERED_RESERVE);
|
||||
}
|
||||
if (number == 0) {
|
||||
// no results
|
||||
return;
|
||||
}
|
||||
|
||||
// Smart reserve: full capacity if needed, small reserve otherwise
|
||||
this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE);
|
||||
|
||||
#ifdef USE_ESP32_HOSTED
|
||||
// getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor
|
||||
// Presumably an upstream bug, work-around by getting all records at once
|
||||
// Use stack buffer (3904 bytes / ~80 bytes per record = ~48 records) with heap fallback
|
||||
static constexpr size_t SCAN_RECORD_STACK_COUNT = 3904 / sizeof(wifi_ap_record_t);
|
||||
SmallBufferWithHeapFallback<SCAN_RECORD_STACK_COUNT, wifi_ap_record_t> records(number);
|
||||
err = esp_wifi_scan_get_ap_records(&number, records.get());
|
||||
if (err != ESP_OK) {
|
||||
esp_wifi_clear_ap_list();
|
||||
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
for (uint16_t i = 0; i < number; i++) {
|
||||
wifi_ap_record_t &record = records.get()[i];
|
||||
#else
|
||||
// Process one record at a time to avoid large buffer allocation
|
||||
for (uint16_t i = 0; i < number; i++) {
|
||||
wifi_ap_record_t record;
|
||||
err = esp_wifi_scan_get_ap_record(&record);
|
||||
// getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor
|
||||
// Presumably an upstream bug, work-around by getting all records at once
|
||||
// Use stack buffer (3904 bytes / ~80 bytes per record = ~48 records) with heap fallback
|
||||
static constexpr size_t SCAN_RECORD_STACK_COUNT = 3904 / sizeof(wifi_ap_record_t);
|
||||
SmallBufferWithHeapFallback<SCAN_RECORD_STACK_COUNT, wifi_ap_record_t> records(number);
|
||||
err = esp_wifi_scan_get_ap_records(&number, records.get());
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err));
|
||||
esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved
|
||||
break;
|
||||
esp_wifi_clear_ap_list();
|
||||
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
for (uint16_t i = 0; i < number; i++) {
|
||||
wifi_ap_record_t &record = records.get()[i];
|
||||
#else
|
||||
// Process one record at a time to avoid large buffer allocation
|
||||
for (uint16_t i = 0; i < number; i++) {
|
||||
wifi_ap_record_t record;
|
||||
err = esp_wifi_scan_get_ap_record(&record);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err));
|
||||
esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved
|
||||
break;
|
||||
}
|
||||
#endif // USE_ESP32_HOSTED
|
||||
|
||||
// Check C string first - avoid std::string construction for non-matching networks
|
||||
const char *ssid_cstr = reinterpret_cast<const char *>(record.ssid);
|
||||
// Check C string first - avoid std::string construction for non-matching networks
|
||||
const char *ssid_cstr = reinterpret_cast<const char *>(record.ssid);
|
||||
|
||||
// Only construct std::string and store if needed
|
||||
if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) {
|
||||
bssid_t bssid;
|
||||
std::copy(record.bssid, record.bssid + 6, bssid.begin());
|
||||
this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi,
|
||||
record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0');
|
||||
} else {
|
||||
this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary);
|
||||
// Only construct std::string and store if needed
|
||||
if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) {
|
||||
bssid_t bssid;
|
||||
std::copy(record.bssid, record.bssid + 6, bssid.begin());
|
||||
this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi,
|
||||
record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0');
|
||||
} else {
|
||||
this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(),
|
||||
|
||||
@@ -657,44 +657,48 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
return true;
|
||||
}
|
||||
void WiFiComponent::wifi_scan_done_callback_() {
|
||||
this->scan_result_.clear();
|
||||
this->scan_done_ = true;
|
||||
|
||||
int16_t num = WiFi.scanComplete();
|
||||
if (num < 0)
|
||||
return;
|
||||
|
||||
bool needs_full = this->needs_full_scan_results_();
|
||||
{
|
||||
// Mutate in place under the lock; blocking a portal request is fine and
|
||||
// avoids scratch buffers
|
||||
ScanResultsLock lock(this);
|
||||
this->scan_result_.clear();
|
||||
this->scan_done_ = true;
|
||||
|
||||
// Access scan results directly via WiFi.scan struct to avoid Arduino String allocations
|
||||
// WiFi.scan is public in LibreTiny for WiFiEvents & WiFiScan static handlers
|
||||
auto *scan = WiFi.scan;
|
||||
if (num < 0)
|
||||
return;
|
||||
|
||||
// First pass: count matching networks
|
||||
size_t count = 0;
|
||||
for (int i = 0; i < num; i++) {
|
||||
const char *ssid_cstr = scan->ap[i].ssid;
|
||||
if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) {
|
||||
count++;
|
||||
// Access scan results directly via WiFi.scan struct to avoid Arduino String allocations
|
||||
// WiFi.scan is public in LibreTiny for WiFiEvents & WiFiScan static handlers
|
||||
auto *scan = WiFi.scan;
|
||||
|
||||
// First pass: count matching networks
|
||||
size_t count = 0;
|
||||
for (int i = 0; i < num; i++) {
|
||||
const char *ssid_cstr = scan->ap[i].ssid;
|
||||
if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
this->scan_result_.init(count); // Exact allocation
|
||||
|
||||
// Second pass: store matching networks
|
||||
for (int i = 0; i < num; i++) {
|
||||
const char *ssid_cstr = scan->ap[i].ssid;
|
||||
auto &ap = scan->ap[i];
|
||||
if (needs_full || this->matches_configured_network_(ssid_cstr, ap.bssid.addr)) {
|
||||
this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3],
|
||||
ap.bssid.addr[4], ap.bssid.addr[5]},
|
||||
ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN,
|
||||
ssid_cstr[0] == '\0');
|
||||
} else {
|
||||
this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this->scan_result_.init(count); // Exact allocation
|
||||
|
||||
// Second pass: store matching networks
|
||||
for (int i = 0; i < num; i++) {
|
||||
const char *ssid_cstr = scan->ap[i].ssid;
|
||||
if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) {
|
||||
auto &ap = scan->ap[i];
|
||||
this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3],
|
||||
ap.bssid.addr[4], ap.bssid.addr[5]},
|
||||
ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN,
|
||||
ssid_cstr[0] == '\0');
|
||||
} else {
|
||||
auto &ap = scan->ap[i];
|
||||
this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel);
|
||||
}
|
||||
}
|
||||
ESP_LOGV(TAG, "Scan complete: %d found, %zu stored%s", num, this->scan_result_.size(),
|
||||
needs_full ? "" : " (filtered)");
|
||||
WiFi.scanDelete();
|
||||
|
||||
@@ -193,12 +193,16 @@ void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *re
|
||||
std::copy(result->bssid, result->bssid + 6, bssid.begin());
|
||||
WiFiScanResult res(bssid, ssid_buf, len, result->channel, result->rssi, result->auth_mode != CYW43_AUTH_OPEN,
|
||||
len == 0);
|
||||
// Compiles to nothing here; kept so every scan_result_ mutation holds the lock
|
||||
ScanResultsLock lock(this);
|
||||
if (std::find(this->scan_result_.begin(), this->scan_result_.end(), res) == this->scan_result_.end()) {
|
||||
this->scan_result_.push_back(res);
|
||||
}
|
||||
}
|
||||
|
||||
bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
// Compiles to nothing here; kept so every scan_result_ mutation holds the lock
|
||||
ScanResultsLock lock(this);
|
||||
this->scan_result_.clear();
|
||||
this->scan_done_ = false;
|
||||
s_scan_result_count = 0;
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.7.2"
|
||||
__version__ = "2026.7.4"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
|
||||
#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16
|
||||
#define USE_CAPTIVE_PORTAL
|
||||
#define USE_WIFI_SCAN_RESULTS_LOCK
|
||||
#define USE_ESP32_BLE
|
||||
#define USE_ESP32_BLE_MAX_CONNECTIONS 3
|
||||
#define USE_ESP32_BLE_CLIENT
|
||||
@@ -387,6 +388,7 @@
|
||||
#define USE_ESP8266_CRASH_HANDLER
|
||||
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 1, 2)
|
||||
#define USE_CAPTIVE_PORTAL
|
||||
#define USE_WIFI_SCAN_RESULTS_LOCK
|
||||
#define USE_ESP8266_LOGGER_SERIAL
|
||||
#define USE_ESP8266_LOGGER_SERIAL1
|
||||
#define USE_ESP8266_PREFERENCES_FLASH
|
||||
@@ -436,6 +438,7 @@
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
#define USE_CAPTIVE_PORTAL
|
||||
#define USE_WIFI_SCAN_RESULTS_LOCK
|
||||
#define USE_SOCKET_IMPL_LWIP_SOCKETS
|
||||
#define USE_LWIP_FAST_SELECT
|
||||
#define USE_WEBSERVER
|
||||
|
||||
@@ -92,6 +92,14 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
|
||||
# In CMakeLists.txt, backslashes need to be escaped
|
||||
return f'"{str(p)}"'.replace("\\", "\\\\")
|
||||
|
||||
def escape_path(p: PathType) -> str:
|
||||
# CMake uses forward slashes for paths on every platform and treats
|
||||
# backslashes as escape characters. On Windows os.path.relpath yields
|
||||
# backslash paths, which break CMake's list re-parsing (e.g. "\b" in
|
||||
# "src\backend" is an invalid character escape). Emit forward slashes,
|
||||
# which Windows accepts too, so the generated CMakeLists is portable.
|
||||
return f'"{str(p).replace(os.sep, "/")}"'
|
||||
|
||||
# Extract the values
|
||||
build_src_dir = component.data.get("build", {}).get("srcDir", None)
|
||||
if not build_src_dir:
|
||||
@@ -173,10 +181,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
|
||||
# Generate the component
|
||||
content = "idf_component_register(\n"
|
||||
if build_src_files:
|
||||
str_srcs = " ".join([escape_entry(p) for p in sorted(build_src_files)])
|
||||
str_srcs = " ".join([escape_path(p) for p in sorted(build_src_files)])
|
||||
content += f" SRCS {str_srcs}\n"
|
||||
if build_include_dirs:
|
||||
str_include_dirs = " ".join([escape_entry(p) for p in build_include_dirs])
|
||||
str_include_dirs = " ".join([escape_path(p) for p in build_include_dirs])
|
||||
content += f" INCLUDE_DIRS {str_include_dirs}\n"
|
||||
# Project-managed and built-in component lists are set per-project
|
||||
# via idf_build_set_property in the top-level CMakeLists; expanded
|
||||
@@ -211,7 +219,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
|
||||
if link_directories:
|
||||
content += "target_link_directories(${COMPONENT_LIB} INTERFACE\n"
|
||||
for link_directory in link_directories:
|
||||
str_build_flag = escape_entry(link_directory)
|
||||
str_build_flag = escape_path(link_directory)
|
||||
content += f" {str_build_flag}\n"
|
||||
content += ")\n"
|
||||
|
||||
|
||||
@@ -397,9 +397,10 @@ def _clone_idf_with_submodules(
|
||||
handles branches, tags, and SHAs uniformly (mirrors the approach in
|
||||
``esphome.git.clone_or_update``).
|
||||
"""
|
||||
from esphome.git import run_git_command
|
||||
from esphome.git import run_git_command, update_submodules
|
||||
|
||||
_LOGGER.info("Cloning ESP-IDF from %s%s", git_url, f"@{ref}" if ref else "")
|
||||
key = f"{git_url}@{ref}" if ref else git_url
|
||||
_LOGGER.info("Cloning ESP-IDF from %s", key)
|
||||
run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)])
|
||||
if ref:
|
||||
run_git_command(
|
||||
@@ -410,25 +411,14 @@ def _clone_idf_with_submodules(
|
||||
["git", "reset", "--hard", "FETCH_HEAD"],
|
||||
git_dir=framework_path,
|
||||
)
|
||||
run_git_command(
|
||||
[
|
||||
"git",
|
||||
"submodule",
|
||||
"update",
|
||||
"--init",
|
||||
"--recursive",
|
||||
"--depth=1",
|
||||
],
|
||||
git_dir=framework_path,
|
||||
)
|
||||
update_submodules(framework_path, key)
|
||||
|
||||
# Sanity-check the resulting tree. run_git_command only raises when
|
||||
# stderr is non-empty, so a clone that silently produces no working
|
||||
# tree would otherwise be marked extracted and stuck until
|
||||
# ``esphome clean``.
|
||||
# Sanity-check the resulting tree: a clone can exit 0 yet produce no
|
||||
# usable ESP-IDF checkout, which would otherwise be marked extracted and
|
||||
# stuck until ``esphome clean``.
|
||||
if not (framework_path / "tools" / "idf_tools.py").is_file():
|
||||
raise RuntimeError(
|
||||
f"Clone of {git_url} produced no usable ESP-IDF tree at {framework_path}"
|
||||
f"Clone of {key} produced no usable ESP-IDF tree at {framework_path}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ import shutil
|
||||
import subprocess
|
||||
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION
|
||||
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_ESPHOME,
|
||||
CONF_FRAMEWORK,
|
||||
CONF_SOURCE,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
|
||||
from esphome.espidf.size_summary import print_summary
|
||||
@@ -147,7 +152,10 @@ def _get_idf_tool(name: str) -> str:
|
||||
|
||||
|
||||
def run_idf_py(
|
||||
*args, cwd: Path | None = None, capture_output: bool = False
|
||||
*args,
|
||||
cwd: Path | None = None,
|
||||
capture_output: bool = False,
|
||||
jobs: int | None = None,
|
||||
) -> int | str:
|
||||
"""Run idf.py with the given arguments."""
|
||||
idf_path = _get_idf_path()
|
||||
@@ -155,6 +163,8 @@ def run_idf_py(
|
||||
raise EsphomeError("ESP-IDF not found")
|
||||
|
||||
env = _get_idf_env()
|
||||
if jobs is not None:
|
||||
env = {**env, "IDF_PY_BUILD_JOBS": str(jobs)}
|
||||
python_executable = _get_idf_tool("python")
|
||||
idf_py = idf_path / "tools" / "idf.py"
|
||||
# Dispatch idf.py through esphome.espidf.runner, which wraps
|
||||
@@ -384,7 +394,7 @@ def run_compile(config, verbose: bool) -> int:
|
||||
args.append("build")
|
||||
args.append("size")
|
||||
|
||||
rc = run_idf_py(*args)
|
||||
rc = run_idf_py(*args, jobs=config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT))
|
||||
if rc == 0:
|
||||
size_json = CORE.relative_build_path("build", "esp_idf_size.json")
|
||||
partitions = CORE.relative_build_path("partitions.csv")
|
||||
|
||||
+509
-101
@@ -1,31 +1,72 @@
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
import errno
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
import urllib.parse
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE, EsphomeError, TimePeriodSeconds
|
||||
from esphome.helpers import rmtree, write_file
|
||||
from esphome.helpers import add_git_ceiling_directory, rmtree, write_file
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from filelock import FileLock
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Special value to indicate never refresh
|
||||
NEVER_REFRESH = TimePeriodSeconds(seconds=-1)
|
||||
|
||||
# Written inside .git only after every clone step (clone, ref fetch, reset,
|
||||
# submodule init) has completed. A directory without it is an interrupted
|
||||
# clone (e.g. the process was killed mid-clone) and must be re-cloned; without
|
||||
# this check such a directory would be trusted forever when the caller uses
|
||||
# NEVER_REFRESH. Lives in .git so stash/reset/checkout can never touch it and
|
||||
# it does not pollute the worktree.
|
||||
# revert() runs on an already-failing path; bound its wait for the cache
|
||||
# entry lock so that recovery cannot hang forever behind another process.
|
||||
_REVERT_LOCK_TIMEOUT_SECONDS = 60
|
||||
|
||||
# When a complete cache entry already exists, a caller does not wait forever
|
||||
# behind another process's stalled clone or update (git sets no network
|
||||
# timeouts): after this bound it uses the existing clone without refreshing
|
||||
# it. With no complete entry there is nothing to fall back to, so the wait
|
||||
# is unbounded.
|
||||
_COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS = 60
|
||||
|
||||
# Written inside .git only while the entry is a complete, quiescent
|
||||
# checkout: after every clone step (clone, ref fetch, reset, submodule init)
|
||||
# has finished, and removed for the duration of a refresh's rewrite
|
||||
# (stash/fetch/reset). A directory without it is an interrupted clone or
|
||||
# update (e.g. the process was killed mid-clone) and must be re-cloned;
|
||||
# without this check such a directory would be trusted forever when the
|
||||
# caller uses NEVER_REFRESH, and the bounded-wait fallback would hand a
|
||||
# mid-rewrite tree to a timed-out peer. Lives in .git so
|
||||
# stash/reset/checkout can never touch it and it does not pollute the
|
||||
# worktree.
|
||||
_CLONE_COMPLETE_MARKER = "esphome_clone_complete"
|
||||
|
||||
# Environment variables that scope git to a specific repository. Git hooks and
|
||||
# some CI wrappers export these; if they leak into the git commands run here,
|
||||
# git binds to the caller's repository instead of the one being managed. The
|
||||
# effects range from loud (`git clone` producing a bare-style directory with
|
||||
# no working tree) to silent (an ambient GIT_INDEX_FILE makes
|
||||
# `git submodule update --init` exit 0 without initializing anything).
|
||||
_GIT_REPO_SCOPING_ENV = frozenset(
|
||||
{
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_COMMON_DIR",
|
||||
"GIT_NAMESPACE",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GitException(cv.Invalid):
|
||||
"""Base exception for git-related errors."""
|
||||
@@ -43,32 +84,61 @@ class GitRepositoryError(GitException):
|
||||
"""Exception raised when a git repository is in an invalid state."""
|
||||
|
||||
|
||||
def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str:
|
||||
if git_dir is not None:
|
||||
_LOGGER.debug(
|
||||
"Running git command with repository isolation: %s (git_dir=%s)",
|
||||
" ".join(cmd),
|
||||
git_dir,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("Running git command: %s", " ".join(cmd))
|
||||
def _redact_url_credentials(text: str) -> str:
|
||||
"""Mask userinfo in any URLs embedded in ``text``.
|
||||
|
||||
# Set up environment for repository isolation if git_dir is provided
|
||||
# Force git to only operate on this specific repository by setting
|
||||
# GIT_DIR and GIT_WORK_TREE. This prevents git from walking up the
|
||||
# directory tree to find parent repositories when the target repo's
|
||||
# .git directory is corrupt. Without this, commands like 'git stash'
|
||||
# could accidentally operate on parent repositories (e.g., the main
|
||||
# ESPHome repo) instead of failing, causing data loss.
|
||||
env: dict[str, str] | None = None
|
||||
cwd: str | None = None
|
||||
Users can put credentials directly in a git URL, and log output is
|
||||
routinely pasted into public issues.
|
||||
"""
|
||||
return re.sub(r"://[^/@\s]+@", "://***@", text)
|
||||
|
||||
|
||||
def run_git_command(
|
||||
cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None
|
||||
) -> str:
|
||||
"""Run a git command and return its stdout.
|
||||
|
||||
The repository-scoping environment variables in ``_GIT_REPO_SCOPING_ENV``
|
||||
are always stripped. ``git_dir`` additionally pins GIT_DIR/GIT_WORK_TREE
|
||||
to that repository and runs the command there; ``cwd`` alone runs the
|
||||
command in that directory with GIT_CEILING_DIRECTORIES capping repository
|
||||
discovery at its parent.
|
||||
"""
|
||||
# Every invocation starts from an environment with the repository-scoping
|
||||
# variables stripped (see _GIT_REPO_SCOPING_ENV) so a git hook or CI
|
||||
# wrapper invoking ESPHome can never redirect these commands to its own
|
||||
# repository or index.
|
||||
#
|
||||
# ``git_dir`` then re-adds GIT_DIR and GIT_WORK_TREE pointing at the
|
||||
# managed repository. This prevents git from walking up the directory
|
||||
# tree to find parent repositories when the target repo's .git directory
|
||||
# is corrupt. Without this, commands like 'git stash' could accidentally
|
||||
# operate on parent repositories (e.g., the main ESPHome repo) instead of
|
||||
# failing, causing data loss.
|
||||
#
|
||||
# ``cwd`` (without ``git_dir``) runs the command in that directory
|
||||
# without GIT_DIR/GIT_WORK_TREE. The ``git submodule`` porcelain needs
|
||||
# this: on some installations (e.g. Windows setups where a shim hands
|
||||
# git untranslated paths) it refuses to run when GIT_DIR/GIT_WORK_TREE
|
||||
# are set, failing with "cannot be used without a working tree".
|
||||
# GIT_CEILING_DIRECTORIES (which git only honors as an absolute path)
|
||||
# keeps the parent-repo-walk protection instead: if the repo's .git is
|
||||
# missing or corrupt, git fails rather than discovering an enclosing
|
||||
# repository.
|
||||
env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_SCOPING_ENV}
|
||||
if git_dir is not None:
|
||||
env = {
|
||||
**subprocess.os.environ,
|
||||
"GIT_DIR": str(Path(git_dir) / ".git"),
|
||||
"GIT_WORK_TREE": str(git_dir),
|
||||
}
|
||||
cwd = str(git_dir)
|
||||
env["GIT_DIR"] = str(Path(git_dir) / ".git")
|
||||
env["GIT_WORK_TREE"] = str(git_dir)
|
||||
cwd = git_dir
|
||||
elif cwd is not None:
|
||||
add_git_ceiling_directory(env, Path(cwd).absolute().parent)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Running git command: %s (cwd=%s, isolated=%s)",
|
||||
_redact_url_credentials(" ".join(cmd)),
|
||||
cwd,
|
||||
git_dir is not None,
|
||||
)
|
||||
|
||||
try:
|
||||
ret = subprocess.run(
|
||||
@@ -86,16 +156,31 @@ def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str:
|
||||
"for installation instructions."
|
||||
) from err
|
||||
|
||||
if ret.returncode != 0 and ret.stderr:
|
||||
err_str = ret.stderr.decode("utf-8")
|
||||
lines = [x.strip() for x in err_str.splitlines()]
|
||||
if lines[-1].startswith("fatal:"):
|
||||
raise GitCommandError(lines[-1][len("fatal: ") :])
|
||||
raise GitCommandError(err_str)
|
||||
if ret.returncode != 0:
|
||||
if ret.stderr:
|
||||
err_str = ret.stderr.decode("utf-8")
|
||||
lines = [x.strip() for x in err_str.splitlines()]
|
||||
if lines[-1].startswith("fatal:"):
|
||||
raise GitCommandError(lines[-1][len("fatal: ") :])
|
||||
raise GitCommandError(err_str)
|
||||
raise GitCommandError(
|
||||
f"git exited with code {ret.returncode}: "
|
||||
f"{_redact_url_credentials(' '.join(cmd))}"
|
||||
)
|
||||
|
||||
return ret.stdout.decode("utf-8").strip()
|
||||
|
||||
|
||||
def _cache_key(url: str, ref: str | None) -> str:
|
||||
"""Cache key identifying one repository checkout.
|
||||
|
||||
The lock path and the entry directory both hash this, keeping them in
|
||||
agreement. (micro_wake_word still rebuilds the format by hand to locate
|
||||
manifests; fold it in here if the format ever changes.)
|
||||
"""
|
||||
return f"{url}@{ref}"
|
||||
|
||||
|
||||
def _compute_destination_path(key: str, domain: str) -> Path:
|
||||
base_dir = Path(CORE.data_dir) / domain
|
||||
h = hashlib.new("sha256")
|
||||
@@ -103,26 +188,220 @@ def _compute_destination_path(key: str, domain: str) -> Path:
|
||||
return base_dir / h.hexdigest()[:8]
|
||||
|
||||
|
||||
def _repo_entry_dir(key: str, domain: str, subpath: Path | None) -> Path:
|
||||
"""Worktree directory of one cache entry: the hash dir plus optional subpath."""
|
||||
repo_dir = _compute_destination_path(key, domain)
|
||||
if subpath:
|
||||
repo_dir = repo_dir / subpath
|
||||
return repo_dir
|
||||
|
||||
|
||||
def _repo_lock_path(key: str, domain: str) -> Path:
|
||||
"""Path of the lock file serializing all work on one cache entry.
|
||||
|
||||
Lives next to the hash directory, never inside it, so the removal of a
|
||||
broken or incomplete clone can never delete a lock another process holds.
|
||||
"""
|
||||
repo_dir = _compute_destination_path(key, domain)
|
||||
return repo_dir.parent / f"{repo_dir.name}.lock"
|
||||
|
||||
|
||||
class _LockStatus(Enum):
|
||||
ACQUIRED = auto()
|
||||
# A bounded wait expired while another process held the lock.
|
||||
TIMEOUT = auto()
|
||||
# The lock could not be taken at all; callers proceed unlocked,
|
||||
# matching the behavior before the lock existed.
|
||||
UNAVAILABLE = auto()
|
||||
|
||||
|
||||
# Errnos that mean the filesystem genuinely cannot take file locks (NFS
|
||||
# without a lock daemon, some FUSE mounts). Any other OSError (permissions,
|
||||
# read-only volume, full disk) is a cache directory problem, which the git
|
||||
# commands themselves report clearly when it actually matters. EPERM is
|
||||
# deliberately absent: it usually means a permissions problem, so it takes
|
||||
# the generic message that names no cause. On Linux ENOTSUP and EOPNOTSUPP
|
||||
# are the same value; the set folds them.
|
||||
_NO_LOCK_SUPPORT_ERRNOS = frozenset(
|
||||
{errno.ENOLCK, errno.ENOSYS, errno.EOPNOTSUPP, errno.ENOTSUP}
|
||||
)
|
||||
|
||||
|
||||
def _acquire_repo_lock(
|
||||
lock: "FileLock",
|
||||
safe_key: str,
|
||||
timeout: float,
|
||||
wait_message: str = "Waiting for another process to finish updating %s",
|
||||
) -> _LockStatus:
|
||||
"""Acquire ``lock``, logging ``wait_message`` when a wait actually begins.
|
||||
|
||||
``timeout`` of -1 waits forever; a positive value bounds the wait and
|
||||
can yield ``TIMEOUT``.
|
||||
"""
|
||||
from filelock import Timeout
|
||||
|
||||
try:
|
||||
try:
|
||||
lock.acquire(blocking=False)
|
||||
except Timeout:
|
||||
# Waiting on another process's clone or update can take
|
||||
# minutes; say so instead of appearing hung.
|
||||
_LOGGER.info(wait_message, safe_key)
|
||||
lock.acquire(timeout=timeout)
|
||||
except Timeout:
|
||||
return _LockStatus.TIMEOUT
|
||||
except OSError as err:
|
||||
if err.errno in _NO_LOCK_SUPPORT_ERRNOS:
|
||||
_LOGGER.warning(
|
||||
"The filesystem does not support locking the cache entry for "
|
||||
"%s (%s), continuing without a lock",
|
||||
safe_key,
|
||||
err,
|
||||
)
|
||||
else:
|
||||
# Not a locking problem (permissions, read-only volume, full
|
||||
# disk). Still continue unlocked: a pre-seeded read-only cache
|
||||
# with refresh disabled only reads and must keep working, and
|
||||
# in every other case the git commands fail with the real error.
|
||||
_LOGGER.warning(
|
||||
"Could not take the cache entry lock for %s (%s), "
|
||||
"continuing without a lock",
|
||||
safe_key,
|
||||
err,
|
||||
)
|
||||
return _LockStatus.UNAVAILABLE
|
||||
return _LockStatus.ACQUIRED
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _repo_cache_lock(
|
||||
key: str, domain: str, repo_dir: Path
|
||||
) -> Iterator[tuple[bool, "FileLock | None"]]:
|
||||
"""Hold the cache entry lock for ``key`` over the with block.
|
||||
|
||||
Yields ``(use_existing, lock)``. ``use_existing`` is True when the lock
|
||||
could not be acquired within the bounded wait but ``repo_dir`` is a
|
||||
complete cache entry; the caller should use it as-is and do nothing
|
||||
else. Otherwise ``lock`` is the held lock, released when the block
|
||||
exits, or ``None`` when the lock could not be taken at all and the
|
||||
caller proceeds unlocked.
|
||||
"""
|
||||
# Lazy import: keeps filelock off the CLI startup import path.
|
||||
from filelock import FileLock
|
||||
|
||||
safe_key = _redact_url_credentials(key)
|
||||
# acquire() creates the lock file's directory itself; git clone later
|
||||
# creates the hash directory next to it. fallback_to_soft would silently
|
||||
# downgrade ENOSYS to a SoftFileLock, whose stale existence marker from
|
||||
# another host on a shared cache could hang the unbounded wait forever;
|
||||
# routing it through the OSError handler runs unlocked instead.
|
||||
lock: FileLock | None = FileLock(
|
||||
str(_repo_lock_path(key, domain)), fallback_to_soft=False
|
||||
)
|
||||
status = _acquire_repo_lock(lock, safe_key, _COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS)
|
||||
if status is _LockStatus.TIMEOUT:
|
||||
if _clone_complete_marker_path(repo_dir).is_file():
|
||||
# Mutual exclusion matters most while no complete entry exists
|
||||
# (initial clone, recovery re-clone); with one on disk, reading
|
||||
# it beats hanging behind a stalled holder.
|
||||
_LOGGER.warning(
|
||||
"Timed out waiting for another process updating %s, proceeding "
|
||||
"with the existing clone, which that process may still be "
|
||||
"changing",
|
||||
safe_key,
|
||||
)
|
||||
yield True, None
|
||||
return
|
||||
# Nothing to fall back to; the holder is producing the clone this
|
||||
# caller needs.
|
||||
status = _acquire_repo_lock(
|
||||
lock,
|
||||
safe_key,
|
||||
timeout=-1,
|
||||
wait_message="Still waiting for the clone of %s, "
|
||||
"there is no existing clone to fall back on",
|
||||
)
|
||||
if status is not _LockStatus.ACQUIRED:
|
||||
lock = None
|
||||
try:
|
||||
yield False, lock
|
||||
finally:
|
||||
if lock is not None:
|
||||
lock.release()
|
||||
|
||||
|
||||
def _clone_complete_marker_path(repo_dir: Path) -> Path:
|
||||
return repo_dir / ".git" / _CLONE_COMPLETE_MARKER
|
||||
|
||||
|
||||
def _clear_clone_complete_marker(repo_dir: Path) -> None:
|
||||
"""Best-effort removal of the completion marker.
|
||||
|
||||
If the unlink fails (e.g. a file lock on Windows), the marker stays and
|
||||
the entry keeps its previous trust level; every consumer of the marker
|
||||
tolerates that.
|
||||
"""
|
||||
try:
|
||||
_clone_complete_marker_path(repo_dir).unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
_LOGGER.debug("Could not delete clone completion marker: %s", err)
|
||||
|
||||
|
||||
def _write_clone_complete_marker(
|
||||
repo_dir: Path, key: str, hash_dir_name: str, safe_key: str
|
||||
) -> None:
|
||||
"""Mark the entry as a complete, quiescent checkout.
|
||||
|
||||
The key and hash dir name are recorded purely to make cache debugging
|
||||
easier. The marker is only a validity signal, so a failed write must not
|
||||
fail an otherwise complete clone or update: the only cost is a re-clone
|
||||
on the next run.
|
||||
"""
|
||||
try:
|
||||
write_file(
|
||||
_clone_complete_marker_path(repo_dir),
|
||||
f"key={key}\nhash={hash_dir_name}\n",
|
||||
)
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not write clone completion marker for %s: %s", safe_key, err
|
||||
)
|
||||
|
||||
|
||||
def _remove_repo_dir(repo_dir: Path) -> None:
|
||||
"""Remove a repo directory, deleting the completion marker first.
|
||||
|
||||
Marker-first ordering guarantees an interrupted removal can never leave a
|
||||
marker behind next to a partially deleted worktree. The unlink is best
|
||||
effort: if it fails (e.g. a file lock on Windows), rmtree below still
|
||||
gets the chance to remove the directory, marker included.
|
||||
effort: if it fails, rmtree below still gets the chance to remove the
|
||||
directory, marker included.
|
||||
"""
|
||||
try:
|
||||
_clone_complete_marker_path(repo_dir).unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
_LOGGER.debug("Could not delete clone completion marker first: %s", err)
|
||||
_clear_clone_complete_marker(repo_dir)
|
||||
if repo_dir.is_dir():
|
||||
rmtree(repo_dir)
|
||||
|
||||
|
||||
def update_submodules(repo_dir: Path, key: str) -> None:
|
||||
"""Initialize/update every submodule the repository declares, recursively,
|
||||
matching how PlatformIO clones libraries.
|
||||
|
||||
Most repositories declare no submodules, so this does nothing when there
|
||||
is no ``.gitmodules`` file. Which submodules get populated is git's own
|
||||
policy (``update = none``, ``submodule.active``, sparse checkouts);
|
||||
git's exit code is the error signal.
|
||||
|
||||
Runs with plain ``cwd`` rather than ``git_dir`` isolation, which the
|
||||
``git submodule`` porcelain does not tolerate (see ``run_git_command``).
|
||||
"""
|
||||
if not (repo_dir / ".gitmodules").is_file():
|
||||
return
|
||||
_LOGGER.info("Updating submodules for %s", _redact_url_credentials(key))
|
||||
run_git_command(
|
||||
["git", "submodule", "update", "--init", "--recursive", "--depth=1"],
|
||||
cwd=repo_dir,
|
||||
)
|
||||
|
||||
|
||||
def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None:
|
||||
"""Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub.
|
||||
|
||||
@@ -212,38 +491,106 @@ def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None:
|
||||
def clone_or_update(
|
||||
*,
|
||||
url: str,
|
||||
ref: str = None,
|
||||
ref: str | None = None,
|
||||
refresh: TimePeriodSeconds | None,
|
||||
domain: str,
|
||||
username: str = None,
|
||||
password: str = None,
|
||||
submodules: list[str] | None = None,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
init_submodules: bool = False,
|
||||
subpath: Path | None = None,
|
||||
_recover_broken: bool = True,
|
||||
) -> tuple[Path, Callable[[], None] | None]:
|
||||
key = f"{url}@{ref}"
|
||||
) -> tuple[Path, Callable[[], bool] | None]:
|
||||
"""Clone a repository into the cache, or refresh an existing clone.
|
||||
|
||||
All work runs under a per-cache-entry inter-process file lock, so
|
||||
concurrent resolutions of the same repository (two esphome processes, or
|
||||
a subprocess plus an in-process load) serialize instead of interleaving.
|
||||
Without the lock, ``repo_dir.is_dir()`` is true from the instant
|
||||
``git clone`` creates the directory: a second caller could read a half
|
||||
populated worktree, or see the missing completion marker and delete the
|
||||
clone in progress out from under the first caller.
|
||||
|
||||
The lock guards mutation of the cache entry only; it is released when
|
||||
this function returns, so a caller still reading the worktree can
|
||||
overlap a later refresh by another process. That residual window is
|
||||
narrow (the refresh interval is re-checked under the lock) and predates
|
||||
the lock.
|
||||
|
||||
Locking is best effort: on a filesystem that cannot take file locks a
|
||||
warning is logged and the work proceeds unlocked, matching the behavior
|
||||
before the lock existed. A complete cache entry also caps the wait: if
|
||||
the holder is still busy after a bounded time (e.g. stalled on the
|
||||
network), the existing clone is used without refreshing it, so a stuck
|
||||
process cannot hang every peer that already has a good entry.
|
||||
"""
|
||||
key = _cache_key(url, ref)
|
||||
repo_dir = _repo_entry_dir(key, domain, subpath)
|
||||
with _repo_cache_lock(key, domain, repo_dir) as (use_existing, lock):
|
||||
if use_existing:
|
||||
return repo_dir, None
|
||||
return _clone_or_update_locked(
|
||||
url=url,
|
||||
ref=ref,
|
||||
refresh=refresh,
|
||||
domain=domain,
|
||||
username=username,
|
||||
password=password,
|
||||
init_submodules=init_submodules,
|
||||
subpath=subpath,
|
||||
lock=lock,
|
||||
)
|
||||
|
||||
|
||||
def _clone_or_update_locked(
|
||||
*,
|
||||
url: str,
|
||||
ref: str | None,
|
||||
refresh: TimePeriodSeconds | None,
|
||||
domain: str,
|
||||
username: str | None,
|
||||
password: str | None,
|
||||
init_submodules: bool,
|
||||
subpath: Path | None,
|
||||
lock: "FileLock | None",
|
||||
_recover_broken: bool = True,
|
||||
) -> tuple[Path, Callable[[], bool] | None]:
|
||||
"""Body of ``clone_or_update``; the caller holds ``lock``.
|
||||
|
||||
Split out because the broken-repository recovery below re-enters this
|
||||
function: re-acquiring the already-held lock would deadlock, since OS
|
||||
file locks taken on separate file descriptors conflict even within one
|
||||
process. ``lock`` is only re-acquired by the returned ``revert``
|
||||
callback, which runs after the wrapper's ``finally`` has released it.
|
||||
``lock`` is ``None`` when the filesystem cannot take file locks and the
|
||||
wrapper fell back to running unlocked.
|
||||
"""
|
||||
key = _cache_key(url, ref)
|
||||
# The user may have embedded credentials in the URL itself; log this
|
||||
# instead of key.
|
||||
safe_key = _redact_url_credentials(key)
|
||||
|
||||
# Keep the caller's URL for the recovery re-clone below: rewriting the
|
||||
# rewritten URL would double the userinfo, and the recursive call must
|
||||
# compute the same cache key as this one.
|
||||
original_url = url
|
||||
if username is not None and password is not None:
|
||||
url = url.replace(
|
||||
"://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@"
|
||||
)
|
||||
|
||||
repo_dir = _compute_destination_path(key, domain)
|
||||
hash_dir_name = repo_dir.name
|
||||
if subpath:
|
||||
repo_dir = repo_dir / subpath
|
||||
hash_dir_name = _compute_destination_path(key, domain).name
|
||||
repo_dir = _repo_entry_dir(key, domain, subpath)
|
||||
|
||||
if repo_dir.is_dir() and not _clone_complete_marker_path(repo_dir).is_file():
|
||||
# The last clone never finished (killed process, container stop) or
|
||||
# predates the marker; either way it cannot be trusted, especially
|
||||
# with NEVER_REFRESH where it would otherwise be reused forever.
|
||||
_LOGGER.warning(
|
||||
"Removing incomplete clone of %s at %s, will re-clone", key, repo_dir
|
||||
"Removing incomplete clone of %s at %s, will re-clone", safe_key, repo_dir
|
||||
)
|
||||
_remove_repo_dir(repo_dir)
|
||||
|
||||
if not repo_dir.is_dir():
|
||||
_LOGGER.info("Cloning %s", key)
|
||||
_LOGGER.info("Cloning %s", safe_key)
|
||||
_LOGGER.debug("Location: %s", repo_dir)
|
||||
try:
|
||||
cmd = ["git", "clone", "--depth=1"]
|
||||
@@ -262,15 +609,8 @@ def clone_or_update(
|
||||
["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir
|
||||
)
|
||||
|
||||
if submodules is not None:
|
||||
_LOGGER.info(
|
||||
"Initializing submodules (%s) for %s", ", ".join(submodules), key
|
||||
)
|
||||
run_git_command(
|
||||
["git", "submodule", "update", "--init", "--depth=1", "--"]
|
||||
+ submodules,
|
||||
git_dir=repo_dir,
|
||||
)
|
||||
if init_submodules:
|
||||
update_submodules(repo_dir, key)
|
||||
|
||||
except GitException:
|
||||
# Remove incomplete clone to prevent stale state. Without this,
|
||||
@@ -279,23 +619,12 @@ def clone_or_update(
|
||||
_remove_repo_dir(repo_dir)
|
||||
raise
|
||||
|
||||
# Every git step succeeded; the key and hash dir name are recorded
|
||||
# purely to make cache debugging easier. The marker is only a
|
||||
# validity signal, so a failed write must not fail an otherwise
|
||||
# complete clone: the only cost is a re-clone on the next run.
|
||||
try:
|
||||
write_file(
|
||||
_clone_complete_marker_path(repo_dir),
|
||||
f"key={key}\nhash={hash_dir_name}\n",
|
||||
)
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not write clone completion marker for %s: %s", key, err
|
||||
)
|
||||
# Every git step succeeded.
|
||||
_write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key)
|
||||
|
||||
else:
|
||||
if refresh == NEVER_REFRESH or CORE.skip_external_update:
|
||||
_LOGGER.debug("Skipping update for %s (refresh disabled)", key)
|
||||
_LOGGER.debug("Skipping update for %s (refresh disabled)", safe_key)
|
||||
return repo_dir, None
|
||||
|
||||
file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD")
|
||||
@@ -319,9 +648,16 @@ def clone_or_update(
|
||||
["git", "rev-parse", "HEAD"], git_dir=repo_dir
|
||||
)
|
||||
|
||||
_LOGGER.info("Updating %s", key)
|
||||
_LOGGER.info("Updating %s", safe_key)
|
||||
_LOGGER.debug("Location: %s", repo_dir)
|
||||
|
||||
# The entry is about to be rewritten; drop the marker so a
|
||||
# timed-out peer's fallback and the incomplete-entry check
|
||||
# can tell a quiescent complete entry from one mid-rewrite,
|
||||
# and so an update interrupted by a crash re-clones instead
|
||||
# of being trusted.
|
||||
_clear_clone_complete_marker(repo_dir)
|
||||
|
||||
# Stash local changes (if any)
|
||||
# Use git_dir to ensure this only affects the specific repo
|
||||
run_git_command(
|
||||
@@ -345,54 +681,126 @@ def clone_or_update(
|
||||
["git", "reset", "--hard", "FETCH_HEAD"],
|
||||
git_dir=repo_dir,
|
||||
)
|
||||
|
||||
# Inside the try so a submodule failure routes through the
|
||||
# recovery re-clone below instead of leaving a repo that the
|
||||
# refresh window would silently accept on the next run.
|
||||
if init_submodules:
|
||||
update_submodules(repo_dir, key)
|
||||
|
||||
# Recorded so revert() can tell whether the checkout is
|
||||
# still the one this update produced.
|
||||
new_sha = run_git_command(
|
||||
["git", "rev-parse", "HEAD"], git_dir=repo_dir
|
||||
)
|
||||
|
||||
# The rewrite finished; the entry is trustworthy again.
|
||||
_write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key)
|
||||
except GitException as err:
|
||||
# Repository is in a broken state or update failed
|
||||
# Only attempt recovery once to prevent infinite recursion
|
||||
if not _recover_broken:
|
||||
_LOGGER.error(
|
||||
"Repository %s recovery failed, cannot retry (already attempted once)",
|
||||
key,
|
||||
safe_key,
|
||||
)
|
||||
raise
|
||||
|
||||
_LOGGER.warning(
|
||||
"Repository %s has issues (%s), attempting recovery",
|
||||
key,
|
||||
safe_key,
|
||||
err,
|
||||
)
|
||||
_LOGGER.info("Removing broken repository at %s", repo_dir)
|
||||
_remove_repo_dir(repo_dir)
|
||||
_LOGGER.info("Successfully removed broken repository, re-cloning...")
|
||||
|
||||
# Recursively call clone_or_update to re-clone
|
||||
# Set _recover_broken=False to prevent infinite recursion
|
||||
result = clone_or_update(
|
||||
url=url,
|
||||
# Re-clone while still holding the lock; going through the
|
||||
# public wrapper would try to re-acquire it and deadlock.
|
||||
# Set _recover_broken=False to prevent infinite recursion.
|
||||
result = _clone_or_update_locked(
|
||||
url=original_url,
|
||||
ref=ref,
|
||||
refresh=refresh,
|
||||
domain=domain,
|
||||
username=username,
|
||||
password=password,
|
||||
submodules=submodules,
|
||||
init_submodules=init_submodules,
|
||||
subpath=subpath,
|
||||
lock=lock,
|
||||
_recover_broken=False,
|
||||
)
|
||||
_LOGGER.info("Repository %s successfully recovered", key)
|
||||
_LOGGER.info("Repository %s successfully recovered", safe_key)
|
||||
return result
|
||||
|
||||
if submodules is not None:
|
||||
_LOGGER.info(
|
||||
"Updating submodules (%s) for %s", ", ".join(submodules), key
|
||||
)
|
||||
run_git_command(
|
||||
["git", "submodule", "update", "--init", "--depth=1", "--"]
|
||||
+ submodules,
|
||||
git_dir=repo_dir,
|
||||
)
|
||||
def revert() -> bool:
|
||||
"""Reset the checkout to the pre-update SHA.
|
||||
|
||||
def revert():
|
||||
_LOGGER.info("Reverting changes to %s -> %s", key, old_sha)
|
||||
run_git_command(["git", "reset", "--hard", old_sha], git_dir=repo_dir)
|
||||
Returns False when the revert did not happen: the cache
|
||||
entry lock could not be acquired in time, the checkout
|
||||
moved since this update (another process refreshed it), or
|
||||
the reset itself failed. A retry cannot reach the
|
||||
pre-update content then.
|
||||
"""
|
||||
if lock is None:
|
||||
# The wrapper already warned about the unlockable
|
||||
# filesystem; revert unlocked like everything else.
|
||||
status = _LockStatus.UNAVAILABLE
|
||||
else:
|
||||
status = _acquire_repo_lock(
|
||||
lock, safe_key, _REVERT_LOCK_TIMEOUT_SECONDS
|
||||
)
|
||||
if status is _LockStatus.TIMEOUT:
|
||||
# revert() only runs on an already-failing path; skip
|
||||
# rather than hang so the original error can surface.
|
||||
_LOGGER.warning(
|
||||
"Could not lock %s to revert to %s, skipping revert; "
|
||||
"the cached checkout keeps the un-reverted content "
|
||||
"until its next refresh",
|
||||
safe_key,
|
||||
old_sha,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
# Anything can happen between the wrapper releasing the
|
||||
# lock and revert() re-acquiring it; only undo this
|
||||
# process's own update, never a peer's newer refresh.
|
||||
head = run_git_command(
|
||||
["git", "rev-parse", "HEAD"], git_dir=repo_dir
|
||||
)
|
||||
if head != new_sha:
|
||||
_LOGGER.warning(
|
||||
"Not reverting %s: the checkout moved since this "
|
||||
"update (another process refreshed it)",
|
||||
safe_key,
|
||||
)
|
||||
return False
|
||||
# Announced only once every skip check has passed, so
|
||||
# the log says exactly one thing per outcome.
|
||||
_LOGGER.info("Reverting changes to %s -> %s", safe_key, old_sha)
|
||||
run_git_command(
|
||||
["git", "reset", "--hard", old_sha], git_dir=repo_dir
|
||||
)
|
||||
except GitException as err:
|
||||
# GitException is a cv.Invalid; letting it escape would
|
||||
# replace the caller's original error with a bare git
|
||||
# message. Report the failed reset like the skip above,
|
||||
# and drop the marker: an entry whose reset fails cannot
|
||||
# be trusted, so the next use re-clones it instead of
|
||||
# the refresh window silently accepting it.
|
||||
_LOGGER.warning(
|
||||
"Could not revert %s to %s (%s), the entry will be "
|
||||
"re-cloned on next use",
|
||||
safe_key,
|
||||
old_sha,
|
||||
err,
|
||||
)
|
||||
_clear_clone_complete_marker(repo_dir)
|
||||
return False
|
||||
finally:
|
||||
if status is _LockStatus.ACQUIRED:
|
||||
lock.release()
|
||||
return True
|
||||
|
||||
return repo_dir, revert
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ class GitSource(Source):
|
||||
ref=self.ref,
|
||||
refresh=git.NEVER_REFRESH if not force else None,
|
||||
domain=domain,
|
||||
submodules=[],
|
||||
init_submodules=True,
|
||||
subpath=Path(dir_suffix),
|
||||
)
|
||||
return path
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@ bleak==2.1.1
|
||||
smpclient==7.2.0
|
||||
requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.10.0 # native esp-idf toolchain global cache dir
|
||||
filelock==3.29.0 # lock guarding the PlatformIO python-version cache heal
|
||||
platformdirs==4.11.0 # native esp-idf toolchain global cache dir
|
||||
filelock==3.32.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
|
||||
# esp-idf >= 5.0 requires this
|
||||
pyparsing >= 3.3.2
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
spi:
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO19
|
||||
|
||||
display:
|
||||
- platform: epaper_spi
|
||||
id: epaper_display
|
||||
model: t133a01
|
||||
dc_pin: GPIO21
|
||||
reset_pin: GPIO38
|
||||
cs_pin: GPIO10
|
||||
cs1_pin: GPIO2
|
||||
busy_pin: GPIO13
|
||||
update_interval: never
|
||||
dimensions:
|
||||
width: 200
|
||||
height: 200
|
||||
@@ -462,3 +462,24 @@ def test_enable_pin_code_generation(
|
||||
# Both pin objects must be passed to the display via set_enable_pins() as a
|
||||
# std::vector initializer list, in the configured order.
|
||||
assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp
|
||||
|
||||
|
||||
def test_model_with_no_default_init_sequence_generates(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Test that code generation succeeds for a model with no default init sequence.
|
||||
|
||||
The base "t133a01" model (used directly, not via one of its `.extend()`
|
||||
variants) doesn't override `get_init_sequence()` or pass `initsequence` to
|
||||
its constructor, and the user didn't supply `init_sequence:` either.
|
||||
`EpaperModel.get_init_sequence()` used to default to `None` in this case,
|
||||
which made `flatten_sequence()` raise a `TypeError` during code
|
||||
generation. Regression test for that crash.
|
||||
"""
|
||||
main_cpp = generate_main(component_config_path("t133a01_no_init_sequence.yaml"))
|
||||
|
||||
# The generated constructor call takes (name, width, height, init_sequence,
|
||||
# init_sequence_length, ...); a length of 0 confirms the empty init
|
||||
# sequence array was generated instead of raising during code generation.
|
||||
assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp)
|
||||
|
||||
@@ -108,6 +108,24 @@ def test_esp32_default_toolchain_is_esp_idf(
|
||||
assert CORE.toolchain == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_toolchain",
|
||||
[Toolchain.SDK_NRF.value, "nonsense"],
|
||||
)
|
||||
def test_esp32_rejects_unsupported_toolchains(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
config_toolchain: str,
|
||||
) -> None:
|
||||
"""Toolchains esp32 does not support are rejected at validation time."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
from esphome.components.esp32 import CONFIG_SCHEMA
|
||||
|
||||
CORE.toolchain = None
|
||||
with pytest.raises(cv.Invalid, match="Unknown value"):
|
||||
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "error_match"),
|
||||
[
|
||||
@@ -454,26 +472,18 @@ def test_flash_mode_unset_leaves_defaults(
|
||||
),
|
||||
pytest.param(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
NetworkSdkconfigData(
|
||||
wifi=True, bluetooth=True, ble_42=True, software_coexistence=True
|
||||
),
|
||||
NetworkSdkconfigData(wifi=True, bluetooth=True, software_coexistence=True),
|
||||
{},
|
||||
{
|
||||
"CONFIG_BT_ENABLED": True,
|
||||
"CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True,
|
||||
"CONFIG_BT_BLE_50_FEATURES_SUPPORTED": False,
|
||||
"CONFIG_SW_COEXIST_ENABLE": True,
|
||||
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
|
||||
"CONFIG_LWIP_DHCPS": False,
|
||||
},
|
||||
id="idf_wifi_ble_tracker_coexistence",
|
||||
),
|
||||
pytest.param(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
NetworkSdkconfigData(bluetooth=True),
|
||||
{},
|
||||
{"CONFIG_BT_ENABLED": True},
|
||||
id="idf_ble_server_only_no_ble42",
|
||||
),
|
||||
# --- IDF: user sdkconfig_options always win ---
|
||||
pytest.param(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
@@ -594,6 +604,7 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert sdkconfig.get("CONFIG_BT_ENABLED") is True
|
||||
assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True
|
||||
assert sdkconfig.get("CONFIG_BT_BLE_50_FEATURES_SUPPORTED") is False
|
||||
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True
|
||||
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
|
||||
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
|
||||
|
||||
@@ -1264,6 +1264,75 @@ def test_remote_packages_no_revert(
|
||||
]
|
||||
|
||||
|
||||
@patch("esphome.yaml_util.load_yaml")
|
||||
@patch("pathlib.Path.is_file")
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_packages_skipped_revert_does_not_retry(
|
||||
mock_clone_or_update, mock_is_file, mock_load_yaml
|
||||
) -> None:
|
||||
"""When revert() reports the rollback was skipped, the load is not
|
||||
retried (the checkout is unchanged) and the error says so."""
|
||||
mock_revert = MagicMock(return_value=False)
|
||||
mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert)
|
||||
mock_is_file.return_value = True
|
||||
mock_load_yaml.side_effect = cv.Invalid("bad yaml")
|
||||
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
"pkg": {
|
||||
CONF_URL: "https://github.com/esphome/repo",
|
||||
CONF_REF: "main",
|
||||
CONF_FILES: [{CONF_PATH: "file.yaml"}],
|
||||
CONF_REFRESH: "1d",
|
||||
}
|
||||
}
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="could not revert the cached checkout"):
|
||||
packages_pass(config)
|
||||
|
||||
assert mock_revert.call_count == 1
|
||||
assert mock_load_yaml.call_count == 1
|
||||
|
||||
|
||||
@patch("esphome.yaml_util.load_yaml")
|
||||
@patch("pathlib.Path.is_file")
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_packages_successful_revert_retries(
|
||||
mock_clone_or_update, mock_is_file, mock_load_yaml, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A successful revert retries the load against the reverted checkout and
|
||||
logs the original error, the only trace that upstream was broken."""
|
||||
mock_revert = MagicMock(return_value=True)
|
||||
mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert)
|
||||
mock_is_file.return_value = True
|
||||
mock_load_yaml.side_effect = [
|
||||
cv.Invalid("bad yaml"),
|
||||
OrderedDict(
|
||||
{CONF_SENSOR: [{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}]}
|
||||
),
|
||||
]
|
||||
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
"pkg": {
|
||||
CONF_URL: "https://github.com/esphome/repo",
|
||||
CONF_REF: "main",
|
||||
CONF_FILES: [{CONF_PATH: "file.yaml"}],
|
||||
CONF_REFRESH: "1d",
|
||||
}
|
||||
}
|
||||
}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
actual = packages_pass(config)
|
||||
|
||||
assert actual[CONF_SENSOR] == [
|
||||
{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}
|
||||
]
|
||||
assert mock_revert.call_count == 1
|
||||
assert mock_load_yaml.call_count == 2
|
||||
assert any("reverted the cached checkout" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None:
|
||||
"""Test that CORE.raw_config contains esphome section from merged package.
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Test-manifest overrides for the captive_portal C++ unit tests.
|
||||
|
||||
``json_escape`` lives in a standalone, dependency-free header
|
||||
(``esphome/components/captive_portal/json_escape.h``). The rest of the
|
||||
captive_portal component and its auto-loaded dependencies (``web_server_base``,
|
||||
``ota.web_server``) do not build for the ``host`` platform that the C++ unit
|
||||
test harness targets. Strip those away and replace the real schema -- which is
|
||||
restricted to non-host platforms via ``cv.only_on`` and requires a
|
||||
``web_server_base`` instance via ``use_id`` -- with an empty one so the host
|
||||
test config validates. ``to_code`` stays suppressed (the default), so
|
||||
``USE_CAPTIVE_PORTAL`` is never defined and ``captive_portal.cpp`` compiles to an
|
||||
empty translation unit; only ``json_escape.h`` is exercised by the test.
|
||||
"""
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
manifest.auto_load = []
|
||||
manifest.dependencies = []
|
||||
manifest.config_schema = cv.Schema({})
|
||||
manifest.final_validate_schema = None
|
||||
@@ -0,0 +1,107 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "esphome/components/captive_portal/json_escape.h"
|
||||
|
||||
namespace esphome::captive_portal::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
// Large enough that none of the inputs below are ever dropped.
|
||||
constexpr size_t TEST_BUFFER_SIZE = 64 * JSON_ESCAPE_MAX_EXPANSION + 1;
|
||||
|
||||
// Escape into a stack buffer and return the result as a string so the expectations stay readable.
|
||||
std::string escape(const std::string &value) {
|
||||
char buf[TEST_BUFFER_SIZE];
|
||||
return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size()));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Plain ASCII with no special characters is passed through unchanged.
|
||||
TEST(CaptivePortalJsonEscape, PlainStringUnchanged) {
|
||||
EXPECT_EQ(escape("MyNetwork"), "MyNetwork");
|
||||
EXPECT_EQ(escape(""), "");
|
||||
}
|
||||
|
||||
// A double quote is escaped so it does not terminate the surrounding JSON string.
|
||||
TEST(CaptivePortalJsonEscape, EscapesDoubleQuote) {
|
||||
EXPECT_EQ(escape("a\"b"), "a\\\"b");
|
||||
// A double quote followed by other characters stays inside the JSON string.
|
||||
EXPECT_EQ(escape("\">end"), "\\\">end");
|
||||
}
|
||||
|
||||
// A backslash is doubled so it does not start an escape sequence in the output.
|
||||
TEST(CaptivePortalJsonEscape, EscapesBackslash) {
|
||||
EXPECT_EQ(escape("a\\b"), "a\\\\b");
|
||||
// A trailing backslash must not escape the closing quote of the JSON string.
|
||||
EXPECT_EQ(escape("net\\"), "net\\\\");
|
||||
}
|
||||
|
||||
// The control characters with short JSON forms use those forms.
|
||||
TEST(CaptivePortalJsonEscape, EscapesShortFormControls) {
|
||||
EXPECT_EQ(escape("\n"), "\\n");
|
||||
EXPECT_EQ(escape("\r"), "\\r");
|
||||
EXPECT_EQ(escape("\t"), "\\t");
|
||||
EXPECT_EQ(escape("\b"), "\\b");
|
||||
EXPECT_EQ(escape("\f"), "\\f");
|
||||
}
|
||||
|
||||
// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex.
|
||||
TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) {
|
||||
EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000");
|
||||
EXPECT_EQ(escape("\x01"), "\\u0001");
|
||||
EXPECT_EQ(escape("\x10"), "\\u0010");
|
||||
EXPECT_EQ(escape("\x1f"), "\\u001f");
|
||||
// 0x7f (DEL) is >= 0x20, so it is NOT escaped by this helper.
|
||||
EXPECT_EQ(escape("\x7f"), "\x7f");
|
||||
}
|
||||
|
||||
// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim.
|
||||
TEST(CaptivePortalJsonEscape, PassesThroughUtf8) {
|
||||
// "café" in UTF-8 (é == 0xC3 0xA9).
|
||||
EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9");
|
||||
// Emoji (📶, 4-byte UTF-8) survives unchanged.
|
||||
EXPECT_EQ(escape("\xf0\x9f\x93\xb6"), "\xf0\x9f\x93\xb6");
|
||||
}
|
||||
|
||||
// A mix of special and normal characters is escaped in place without disturbing the rest.
|
||||
TEST(CaptivePortalJsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); }
|
||||
|
||||
// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly.
|
||||
TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) {
|
||||
constexpr size_t input_len = 8;
|
||||
char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1];
|
||||
const std::string input(input_len, '\x01');
|
||||
std::string expected;
|
||||
for (size_t i = 0; i < input_len; i++)
|
||||
expected += "\\u0001";
|
||||
EXPECT_EQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), expected);
|
||||
}
|
||||
|
||||
// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null
|
||||
// terminated.
|
||||
TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) {
|
||||
// Room for one \u00XX sequence plus the null terminator, but two are requested.
|
||||
char buf[JSON_ESCAPE_MAX_EXPANSION + 1];
|
||||
const std::string input(2, '\x01');
|
||||
const std::string result = json_escape_into_buffer(buf, StringRef(input.c_str(), input.size()));
|
||||
EXPECT_EQ(result, "\\u0001");
|
||||
EXPECT_EQ(buf[JSON_ESCAPE_MAX_EXPANSION], '\0');
|
||||
}
|
||||
|
||||
// Plain characters are truncated at the buffer size, leaving room for the null terminator.
|
||||
TEST(CaptivePortalJsonEscape, TruncatesPlainInput) {
|
||||
char buf[5];
|
||||
const std::string input(20, 'a');
|
||||
EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa");
|
||||
}
|
||||
|
||||
// A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing.
|
||||
TEST(CaptivePortalJsonEscape, EmptyBufferIsSafe) {
|
||||
const std::string input("test");
|
||||
EXPECT_STREQ(json_escape_into_buffer(std::span<char>(), StringRef(input.c_str(), input.size())), "");
|
||||
}
|
||||
|
||||
} // namespace esphome::captive_portal::testing
|
||||
@@ -0,0 +1,120 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/light/light_call.h"
|
||||
#include "esphome/components/light/light_output.h"
|
||||
#include "esphome/components/light/light_state.h"
|
||||
|
||||
namespace esphome::light::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
// A light that only supports ON_OFF, like the `binary` platform and `status_led`.
|
||||
class OnOffOutput : public LightOutput {
|
||||
public:
|
||||
LightTraits get_traits() override {
|
||||
LightTraits traits;
|
||||
traits.set_supported_color_modes({ColorMode::ON_OFF});
|
||||
return traits;
|
||||
}
|
||||
void write_state(LightState *state) override {}
|
||||
};
|
||||
|
||||
// A dimmable light, like the `monochromatic` platform.
|
||||
class BrightnessOutput : public LightOutput {
|
||||
public:
|
||||
LightTraits get_traits() override {
|
||||
LightTraits traits;
|
||||
traits.set_supported_color_modes({ColorMode::BRIGHTNESS});
|
||||
return traits;
|
||||
}
|
||||
void write_state(LightState *state) override {}
|
||||
};
|
||||
|
||||
// validate_() is where zero brightness is resolved against the light's capabilities.
|
||||
class TestableLightCall : public LightCall {
|
||||
public:
|
||||
using LightCall::LightCall;
|
||||
using LightCall::validate_;
|
||||
};
|
||||
|
||||
bool as_binary(const LightColorValues &values) {
|
||||
bool binary;
|
||||
values.as_binary(&binary);
|
||||
return binary;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// An ON/OFF light has no "on but dark" state, so a zero brightness -- how effects encode
|
||||
// their dark phase -- must turn the light off. Regression test for
|
||||
// https://github.com/esphome/esphome/issues/17873.
|
||||
TEST(LightCallOnOff, ZeroBrightnessTurnsOutputOff) {
|
||||
OnOffOutput output;
|
||||
LightState state(&output);
|
||||
TestableLightCall call(&state);
|
||||
|
||||
call.set_state(true).set_brightness(0.0f);
|
||||
auto values = call.validate_();
|
||||
|
||||
EXPECT_FALSE(as_binary(values));
|
||||
}
|
||||
|
||||
// The zero must not be stored, or no later turn-on could clear it: the capability check in
|
||||
// validate_() drops any brightness an ON/OFF light doesn't support, so a stored zero would
|
||||
// leave the light permanently off.
|
||||
TEST(LightCallOnOff, ZeroBrightnessIsNotStored) {
|
||||
OnOffOutput output;
|
||||
LightState state(&output);
|
||||
|
||||
TestableLightCall dark_call(&state);
|
||||
dark_call.set_state(true).set_brightness(0.0f);
|
||||
state.remote_values = dark_call.validate_();
|
||||
|
||||
EXPECT_FLOAT_EQ(state.remote_values.get_brightness(), 1.0f);
|
||||
|
||||
// A plain turn-on afterwards must switch the light back on.
|
||||
TestableLightCall on_call(&state);
|
||||
on_call.set_state(true);
|
||||
auto values = on_call.validate_();
|
||||
|
||||
EXPECT_TRUE(as_binary(values));
|
||||
}
|
||||
|
||||
// A plain turn-on with no brightness must still light up.
|
||||
TEST(LightCallOnOff, PlainTurnOnIsVisible) {
|
||||
OnOffOutput output;
|
||||
LightState state(&output);
|
||||
TestableLightCall call(&state);
|
||||
|
||||
call.set_state(true);
|
||||
auto values = call.validate_();
|
||||
|
||||
EXPECT_TRUE(as_binary(values));
|
||||
}
|
||||
|
||||
TEST(LightCallOnOff, TurnOffTurnsOutputOff) {
|
||||
OnOffOutput output;
|
||||
LightState state(&output);
|
||||
TestableLightCall call(&state);
|
||||
|
||||
call.set_state(false);
|
||||
auto values = call.validate_();
|
||||
|
||||
EXPECT_FALSE(as_binary(values));
|
||||
}
|
||||
|
||||
// A dimmable light can represent "on but dark", so zero brightness must be kept as-is and
|
||||
// must not be rewritten into a turn-off.
|
||||
TEST(LightCallBrightness, ZeroBrightnessStaysOnButDark) {
|
||||
BrightnessOutput output;
|
||||
LightState state(&output);
|
||||
TestableLightCall call(&state);
|
||||
|
||||
call.set_state(true).set_brightness(0.0f);
|
||||
auto values = call.validate_();
|
||||
|
||||
EXPECT_TRUE(values.is_on());
|
||||
EXPECT_FLOAT_EQ(values.get_brightness(), 0.0f);
|
||||
}
|
||||
|
||||
} // namespace esphome::light::testing
|
||||
@@ -0,0 +1,29 @@
|
||||
esphome:
|
||||
name: light-binary-effect-off
|
||||
host:
|
||||
api: # Port will be automatically injected
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
output:
|
||||
- platform: template
|
||||
id: binary_output
|
||||
type: binary
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "BINARY_OUTPUT:%s"
|
||||
args: [YESNO(state)]
|
||||
|
||||
light:
|
||||
- platform: binary
|
||||
name: "Test Binary Light"
|
||||
id: test_binary_light
|
||||
output: binary_output
|
||||
effects:
|
||||
- strobe:
|
||||
name: "Fast Strobe"
|
||||
colors:
|
||||
- state: true
|
||||
duration: 50ms
|
||||
- state: false
|
||||
duration: 50ms
|
||||
@@ -0,0 +1,21 @@
|
||||
esphome:
|
||||
name: light-binary-zero-bright
|
||||
host:
|
||||
api: # Port will be automatically injected
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
output:
|
||||
- platform: template
|
||||
id: binary_output
|
||||
type: binary
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "BINARY_OUTPUT:%s"
|
||||
args: [YESNO(state)]
|
||||
|
||||
light:
|
||||
- platform: binary
|
||||
name: "Test Binary Light"
|
||||
id: test_binary_light
|
||||
output: binary_output
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Integration test verifying the off phase of an effect reaches an ON/OFF-only light.
|
||||
|
||||
Regression test for https://github.com/esphome/esphome/issues/17873. A strobe effect
|
||||
encodes its dark phase as `brightness = 0` while keeping `state = true`, so that the
|
||||
effect keeps running instead of being stopped by an explicit turn-off. On a dimmable
|
||||
light that works, because the output is driven by `state * brightness`. On a binary
|
||||
light the dark phase used to be dropped, so the output stayed on forever.
|
||||
|
||||
Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with
|
||||
every frame), so the effect's actual output can't be observed via API state broadcasts.
|
||||
Instead, this test reads the output component's log lines, which are written on every
|
||||
update regardless of the publish flag.
|
||||
|
||||
The output log line is emitted strictly after the API state response: `perform()`
|
||||
publishes inline, but the write is deferred to the next `LightState::loop()` iteration
|
||||
and then has to cross the subprocess stdout pipe. So a future is armed *before* each
|
||||
command and awaited afterwards, rather than reading the last observed value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import EntityState, LightState
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
OUTPUT_PATTERN = re.compile(r"BINARY_OUTPUT:(YES|NO)")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_light_binary_effect_off_phase(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A strobe effect must drive a binary light's output both on and off."""
|
||||
loop = asyncio.get_running_loop()
|
||||
observed: list[bool] = []
|
||||
pending: list[asyncio.Future[bool]] = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
if match := OUTPUT_PATTERN.search(line):
|
||||
value = match.group(1) == "YES"
|
||||
observed.append(value)
|
||||
while pending:
|
||||
future = pending.pop(0)
|
||||
if not future.done():
|
||||
future.set_result(value)
|
||||
break
|
||||
|
||||
def arm_output() -> asyncio.Future[bool]:
|
||||
"""Arm a future for the next output write, before sending the command."""
|
||||
future: asyncio.Future[bool] = loop.create_future()
|
||||
pending.append(future)
|
||||
return future
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
light = next(e for e in entities if e.object_id == "test_binary_light")
|
||||
|
||||
state_futures: dict[int, asyncio.Future[LightState]] = {}
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, LightState) and state.key in state_futures:
|
||||
future = state_futures[state.key]
|
||||
if not future.done():
|
||||
future.set_result(state)
|
||||
|
||||
# ESPHome sends the current state of every entity right after connecting; drain
|
||||
# that initial burst so it can't be mistaken for the response to a command below.
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
|
||||
"""Send a light command and wait for the matching state response."""
|
||||
state_futures[light.key] = loop.create_future()
|
||||
client.light_command(key=light.key, **kwargs)
|
||||
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
|
||||
|
||||
# A plain turn-on must drive the output on -- brightness defaults to 100% and
|
||||
# must not be mistaken for a dark phase.
|
||||
output = arm_output()
|
||||
state = await send_and_wait(state=True)
|
||||
assert state.state is True
|
||||
assert await asyncio.wait_for(output, timeout=5.0) is True, (
|
||||
"Plain turn-on did not switch the output on"
|
||||
)
|
||||
|
||||
# Run the strobe effect; both phases must reach the output.
|
||||
observed.clear()
|
||||
state = await send_and_wait(effect="Fast Strobe")
|
||||
assert state.effect == "Fast Strobe"
|
||||
# Let several effect cycles run (each phase is 50ms in the fixture).
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
assert True in observed, (
|
||||
f"Strobe effect never switched the output on -- got {observed}"
|
||||
)
|
||||
assert False in observed, (
|
||||
f"Strobe effect never switched the output off; its dark phase was lost -- "
|
||||
f"got {observed}"
|
||||
)
|
||||
|
||||
# Stopping the effect must leave the light usable.
|
||||
state = await send_and_wait(effect="None")
|
||||
assert state.effect == "None"
|
||||
output = arm_output()
|
||||
state = await send_and_wait(state=True)
|
||||
assert state.state is True
|
||||
assert await asyncio.wait_for(output, timeout=5.0) is True, (
|
||||
"Light stayed off after the effect stopped"
|
||||
)
|
||||
|
||||
# An explicit turn-off still switches the output off.
|
||||
output = arm_output()
|
||||
state = await send_and_wait(state=False)
|
||||
assert state.state is False
|
||||
assert await asyncio.wait_for(output, timeout=5.0) is False, (
|
||||
"Turn-off did not switch the output off"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_light_binary_zero_brightness_is_recoverable(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Zero brightness on an ON/OFF light must not leave it permanently stuck off.
|
||||
|
||||
An ON/OFF light has no brightness capability, so `turn_on` with 0% brightness has
|
||||
no representable "on but dark" state. It must switch the output off and report the
|
||||
light as off, and a later plain turn-on must bring it back.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
pending: list[asyncio.Future[bool]] = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
if match := OUTPUT_PATTERN.search(line):
|
||||
value = match.group(1) == "YES"
|
||||
while pending:
|
||||
future = pending.pop(0)
|
||||
if not future.done():
|
||||
future.set_result(value)
|
||||
break
|
||||
|
||||
def arm_output() -> asyncio.Future[bool]:
|
||||
future: asyncio.Future[bool] = loop.create_future()
|
||||
pending.append(future)
|
||||
return future
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
light = next(e for e in entities if e.object_id == "test_binary_light")
|
||||
|
||||
state_futures: dict[int, asyncio.Future[LightState]] = {}
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, LightState) and state.key in state_futures:
|
||||
future = state_futures[state.key]
|
||||
if not future.done():
|
||||
future.set_result(state)
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
|
||||
state_futures[light.key] = loop.create_future()
|
||||
client.light_command(key=light.key, **kwargs)
|
||||
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
|
||||
|
||||
output = arm_output()
|
||||
state = await send_and_wait(state=True)
|
||||
assert state.state is True
|
||||
assert await asyncio.wait_for(output, timeout=5.0) is True
|
||||
|
||||
# Turning on at 0% brightness has no representable "on but dark" state here,
|
||||
# so the light must switch off and report itself as off.
|
||||
output = arm_output()
|
||||
state = await send_and_wait(state=True, brightness=0.0)
|
||||
assert await asyncio.wait_for(output, timeout=5.0) is False, (
|
||||
"Zero brightness did not switch the output off"
|
||||
)
|
||||
assert state.state is False, (
|
||||
"Light reported itself as on while its output was off"
|
||||
)
|
||||
|
||||
# A plain turn-on must recover -- the stored zero brightness must not persist.
|
||||
output = arm_output()
|
||||
state = await send_and_wait(state=True)
|
||||
assert state.state is True
|
||||
assert await asyncio.wait_for(output, timeout=5.0) is True, (
|
||||
"Light was left permanently off by a zero-brightness turn-on"
|
||||
)
|
||||
@@ -184,6 +184,18 @@ def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> Non
|
||||
assert "-Wl,--gc-sections" in content
|
||||
|
||||
|
||||
def test_get_component_cmakelists_globs_alternate_cpp_extensions() -> None:
|
||||
"""Both app_sources glob variants include .cc/.cxx/.c++ so vendored sources
|
||||
are compiled, matching the extensions PlatformIO's builder globs by default."""
|
||||
CORE.build_flags = set()
|
||||
from esphome.build_gen.espidf import get_component_cmakelists
|
||||
|
||||
content = get_component_cmakelists()
|
||||
for ext in ("cc", "cxx", "c++"):
|
||||
assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/*.{ext}"') == 2
|
||||
assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.{ext}"') == 2
|
||||
|
||||
|
||||
def test_get_project_cmakelists_emits_managed_components_property(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -169,30 +169,57 @@ def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
|
||||
}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
sep = "\\\\" if os.name == "nt" else "/"
|
||||
# Paths are always emitted with forward slashes so the CMakeLists is
|
||||
# portable; on Windows os.path.relpath would otherwise yield backslashes
|
||||
# that break CMake's list re-parsing.
|
||||
assert (
|
||||
content
|
||||
== f"""idf_component_register(
|
||||
SRCS "src{sep}main.c"
|
||||
== """idf_component_register(
|
||||
SRCS "src/main.c"
|
||||
INCLUDE_DIRS "src"
|
||||
REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
|
||||
REQUIRES dep ${ESPHOME_PROJECT_MANAGED_COMPONENTS} ${ESPHOME_PROJECT_BUILTIN_COMPONENTS}
|
||||
)
|
||||
target_compile_options(${{COMPONENT_LIB}} PUBLIC
|
||||
target_compile_options(${COMPONENT_LIB} PUBLIC
|
||||
"-DTEST"
|
||||
)
|
||||
target_compile_options(${{COMPONENT_LIB}} PRIVATE
|
||||
target_compile_options(${COMPONENT_LIB} PRIVATE
|
||||
"-Wall"
|
||||
)
|
||||
target_link_directories(${{COMPONENT_LIB}} INTERFACE
|
||||
target_link_directories(${COMPONENT_LIB} INTERFACE
|
||||
"lib"
|
||||
)
|
||||
target_link_libraries(${{COMPONENT_LIB}} INTERFACE
|
||||
target_link_libraries(${COMPONENT_LIB} INTERFACE
|
||||
"mylib"
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_uses_forward_slashes_on_windows(
|
||||
tmp_component, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# os.path.relpath yields backslash paths on Windows, which CMake rejects
|
||||
# when it re-parses the SRCS list (e.g. "\b" in "src\backend" is an invalid
|
||||
# character escape). Simulate that output and confirm the generated
|
||||
# CMakeLists normalizes the separators to forward slashes.
|
||||
src_dir = tmp_component.path / "src" / "backend"
|
||||
src_dir.mkdir(parents=True)
|
||||
(src_dir / "cipher.c").write_text("int f() {}")
|
||||
|
||||
tmp_component.data = {}
|
||||
|
||||
monkeypatch.setattr("esphome.espidf.component.os.sep", "\\")
|
||||
monkeypatch.setattr(
|
||||
"esphome.espidf.component.os.path.relpath",
|
||||
lambda *args, **kwargs: "src\\backend\\cipher.c",
|
||||
)
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
|
||||
assert 'SRCS "src/backend/cipher.c"' in content
|
||||
assert "\\" not in content
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_multi_token_flag(tmp_component):
|
||||
# PlatformIO shell-lexes each build.flags entry, so a single entry can
|
||||
# carry a flag and its argument. The generated CMakeLists must emit them
|
||||
|
||||
@@ -137,10 +137,17 @@ def test_parse_git_source_rejected(source: str) -> None:
|
||||
assert _parse_git_source(source) is None
|
||||
|
||||
|
||||
def _make_idf_tree(framework_path: Path) -> None:
|
||||
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for."""
|
||||
def _make_idf_tree(framework_path: Path, *, gitmodules: bool = True) -> None:
|
||||
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for.
|
||||
|
||||
``gitmodules=False`` simulates a fork that vendors components in-tree
|
||||
instead of declaring submodules; update_submodules skips the git call
|
||||
when that file is missing.
|
||||
"""
|
||||
(framework_path / "tools").mkdir(parents=True)
|
||||
(framework_path / "tools" / "idf_tools.py").write_text("# stub\n")
|
||||
if gitmodules:
|
||||
(framework_path / ".gitmodules").write_text("# stub\n")
|
||||
|
||||
|
||||
def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None:
|
||||
@@ -214,6 +221,28 @@ def test_clone_idf_with_submodules_raises_when_tree_missing(
|
||||
)
|
||||
|
||||
|
||||
def test_clone_idf_accepts_flattened_fork_without_gitmodules(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A fork that vendors components in-tree instead of as submodules is valid.
|
||||
|
||||
No .gitmodules means the submodule step is skipped entirely.
|
||||
"""
|
||||
framework_path = tmp_path / "idf"
|
||||
framework_path.mkdir()
|
||||
_make_idf_tree(framework_path, gitmodules=False)
|
||||
|
||||
with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock:
|
||||
_clone_idf_with_submodules(
|
||||
framework_path,
|
||||
"https://github.com/example/flattened-esp-idf.git",
|
||||
None,
|
||||
)
|
||||
|
||||
calls = [c.args[0] for c in run_git_command_mock.call_args_list]
|
||||
assert not any(c[1] == "submodule" for c in calls)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for _tar_extract_all hard-link prefix-stripping tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,12 @@ import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_ESPHOME,
|
||||
CONF_FRAMEWORK,
|
||||
CONF_SOURCE,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
@@ -184,6 +189,58 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
|
||||
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
|
||||
|
||||
|
||||
def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None:
|
||||
"""The jobs argument is exported to idf.py as IDF_PY_BUILD_JOBS."""
|
||||
_setup_build(setup_core)
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
|
||||
patch.object(toolchain, "_get_idf_env", return_value={"PATH": "/bin"}),
|
||||
patch.object(toolchain, "_get_idf_tool", return_value="python"),
|
||||
patch.object(toolchain.subprocess, "run") as mock_run,
|
||||
):
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
toolchain.run_idf_py("build", jobs=2)
|
||||
env = mock_run.call_args.kwargs["env"]
|
||||
assert env["IDF_PY_BUILD_JOBS"] == "2"
|
||||
assert env["PATH"] == "/bin"
|
||||
|
||||
toolchain.run_idf_py("build")
|
||||
env = mock_run.call_args.kwargs["env"]
|
||||
assert "IDF_PY_BUILD_JOBS" not in env
|
||||
|
||||
|
||||
def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None:
|
||||
"""compile_process_limit is forwarded to run_idf_py as the job limit."""
|
||||
_setup_build(setup_core)
|
||||
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 1}}
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=False),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0) as mock_run,
|
||||
patch.object(toolchain, "print_summary"),
|
||||
):
|
||||
assert toolchain.run_compile(config, verbose=False) == 0
|
||||
|
||||
mock_run.assert_called_once_with("build", "size", jobs=1)
|
||||
|
||||
|
||||
def test_run_compile_without_compile_process_limit(setup_core: Path) -> None:
|
||||
"""When no compile_process_limit is set, no job limit is passed to idf.py."""
|
||||
_setup_build(setup_core)
|
||||
config = {CONF_ESPHOME: {}}
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=False),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0) as mock_run,
|
||||
patch.object(toolchain, "print_summary"),
|
||||
):
|
||||
assert toolchain.run_compile(config, verbose=False) == 0
|
||||
|
||||
mock_run.assert_called_once_with("build", "size", jobs=None)
|
||||
|
||||
|
||||
def test_get_core_framework_version_from_core_data():
|
||||
"""The version is read from CORE.data when validation populated it."""
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
|
||||
|
||||
+1039
-60
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user