Merge remote-tracking branch 'origin/dev' into jesserockz-2026-446

This commit is contained in:
Jesse Hills
2026-07-15 14:44:56 +12:00
52 changed files with 735 additions and 123 deletions
+1 -1
View File
@@ -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.4.3
RUN uv pip install --no-cache-dir esphome-device-builder==1.5.0
RUN \
platformio settings set enable_telemetry No \
+2 -2
View File
@@ -5,7 +5,7 @@ from esphome.const import (
CONF_CLEAR,
CONF_GAIN,
CONF_ID,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_EMPTY,
ICON_BRIGHTNESS_5,
STATE_CLASS_MEASUREMENT,
)
@@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_5,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
)
@@ -65,12 +65,11 @@ optional<ParseResult> ATCMiThermometer::parse_header_(const esp32_ble_tracker::S
return {};
}
static uint8_t last_frame_count = 0;
if (last_frame_count == raw[12]) {
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count);
if (this->last_frame_count_ == raw[12]) {
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_);
return {};
}
last_frame_count = raw[12];
this->last_frame_count_ = raw[12];
return result;
}
@@ -38,6 +38,8 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT
sensor::Sensor *battery_voltage_{nullptr};
sensor::Sensor *signal_strength_{nullptr};
uint8_t last_frame_count_{0};
optional<ParseResult> parse_header_(const esp32_ble_tracker::ServiceData &service_data);
bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result);
bool report_results_(const optional<ParseResult> &result, const char *address);
+15
View File
@@ -76,12 +76,27 @@ _BLE5_BK_SYS_CONFIG_OPTIONS = [
"CFG_SUPPORT_BLE=0",
]
# Board ids upstream LibreTiny renamed; configs written against the old id
# keep validating and building against the new one (with a warning).
# generic-ln882hki -> generic-ln882h: LibreTiny v1.13.0.
_RENAMED_BOARDS = {
"generic-ln882hki": "generic-ln882h",
}
def _detect_variant(value):
if KEY_LIBRETINY not in CORE.data:
raise cv.Invalid("Family component didn't populate core data properly!")
component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA]
board = value[CONF_BOARD]
if board not in component.boards and (renamed := _RENAMED_BOARDS.get(board)):
_LOGGER.warning(
"Board '%s' was renamed to '%s'; please update your configuration",
board,
renamed,
)
value = value.copy()
value[CONF_BOARD] = board = renamed
# read board-default family if not specified
if board not in component.boards:
if CONF_FAMILY not in value:
+5 -5
View File
@@ -15,7 +15,7 @@ from esphome.const import (
CONF_NAME,
CONF_REPEAT,
CONF_TYPE,
DEVICE_CLASS_DISTANCE,
DEVICE_CLASS_EMPTY,
DEVICE_CLASS_ILLUMINANCE,
ICON_BRIGHTNESS_5,
ICON_BRIGHTNESS_6,
@@ -159,7 +159,7 @@ CONFIG_SCHEMA = cv.All(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_5,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
@@ -169,7 +169,7 @@ CONFIG_SCHEMA = cv.All(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_7,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
@@ -179,7 +179,7 @@ CONFIG_SCHEMA = cv.All(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_PROXIMITY,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DISTANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
@@ -188,7 +188,7 @@ CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(
icon=ICON_GAIN,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
+4
View File
@@ -19,6 +19,10 @@ void MCP23017::setup() {
this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_);
this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_);
// Reset IPOL to 0x00: ESPHome handles 'inverted' in software.
this->write_reg(mcp23x17_base::MCP23X17_IPOLA, 0x00);
this->write_reg(mcp23x17_base::MCP23X17_IPOLB, 0x00);
uint8_t iocon_flags = 0;
if (this->open_drain_ints_) {
iocon_flags |= IOCON_ODR;
+24 -8
View File
@@ -1,6 +1,7 @@
#include "nextion.h"
#include <cinttypes>
#include <new>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
@@ -352,8 +353,9 @@ void Nextion::loop() {
this->connection_state_.ignore_is_setup_ = false;
}
this->process_serial_(); // Receive serial data
this->process_nextion_commands_(); // Process nextion return commands
this->process_serial_(); // Receive serial data
this->process_nextion_commands_(); // Process nextion return commands
this->purge_stale_queue_entries_(); // Drop expired entries even when the display sends no data
if (!this->connection_state_.nextion_reports_is_setup_) {
if (this->started_ms_ == 0)
@@ -902,6 +904,11 @@ void Nextion::process_nextion_commands_() {
this->command_data_.erase(0, to_process_length + DELIMITER_SIZE + 1);
}
ESP_LOGN(TAG, "Loop end");
this->process_serial_();
} // Nextion::process_nextion_commands_()
void Nextion::purge_stale_queue_entries_() {
const uint32_t ms = App.get_loop_component_start_time();
if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() &&
@@ -927,10 +934,7 @@ void Nextion::process_nextion_commands_() {
}
}
}
ESP_LOGN(TAG, "Loop end");
// App.feed_wdt(); Remove before master merge
this->process_serial_();
} // Nextion::process_nextion_commands_()
}
void Nextion::set_nextion_sensor_state(int queue_type, const std::string &name, float state) {
this->set_nextion_sensor_state(static_cast<NextionQueueType>(queue_type), name, state);
@@ -1101,7 +1105,13 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
new (nextion_queue) nextion::NextionQueue();
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
nextion_queue->component = new nextion::NextionComponentBase;
nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase;
if (nextion_queue->component == nullptr) {
ESP_LOGW(TAG, "Component alloc failed");
nextion_queue->~NextionQueue();
allocator.deallocate(nextion_queue, 1);
return;
}
nextion_queue->component->set_variable_name(variable_name);
nextion_queue->queue_time = App.get_loop_component_start_time();
@@ -1157,7 +1167,13 @@ void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &va
}
new (nextion_queue) nextion::NextionQueue();
nextion_queue->component = new nextion::NextionComponentBase;
nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase;
if (nextion_queue->component == nullptr) {
ESP_LOGW(TAG, "Component alloc failed");
nextion_queue->~NextionQueue();
allocator.deallocate(nextion_queue, 1);
return;
}
nextion_queue->component->set_variable_name(variable_name);
nextion_queue->queue_time = App.get_loop_component_start_time();
nextion_queue->pending_command = command; // Store command for retry
+4
View File
@@ -1486,6 +1486,10 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
void process_nextion_commands_();
void process_serial_();
/// Drop queue entries older than max_q_age_ms_. Called from loop() so it also runs when the
/// display sends no data at all (disconnected or asleep), which would otherwise grow the queue
/// without bound.
void purge_stale_queue_entries_();
uint16_t touch_sleep_timeout_ = 0;
uint8_t wake_up_page_ = 255;
#ifdef USE_NEXTION_CONF_START_UP_PAGE
+3 -2
View File
@@ -14,6 +14,7 @@ from esphome.const import (
CONF_INFRARED,
CONF_INTEGRATION_TIME,
CONF_NAME,
DEVICE_CLASS_EMPTY,
DEVICE_CLASS_ILLUMINANCE,
ICON_BRIGHTNESS_5,
ICON_BRIGHTNESS_6,
@@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_6,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
@@ -111,7 +112,7 @@ CONFIG_SCHEMA = cv.All(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_7,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
+40 -12
View File
@@ -25,6 +25,7 @@ from esphome.const import (
CONF_OTA,
CONF_PASSWORD,
CONF_PORT,
CONF_TYPE,
CONF_USERNAME,
CONF_VERSION,
CONF_WEB_SERVER,
@@ -44,6 +45,9 @@ _LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["json", "web_server_base"]
AUTH_TYPE_BASIC = "basic"
AUTH_TYPE_DIGEST = "digest"
CONF_SORTING_GROUP_ID = "sorting_group_id"
CONF_SORTING_GROUPS = "sorting_groups"
CONF_SORTING_WEIGHT = "sorting_weight"
@@ -85,6 +89,19 @@ def validate_version_deprecated(config: ConfigType) -> ConfigType:
return config
def validate_auth_type_deprecated(auth: ConfigType) -> ConfigType:
# Remove before 2027.1.0: the default auth scheme changes from basic to digest.
if CONF_TYPE not in auth:
_LOGGER.warning(
"The 'web_server' 'auth' scheme currently defaults to 'basic', which sends the "
"password over the network in an easily reversible form. The default will change "
"to 'digest' in ESPHome 2027.1.0. To keep using basic authentication, set "
"'type: basic' under 'auth:' explicitly; otherwise set 'type: digest' now to "
"adopt the more secure scheme."
)
return auth
def validate_local(config: ConfigType) -> ConfigType:
if CONF_LOCAL in config and config[CONF_VERSION] == 1:
raise cv.Invalid("'local' is not supported in version 1")
@@ -242,15 +259,21 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ALLOWED_ORIGINS): cv.All(
cv.ensure_list(validate_origin), cv.Length(min=1)
),
cv.Optional(CONF_AUTH): cv.Schema(
{
cv.Required(CONF_USERNAME): cv.All(
cv.string_strict, cv.Length(min=1)
),
cv.Required(CONF_PASSWORD): cv.sensitive(
cv.All(cv.string_strict, cv.Length(min=1))
),
}
cv.Optional(CONF_AUTH): cv.All(
cv.Schema(
{
cv.Required(CONF_USERNAME): cv.All(
cv.string_strict, cv.Length(min=1)
),
cv.Required(CONF_PASSWORD): cv.sensitive(
cv.All(cv.string_strict, cv.Length(min=1))
),
cv.Optional(CONF_TYPE): cv.one_of(
AUTH_TYPE_BASIC, AUTH_TYPE_DIGEST, lower=True
),
}
),
validate_auth_type_deprecated,
),
cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id(
web_server_base.WebServerBase
@@ -378,10 +401,15 @@ async def to_code(config):
if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None:
cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS")
cg.add(var.set_allowed_origins(allowed_origins))
if CONF_AUTH in config:
if (auth := config.get(CONF_AUTH)) is not None:
cg.add_define("USE_WEBSERVER_AUTH")
cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME]))
cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD]))
# The scheme is fixed at build time so the unused Basic/Digest code path is compiled
# out. Basic is the current default (the absence of this define); an explicit
# 'type: digest' opts in early. Default changes to digest in 2027.1.0.
if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST:
cg.add_define("USE_WEBSERVER_AUTH_DIGEST")
cg.add(paren.set_auth_username(auth[CONF_USERNAME]))
cg.add(paren.set_auth_password(auth[CONF_PASSWORD]))
if CONF_CSS_INCLUDE in config:
cg.add_define("USE_WEBSERVER_CSS_INCLUDE")
path = CORE.relative_config_path(config[CONF_CSS_INCLUDE])
@@ -59,7 +59,15 @@ class AuthMiddlewareHandler : public MiddlewareHandler {
bool check_auth(AsyncWebServerRequest *request) {
bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str());
if (!success) {
// The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is
// compiled out. On ESP32 our own server picks the scheme internally.
#if USE_ESP32
request->requestAuthentication();
#elif defined(USE_WEBSERVER_AUTH_DIGEST)
request->requestAuthentication(nullptr, true);
#else
request->requestAuthentication(nullptr, false);
#endif
}
return success;
}
@@ -16,6 +16,11 @@
#include "utils.h"
#include "web_server_idf.h"
#ifdef USE_WEBSERVER_AUTH_DIGEST
#include <esp_random.h>
#include <esp_rom_md5.h>
#endif
#ifdef USE_WEBSERVER_OTA
#include <multipart_parser.h>
#include "multipart.h" // For parse_multipart_boundary and other utils
@@ -372,6 +377,135 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code
}
#ifdef USE_WEBSERVER_AUTH
#ifdef USE_WEBSERVER_AUTH_DIGEST
namespace {
// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated.
void bytes_to_hex(const uint8_t *data, size_t len, char *out) {
static const char HEX[] = "0123456789abcdef";
for (size_t i = 0; i < len; i++) {
out[i * 2] = HEX[data[i] >> 4];
out[i * 2 + 1] = HEX[data[i] & 0x0f];
}
out[len * 2] = '\0';
}
// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated
// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent.
// Only whole parameter names match, so "nc" does not match inside "cnonce".
StringRef digest_param(StringRef params, const char *key) {
size_t key_len = strlen(key);
const char *base = params.c_str();
size_t n = params.size();
size_t i = 0;
while (i < n) {
while (i < n && (base[i] == ' ' || base[i] == ','))
i++;
size_t name_start = i;
while (i < n && base[i] != '=' && base[i] != ',')
i++;
if (i >= n || base[i] == ',')
continue; // token without a '=', skip it
size_t name_len = i - name_start;
while (name_len > 0 && base[name_start + name_len - 1] == ' ')
name_len--;
i++; // consume '='
const char *val_start;
size_t val_len;
if (i < n && base[i] == '"') {
i++;
val_start = base + i;
while (i < n && base[i] != '"')
i++;
val_len = (base + i) - val_start;
if (i < n)
i++; // consume closing quote
} else {
val_start = base + i;
while (i < n && base[i] != ',')
i++;
val_len = (base + i) - val_start;
}
if (name_len == key_len && memcmp(base + name_start, key, key_len) == 0)
return StringRef(val_start, val_len);
while (i < n && base[i] != ',')
i++;
}
return StringRef();
}
// Verify an RFC 2617 Digest response. Stateless (the nonce we issued is not tracked), which
// matches the ESPAsyncWebServer backend used on the Arduino platforms.
bool check_digest_auth(const char *username, const char *password, const std::string &header, const char *method) {
const size_t prefix_len = sizeof("Digest ") - 1;
StringRef params(header.c_str() + prefix_len, header.size() - prefix_len);
if (digest_param(params, "username") != username)
return false;
StringRef realm = digest_param(params, "realm");
StringRef nonce = digest_param(params, "nonce");
StringRef uri = digest_param(params, "uri");
StringRef qop = digest_param(params, "qop");
StringRef nc = digest_param(params, "nc");
StringRef cnonce = digest_param(params, "cnonce");
StringRef response = digest_param(params, "response");
if (response.size() != 32)
return false;
// Compute the three MD5 hashes by streaming the pieces straight into the ROM MD5 engine, so
// nothing is concatenated on the heap. Each hash is emitted as 32 lowercase hex characters.
md5_context_t ctx;
uint8_t digest[16];
// HA1 = MD5(username:realm:password) -- uses the realm the client echoed back.
char ha1[33];
esp_rom_md5_init(&ctx);
esp_rom_md5_update(&ctx, username, strlen(username));
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, realm.c_str(), realm.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, password, strlen(password));
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), ha1);
// HA2 = MD5(method:uri) -- uses the uri the client echoed back.
char ha2[33];
esp_rom_md5_init(&ctx);
esp_rom_md5_update(&ctx, method, strlen(method));
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, uri.c_str(), uri.size());
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), ha2);
// expected = MD5(HA1:nonce:nc:cnonce:qop:HA2)
char expected[33];
esp_rom_md5_init(&ctx);
esp_rom_md5_update(&ctx, ha1, 32);
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, nonce.c_str(), nonce.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, nc.c_str(), nc.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, cnonce.c_str(), cnonce.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, qop.c_str(), qop.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, ha2, 32);
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), expected);
// Constant-time comparison of the two 32-char hex digests.
uint8_t result = 0;
for (size_t i = 0; i < 32; i++)
result |= static_cast<uint8_t>(expected[i] ^ response[i]);
return result == 0;
}
} // namespace
#endif // USE_WEBSERVER_AUTH_DIGEST
bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const {
if (username == nullptr || password == nullptr || *username == 0) {
return true;
@@ -383,9 +517,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
auto *auth_str = auth.value().c_str();
#ifdef USE_WEBSERVER_AUTH_DIGEST
// The build fixed the scheme to Digest, so the Basic path is compiled out entirely.
const auto auth_prefix_len = sizeof("Digest ") - 1;
if (strncmp("Digest ", auth_str, auth_prefix_len) != 0) {
ESP_LOGW(TAG, "Only Digest authorization supported");
return false;
}
return check_digest_auth(username, password, auth.value(), http_method_str(this->method()));
#else
const auto auth_prefix_len = sizeof("Basic ") - 1;
if (strncmp("Basic ", auth_str, auth_prefix_len) != 0) {
ESP_LOGW(TAG, "Only Basic authorization supported yet");
ESP_LOGW(TAG, "Only Basic authorization supported");
return false;
}
@@ -434,16 +577,33 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
result |= static_cast<uint8_t>(digest[i] ^ provided_ch);
}
return result == 0;
#endif // USE_WEBSERVER_AUTH_DIGEST
}
void AsyncWebServerRequest::requestAuthentication(const char *realm) const {
void AsyncWebServerRequest::requestAuthentication() const {
httpd_resp_set_hdr(*this, "Connection", "keep-alive");
// Note: realm is never configured in ESPHome, always nullptr -> "Login Required"
(void) realm; // Unused - always use default
#ifdef USE_WEBSERVER_AUTH_DIGEST
// Issue a fresh random nonce and opaque. The nonce is not stored, so this is stateless and
// does not defend against replay -- its purpose is to keep the password off the wire.
// The header value must stay alive until httpd_resp_send_err() below sends it, so the buffer
// lives on this stack frame (httpd_resp_set_hdr stores the pointer, it does not copy).
uint8_t random_bytes[16];
char nonce[33];
char opaque[33];
char header[160];
esp_fill_random(random_bytes, sizeof(random_bytes));
bytes_to_hex(random_bytes, sizeof(random_bytes), nonce);
esp_fill_random(random_bytes, sizeof(random_bytes));
bytes_to_hex(random_bytes, sizeof(random_bytes), opaque);
snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce,
opaque);
httpd_resp_set_hdr(*this, "WWW-Authenticate", header);
#else
httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\"");
#endif // USE_WEBSERVER_AUTH_DIGEST
httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr);
}
#endif
#endif // USE_WEBSERVER_AUTH
AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) {
// Check cache first - only successful lookups are cached
@@ -129,7 +129,7 @@ class AsyncWebServerRequest {
#ifdef USE_WEBSERVER_AUTH
bool authenticate(const char *username, const char *password) const;
// NOLINTNEXTLINE(readability-identifier-naming)
void requestAuthentication(const char *realm = nullptr) const;
void requestAuthentication() const;
#endif
void redirect(const std::string &url);
+1 -1
View File
@@ -274,7 +274,7 @@ async def attributes_to_code(
async def esp32_to_code(config: ConfigType) -> "MockObj":
add_idf_component(
name="espressif/esp-zigbee-lib",
ref="2.0.2",
ref="2.0.3",
)
# add sdkconfigs later so they can overwrite esp32 defaults
+3
View File
@@ -298,6 +298,7 @@
#define USE_VOICE_ASSISTANT
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_OTA
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_WEBSERVER_GZIP
@@ -408,6 +409,7 @@
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_PORT 80 // NOLINT
#endif
@@ -438,6 +440,7 @@
#define USE_LWIP_FAST_SELECT
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_ESPHOME_TASK_LOG_BUFFER
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
+1 -1
View File
@@ -48,7 +48,7 @@ dependencies:
rules:
- if: "target in [esp32, esp32p4]"
espressif/esp-zigbee-lib:
version: 2.0.2
version: 2.0.3
rules:
- if: "target in [esp32h2, esp32c5, esp32c6]"
espressif/lan87xx:
+1 -1
View File
@@ -1,4 +1,4 @@
# Useful stuff when working in a development environment
clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating
clang-tidy==22.1.7
clang-tidy==22.1.8
yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating
+7
View File
@@ -61,6 +61,13 @@ for path in components_dir.iterdir():
codeowners[f"esphome/components/{name}/*"].extend(comp.codeowners)
for platform_path in path.iterdir():
if platform_path.name == "__init__.py":
# `import pkg.__init__` is valid but distinct from `import pkg`: it re-executes
# the component's __init__.py as a second, separate module. That's harmless for
# components whose top-level code is idempotent, but not guaranteed in general
# (e.g. code that registers into a global registry with a duplicate check), so
# never treat __init__.py itself as a platform candidate.
continue
platform_name = platform_path.stem
platform = get_platform(platform_name, name)
if platform is None:
+33 -16
View File
@@ -263,22 +263,39 @@ def prepare_component_body(comp_data: dict, comp_name: str, comp_dir: Path) -> d
else {}
)
packages_value = comp_data.get("packages")
if isinstance(packages_value, dict):
common_bus_packages = get_common_bus_packages()
for pkg_name, pkg_value in list(packages_value.items()):
if pkg_name in common_bus_packages:
continue
if isinstance(pkg_value, yaml_util.IncludeFile):
pkg_value = pkg_value.load()
if isinstance(pkg_value, dict):
comp_data = merge_config(comp_data, pkg_value)
elif isinstance(packages_value, list):
for pkg_value in packages_value:
if isinstance(pkg_value, yaml_util.IncludeFile):
pkg_value = pkg_value.load()
if isinstance(pkg_value, dict):
comp_data = merge_config(comp_data, pkg_value)
# Expand component-specific package includes inline. A package include may
# itself pull in further component-specific packages (e.g. web_server's test
# includes common_v2, which includes common with the wifi/network config), so
# keep expanding until only common bus packages remain -- otherwise the nested
# includes are silently dropped when the packages key is removed below.
common_bus_packages = get_common_bus_packages()
while True:
packages_value = comp_data.get("packages")
expanded = False
if isinstance(packages_value, dict):
for pkg_name, pkg_value in list(packages_value.items()):
if pkg_name in common_bus_packages:
continue
# Drop before merging so a nested packages dict introduced by the
# include does not re-add this same key on the next iteration.
del packages_value[pkg_name]
if isinstance(pkg_value, yaml_util.IncludeFile):
pkg_value = pkg_value.load()
if isinstance(pkg_value, dict):
comp_data = merge_config(comp_data, pkg_value)
expanded = True
elif isinstance(packages_value, list):
# List-style packages never contain common bus packages, so expand
# them all and drop the key entirely.
comp_data.pop("packages", None)
for pkg_value in packages_value:
if isinstance(pkg_value, yaml_util.IncludeFile):
pkg_value = pkg_value.load()
if isinstance(pkg_value, dict):
comp_data = merge_config(comp_data, pkg_value)
expanded = True
if not expanded:
break
# Common bus packages are re-added once by the caller; drop them here.
comp_data.pop("packages", None)
@@ -0,0 +1,65 @@
"""Tests for web_server authentication codegen."""
from collections.abc import Callable
import pytest
from esphome.core import CORE
_DEFAULT_CHANGE_WARNING = "default will change to 'digest' in ESPHome 2027.1.0"
def _has_define(name: str) -> bool:
return any(d.name == name for d in CORE.defines)
def test_web_server_auth_default_is_basic_with_deprecation_warning(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth without an explicit type builds Basic and warns about the upcoming default change."""
main_cpp = generate_main(
"tests/component_tests/web_server/web_server_auth_default.yaml"
)
assert '->set_auth_username("admin");' in main_cpp
assert '->set_auth_password("password");' in main_cpp
assert _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING in caplog.text
def test_web_server_auth_explicit_basic_no_warning(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth type basic builds Basic and does not warn."""
generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml")
assert _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
def test_web_server_auth_explicit_digest(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth type digest builds Digest and does not warn."""
generate_main("tests/component_tests/web_server/web_server_auth_digest.yaml")
assert _has_define("USE_WEBSERVER_AUTH")
assert _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
def test_web_server_without_auth(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Without an auth block, no auth is compiled in and no warning is emitted."""
generate_main("tests/component_tests/web_server/web_server_no_auth.yaml")
assert not _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
type: basic
@@ -0,0 +1,17 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
type: digest
@@ -0,0 +1,14 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
@@ -1,32 +1,20 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
xl9535:
id: expander
display:
- platform: mipi_spi
id: gsl3670_display
spi_id: spi_bus
model: t-display-s3-pro
# The model's default DC pin (GPIO9) clashes with the shared i2c bus SCL
# pin, so override it onto a free pin for this test.
dc_pin: GPIO5
psram:
mode: quad
touchscreen:
# Firmware downloaded from the model's default release URL and cached.
- platform: gsl3670
model: seeed-reterminal-d1001
display: gsl3670_display
display: test_display_screen
interrupt_pin: 18
# Explicit firmware URL + SHA-256 override.
- platform: gsl3670
model: seeed-reterminal-d1001
display: gsl3670_display
display: test_display_screen
reset_pin: 10
interrupt_pin: 11
firmware:
+1 -12
View File
@@ -1,19 +1,8 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: gt911_ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${display_reset_pin}
pages:
- id: gt911_page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
touchscreen:
- platform: gt911
i2c_id: i2c_bus
id: gt911_touchscreen
display: gt911_ssd1306_i2c_display
display: test_display_screen
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}
+2 -3
View File
@@ -1,9 +1,8 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "20"
reset_pin: "21"
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
gt911: !include common.yaml
+2 -3
View File
@@ -1,9 +1,8 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "12"
reset_pin: "13"
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
gt911: !include common.yaml
+2 -3
View File
@@ -1,9 +1,8 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "20"
reset_pin: "21"
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
gt911: !include common.yaml
+13
View File
@@ -0,0 +1,13 @@
# The test_display platform (and its external_components entry) is provided by
# the shared package included from the test.*.yaml files. These extra instances
# exercise the remaining `dimensions` code paths: the width/height map form and
# the default when omitted. The package's own `test_display_screen` covers the
# "WIDTHxHEIGHT" string form.
display:
- platform: test_display
id: test_display_wh_dimensions
dimensions:
width: 320
height: 240
- platform: test_display
id: test_display_default_dimensions
@@ -0,0 +1,36 @@
import esphome.codegen as cg
from esphome.components import display
import esphome.config_validation as cv
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, CONF_WIDTH
from esphome.core import CoroPriority, coroutine_with_priority
test_display_ns = cg.esphome_ns.namespace("test_display")
TestDisplay = test_display_ns.class_("TestDisplay", display.Display)
CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(TestDisplay),
cv.Optional(CONF_DIMENSIONS, default="100x100"): cv.Any(
cv.dimensions,
cv.Schema(
{
cv.Required(CONF_WIDTH): cv.int_,
cv.Required(CONF_HEIGHT): cv.int_,
}
),
),
}
)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await display.register_display(var, config)
dimensions = config[CONF_DIMENSIONS]
if isinstance(dimensions, dict):
width, height = dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT]
else:
width, height = dimensions
cg.add(var.set_dimensions(width, height))
@@ -0,0 +1,36 @@
#pragma once
#include "esphome/components/display/display.h"
#include "esphome/core/color.h"
namespace esphome::test_display {
/** A no-op display that draws nothing and uses no pins.
*
* It exists purely to satisfy components that require a display (for example
* touchscreens, which read the display dimensions) in configurations - most
* notably YAML build tests - where a real display driver would only get in the
* way by occupying GPIO pins and pulling in bus dependencies.
*/
class TestDisplay : public display::Display {
public:
void update() override { this->do_update_(); }
void set_dimensions(int width, int height) {
this->width_ = width;
this->height_ = height;
}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
void draw_pixel_at(int x, int y, Color color) override {}
protected:
int get_width_internal() override { return this->width_; }
int get_height_internal() override { return this->height_; }
int width_{0};
int height_{0};
};
} // namespace esphome::test_display
@@ -0,0 +1,3 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
test_display_extra: !include common.yaml
@@ -0,0 +1,3 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
test_display_extra: !include common.yaml
@@ -0,0 +1,3 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
test_display_extra: !include common.yaml
+1 -12
View File
@@ -1,19 +1,8 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: tt21100_ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${disp_reset_pin}
pages:
- id: tt21100_page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
touchscreen:
- platform: tt21100
i2c_id: i2c_bus
id: tt21100_touchscreen
display: tt21100_ssd1306_i2c_display
display: test_display_screen
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}
+2 -3
View File
@@ -1,9 +1,8 @@
substitutions:
disp_reset_pin: GPIO12
interrupt_pin: GPIO15
reset_pin: GPIO4
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
tt21100: !include common.yaml
@@ -1,9 +1,8 @@
substitutions:
disp_reset_pin: GPIO0
interrupt_pin: GPIO15
reset_pin: GPIO16
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
tt21100: !include common.yaml
@@ -1,9 +1,8 @@
substitutions:
disp_reset_pin: GPIO10
interrupt_pin: GPIO2
reset_pin: GPIO3
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
tt21100: !include common.yaml
@@ -1 +1,2 @@
<<: !include common_v2.yaml
packages:
web_server: !include common_v2.yaml
@@ -1,6 +1,8 @@
<<: !include common_v2.yaml
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: digest
@@ -1 +1,8 @@
<<: !include common_v2.yaml
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: digest
@@ -1 +1,8 @@
<<: !include common_v2.yaml
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: basic
@@ -1 +1,2 @@
<<: !include common_v1.yaml
packages:
web_server: !include common_v1.yaml
@@ -1 +1,2 @@
<<: !include common_v1.yaml
packages:
web_server: !include common_v1.yaml
@@ -1 +1,2 @@
<<: !include common_v3.yaml
packages:
web_server: !include common_v3.yaml
@@ -0,0 +1,8 @@
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: basic
@@ -10,7 +10,10 @@ sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve(
import merge_component_configs # noqa: E402
from esphome import yaml_util # noqa: E402
deduplicate_by_id = merge_component_configs.deduplicate_by_id
prepare_component_body = merge_component_configs.prepare_component_body
def test_identical_duplicate_ids_collapse() -> None:
@@ -99,3 +102,56 @@ def test_nested_lists_are_checked() -> None:
}
with pytest.raises(ValueError, match="dup"):
deduplicate_by_id(data)
def test_nested_package_includes_are_fully_expanded(tmp_path: Path) -> None:
"""A package include that itself pulls in another package expands fully.
Mirrors web_server's tests, where test.yaml includes common_v2, which
includes common (holding the wifi/network config). Without recursive
expansion the nested include is dropped and network config is lost.
"""
(tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n")
(tmp_path / "common_v2.yaml").write_text(
"packages:\n device_base: !include common.yaml\nweb_server:\n port: 8080\n"
)
(tmp_path / "test.yaml").write_text(
"packages:\n web_server: !include common_v2.yaml\n"
"web_server:\n auth:\n username: admin\n"
)
comp_data = yaml_util.load_yaml(tmp_path / "test.yaml")
result = prepare_component_body(comp_data, "web_server", tmp_path)
assert "packages" not in result
assert result["wifi"] == {"ssid": "MySSID"}
assert result["web_server"] == {"port": 8080, "auth": {"username": "admin"}}
def test_common_bus_package_is_left_for_caller(tmp_path: Path) -> None:
"""Common bus packages are not expanded inline; the caller re-adds them."""
comp_data = {
"packages": {
"i2c": {"sda": 21, "scl": 22},
"device_base": {"wifi": {"ssid": "MySSID"}},
},
}
result = prepare_component_body(comp_data, "mycomp", tmp_path)
# The bus package's body must not be merged in, and the packages key is
# dropped entirely for the caller to re-add the common bus package.
assert "packages" not in result
assert "sda" not in result
assert result["wifi"] == {"ssid": "MySSID"}
def test_list_style_packages_are_expanded(tmp_path: Path) -> None:
"""List-style package includes are expanded and the key removed."""
(tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n")
(tmp_path / "test.yaml").write_text("packages:\n - !include common.yaml\n")
comp_data = yaml_util.load_yaml(tmp_path / "test.yaml")
result = prepare_component_body(comp_data, "mycomp", tmp_path)
assert "packages" not in result
assert result["wifi"] == {"ssid": "MySSID"}
@@ -0,0 +1,26 @@
# Shared "test display" package for component tests.
#
# Provides a no-op display (id: test_display_screen) that uses no pins and no
# bus, so tests that only need a display to exist -- touchscreens especially --
# don't have to instantiate a real driver and fight it over GPIOs. Include it
# like a common bus package; the consuming test does NOT need to declare
# external_components itself:
#
# packages:
# test_display: !include ../../test_build_components/common/test_display/test_display.yaml
#
# then point the touchscreen (or other display consumer) at `test_display_screen`.
#
# The test_display platform lives at tests/components/test_display/components/ and
# is loaded via external_components. The source path is written relative to the
# build directory (tests/test_build_components/build/), which every test --
# standalone or grouped -- is generated into, so this always resolves to the
# component under tests/components/test_display/.
external_components:
- source: ../../components/test_display/components
components: [test_display]
display:
- platform: test_display
id: test_display_screen
dimensions: 240x320
@@ -0,0 +1,52 @@
"""Tests for LibreTiny board detection, including renamed-board migration."""
import pytest
from esphome.components.libretiny import _detect_variant
from esphome.components.libretiny.const import (
FAMILY_LN882H,
KEY_COMPONENT_DATA,
KEY_LIBRETINY,
)
from esphome.components.ln882x import COMPONENT_DATA
import esphome.config_validation as cv
from esphome.const import CONF_BOARD, CONF_FAMILY
from esphome.core import CORE
@pytest.fixture
def ln882x_core_data() -> None:
"""Populate CORE the way the ln882x component schema does."""
CORE.data[KEY_LIBRETINY] = {KEY_COMPONENT_DATA: COMPONENT_DATA}
def test_detect_variant_known_board_passes(ln882x_core_data: None) -> None:
"""A current board id resolves its family without warnings."""
result = _detect_variant({CONF_BOARD: "generic-ln882h"})
assert result[CONF_BOARD] == "generic-ln882h"
assert result[CONF_FAMILY] == FAMILY_LN882H
def test_detect_variant_renamed_board_migrates(
ln882x_core_data: None, caplog: pytest.LogCaptureFixture
) -> None:
"""A pre-rename board id validates against the new id, with a warning."""
result = _detect_variant({CONF_BOARD: "generic-ln882hki"})
assert result[CONF_BOARD] == "generic-ln882h"
assert result[CONF_FAMILY] == FAMILY_LN882H
assert "renamed to 'generic-ln882h'" in caplog.text
def test_detect_variant_renamed_board_does_not_mutate_input(
ln882x_core_data: None,
) -> None:
"""Migration copies the config; the caller's dict keeps the old id."""
value = {CONF_BOARD: "generic-ln882hki"}
_detect_variant(value)
assert value[CONF_BOARD] == "generic-ln882hki"
def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> None:
"""Ids outside the rename map keep the family-override error."""
with pytest.raises(cv.Invalid, match="This board is unknown"):
_detect_variant({CONF_BOARD: "not-a-real-board"})