mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
@@ -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.0b2
|
||||
PROJECT_NUMBER = 2026.7.0b3
|
||||
|
||||
# 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
|
||||
|
||||
@@ -79,6 +79,48 @@ These *are* security bugs in this repo, and we want to hear about them privately
|
||||
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
|
||||
below their documented guarantees.
|
||||
|
||||
## The web server is an open HTTP API by design
|
||||
|
||||
The `web_server` component exposes a plain HTTP interface for viewing and
|
||||
controlling entities, and, when the `web_server` OTA platform is enabled, for
|
||||
uploading firmware at `/update`. Its only access controls are the optional
|
||||
`web_server` `auth:` credentials and the network the device sits on.
|
||||
|
||||
When `auth:` is not configured, every endpoint is reachable by any client that
|
||||
can reach the device. This is intentional; enabling `web_server` without `auth:`
|
||||
is choosing an open control surface, in the same way that running native OTA
|
||||
without a password leaves OTA open. The API is documented and is meant to be
|
||||
called by other devices, scripts, and pages.
|
||||
|
||||
As defense-in-depth, the web server checks the `Origin` header on browser requests
|
||||
to its entity control and state endpoints: a request whose `Origin` does not match
|
||||
the address the device is served on is rejected, and the `allowed_origins` option
|
||||
widens that list. This blocks the common "confused deputy" (CSRF) case where a page
|
||||
the operator visits drives the device through their browser. It is **not** an
|
||||
authentication boundary: it only constrains browsers. Any client that omits the
|
||||
`Origin` header — `curl`, scripts, or other non-browser callers on the same
|
||||
network — reaches every endpoint exactly as before. The check also does not cover
|
||||
the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer`
|
||||
validation. The following are therefore **not** vulnerabilities in this repository:
|
||||
|
||||
- Requests without an `Origin` header (for example `curl`) reaching the control
|
||||
endpoints, whether or not `web_server` `auth:` is set.
|
||||
- Requests from an origin the operator added to `allowed_origins`.
|
||||
- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
|
||||
web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
|
||||
covered by the `Origin` check; this is the same exposure as running OTA without a
|
||||
password.
|
||||
|
||||
The supported defenses are `web_server` `auth:`, protecting OTA (a web password or
|
||||
a native OTA password), and keeping devices on a trusted, segmented network. See
|
||||
the security best practices guide linked above.
|
||||
|
||||
What remains in scope is bypassing `web_server` `auth:` when it *is* configured,
|
||||
and any memory-safety or protocol bug in the server reachable without credentials.
|
||||
|
||||
This section documents the current design and scope; it is not a judgment that the
|
||||
design is optimal or that it will not change.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- Local attackers who already have shell access on the host that runs `esphome`.
|
||||
@@ -86,6 +128,10 @@ These *are* security bugs in this repo, and we want to hear about them privately
|
||||
- Operator-supplied hostile YAML (covered above — config authoring is trusted).
|
||||
- Attacks that require an already-authenticated device peer (someone who already
|
||||
holds the API key / OTA / web credentials).
|
||||
- Access to the device web server or its web OTA endpoint by non-browser clients
|
||||
(those that send no `Origin` header). The web server is an open HTTP API by
|
||||
design (see above); browser cross-origin requests are blocked by default, but the
|
||||
real controls are `web_server` `auth:` and network isolation.
|
||||
- Anything in the dashboard / device-builder — report that in its own repository
|
||||
(linked at the top).
|
||||
- Deployments where the operator removed protections or exposed credentials. See
|
||||
|
||||
+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.4.3
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.5.0
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -1160,6 +1160,74 @@ def _ota_downgrade_protection_errors(
|
||||
return errs
|
||||
|
||||
|
||||
_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_SIGNING_KEY): cv.file_,
|
||||
cv.Optional(CONF_VERIFICATION_KEY): cv.file_,
|
||||
cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of(
|
||||
*SIGNING_SCHEMES, lower=True
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def _validate_signed_ota_verification(value):
|
||||
if value is SCHEMA_EXTRACT:
|
||||
# Expose the inner schema so the language-schema dumper can walk the
|
||||
# signing_key / verification_key / signing_scheme options.
|
||||
return _SIGNED_OTA_VERIFICATION_SCHEMA
|
||||
if value is None:
|
||||
# A bare `signed_ota_verification:` block is valid: the default V2
|
||||
# scheme needs no keys (verify externally-signed binaries).
|
||||
value = {}
|
||||
return _validate_signed_ota_keys(_SIGNED_OTA_VERIFICATION_SCHEMA(value))
|
||||
|
||||
|
||||
def _validate_signed_ota_keys(config: ConfigType) -> ConfigType:
|
||||
"""Validate the signing/verification key combination for the selected scheme.
|
||||
|
||||
A verification key is only used by the Secure Boot V1 scheme (ecdsa_v1):
|
||||
the public key is compiled into the app so it can verify externally-signed
|
||||
images. ESP-IDF's CONFIG_SECURE_BOOT_VERIFICATION_KEY only takes effect
|
||||
when the V1 ECDSA scheme is selected and binaries are not signed during
|
||||
the build (see SECURE_BOOT_VERIFICATION_KEY in the bootloader Kconfig).
|
||||
|
||||
The V2 schemes (rsa3072, ecdsa256) embed the public key in the signature
|
||||
block appended to each image, so verifying externally-signed binaries
|
||||
needs no key in the config at all -- omitting both keys selects that
|
||||
external-signing mode.
|
||||
"""
|
||||
has_signing_key = CONF_SIGNING_KEY in config
|
||||
has_verification_key = CONF_VERIFICATION_KEY in config
|
||||
scheme = config[CONF_SIGNING_SCHEME]
|
||||
if has_signing_key and has_verification_key:
|
||||
raise cv.Invalid(
|
||||
f"Provide at most one of '{CONF_SIGNING_KEY}' and "
|
||||
f"'{CONF_VERIFICATION_KEY}', not both.",
|
||||
path=[CONF_VERIFICATION_KEY],
|
||||
)
|
||||
if scheme == "ecdsa_v1":
|
||||
if not has_signing_key and not has_verification_key:
|
||||
raise cv.Invalid(
|
||||
f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' "
|
||||
f"(to sign binaries during the build) or '{CONF_VERIFICATION_KEY}' "
|
||||
f"(to verify binaries signed externally).",
|
||||
path=[CONF_SIGNING_KEY],
|
||||
)
|
||||
elif has_verification_key:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_VERIFICATION_KEY}' is only used with signing scheme "
|
||||
f"'ecdsa_v1'. With '{scheme}' the public key is embedded in each "
|
||||
f"image's signature block, so no key file is needed to verify "
|
||||
f"externally-signed binaries: remove '{CONF_VERIFICATION_KEY}', and "
|
||||
f"set '{CONF_SIGNING_KEY}' only if binaries should be signed during "
|
||||
f"the build.",
|
||||
path=[CONF_VERIFICATION_KEY],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def final_validate(config):
|
||||
# Imported locally to avoid circular import issues
|
||||
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
|
||||
@@ -1361,7 +1429,7 @@ def final_validate(config):
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Signed OTA verification is configured with a public verification key. "
|
||||
"Signed OTA verification is enabled without a signing key. "
|
||||
"Binaries will NOT be signed automatically during build. "
|
||||
"You must sign them externally before flashing."
|
||||
)
|
||||
@@ -1640,18 +1708,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
|
||||
cv.Optional(
|
||||
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False
|
||||
): cv.boolean,
|
||||
cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_SIGNING_KEY): cv.file_,
|
||||
cv.Optional(CONF_VERIFICATION_KEY): cv.file_,
|
||||
cv.Optional(
|
||||
CONF_SIGNING_SCHEME, default="rsa3072"
|
||||
): cv.one_of(*SIGNING_SCHEMES, lower=True),
|
||||
}
|
||||
),
|
||||
cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY),
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_SIGNED_OTA_VERIFICATION
|
||||
): _validate_signed_ota_verification,
|
||||
cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema(
|
||||
{
|
||||
# eFuse key block (0-5) that stores the HMAC key from
|
||||
@@ -2498,12 +2557,16 @@ async def to_code(config):
|
||||
signed_ota[CONF_SIGNING_KEY].resolve().as_posix(),
|
||||
)
|
||||
else:
|
||||
# Public key mode — verification only, external signing required
|
||||
# External signing mode — binaries must be signed after the build
|
||||
add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False)
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_SECURE_BOOT_VERIFICATION_KEY",
|
||||
signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(),
|
||||
)
|
||||
if CONF_VERIFICATION_KEY in signed_ota:
|
||||
# V1 ECDSA only: the public key is compiled into the app to
|
||||
# verify externally-signed images. V2 schemes carry the public
|
||||
# key in each image's signature block and need no key here.
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_SECURE_BOOT_VERIFICATION_KEY",
|
||||
signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(),
|
||||
)
|
||||
|
||||
cg.add_define("USE_OTA_SIGNED_VERIFICATION")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import logging
|
||||
import re
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import web_server_base
|
||||
@@ -24,6 +25,7 @@ from esphome.const import (
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_TYPE,
|
||||
CONF_USERNAME,
|
||||
CONF_VERSION,
|
||||
CONF_WEB_SERVER,
|
||||
@@ -43,9 +45,13 @@ _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"
|
||||
CONF_ALLOWED_ORIGINS = "allowed_origins"
|
||||
|
||||
|
||||
web_server_ns = cg.esphome_ns.namespace("web_server")
|
||||
@@ -83,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")
|
||||
@@ -104,6 +123,41 @@ def validate_ota(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
# An Origin header is always "scheme://host[:port]" with no path or trailing slash.
|
||||
_ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$")
|
||||
|
||||
|
||||
def validate_origin(value: str) -> str:
|
||||
# "*" is the wildcard that allows any origin.
|
||||
if value == "*":
|
||||
return value
|
||||
value = cv.string_strict(value)
|
||||
if not _ORIGIN_RE.match(value):
|
||||
raise cv.Invalid(
|
||||
f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no "
|
||||
f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin."
|
||||
)
|
||||
# Browsers send the scheme and host lowercased in the Origin header, so normalize to match.
|
||||
return value.lower()
|
||||
|
||||
|
||||
def validate_private_network_access(config: ConfigType) -> ConfigType:
|
||||
# PNA preflights are always cross-origin, so they can only be authorized against the
|
||||
# allowed_origins list. Enabling PNA without any origins would deny every PNA request.
|
||||
if (
|
||||
config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]
|
||||
and config.get(CONF_ALLOWED_ORIGINS) is None
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_ALLOWED_ORIGINS}' must be set when "
|
||||
f"'{CONF_ENABLE_PRIVATE_NETWORK_ACCESS}' is enabled. List each origin that is "
|
||||
f"allowed to reach the device (e.g. 'https://example.com'). '*' allows any origin "
|
||||
f"but is not recommended.",
|
||||
path=[CONF_ENABLE_PRIVATE_NETWORK_ACCESS],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def validate_sorting_groups(config: ConfigType) -> ConfigType:
|
||||
if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3:
|
||||
raise cv.Invalid(
|
||||
@@ -201,16 +255,25 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_CSS_INCLUDE): cv.file_,
|
||||
cv.Optional(CONF_JS_URL): cv.string,
|
||||
cv.Optional(CONF_JS_INCLUDE): cv.file_,
|
||||
cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=True): cv.boolean,
|
||||
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_ENABLE_PRIVATE_NETWORK_ACCESS, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ALLOWED_ORIGINS): cv.All(
|
||||
cv.ensure_list(validate_origin), 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
|
||||
@@ -238,6 +301,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_local,
|
||||
validate_sorting_groups,
|
||||
validate_ota,
|
||||
validate_private_network_access,
|
||||
_consume_web_server_sockets,
|
||||
)
|
||||
|
||||
@@ -334,10 +398,18 @@ async def to_code(config):
|
||||
request_log_listener() # Request a log listener slot for web server log streaming
|
||||
if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]:
|
||||
cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS")
|
||||
if CONF_AUTH in 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 (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])
|
||||
|
||||
@@ -456,9 +456,58 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Read a request header value portably across the Arduino and ESP-IDF web servers.
|
||||
// Returns an empty string when the header is absent (only allocates when a value is present).
|
||||
static std::string get_request_header(AsyncWebServerRequest *request, const char *name) {
|
||||
#ifdef USE_ESP32
|
||||
// ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend.
|
||||
optional<std::string> value = request->get_header(name);
|
||||
return value.has_value() ? std::move(*value) : std::string();
|
||||
#else
|
||||
// ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend.
|
||||
const AsyncWebHeader *header = request->getHeader(name);
|
||||
return header != nullptr ? std::string(header->value().c_str()) : std::string();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) {
|
||||
// No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow.
|
||||
if (origin.empty())
|
||||
return true;
|
||||
|
||||
// Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to.
|
||||
// This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time.
|
||||
const size_t scheme_sep = origin.find("://");
|
||||
if (scheme_sep != std::string::npos) {
|
||||
const std::string host = get_request_header(request, "Host");
|
||||
if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
|
||||
// Otherwise the origin must be explicitly allowed via configuration.
|
||||
for (const char *allowed_origin : this->allowed_origins_) {
|
||||
// A single "*" entry allows any origin.
|
||||
if (allowed_origin[0] == '*' && allowed_origin[1] == '\0')
|
||||
return true;
|
||||
if (origin == allowed_origin)
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
|
||||
void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) {
|
||||
const std::string origin = get_request_header(request, "Origin");
|
||||
if (!this->is_request_origin_allowed_(request, origin)) {
|
||||
request->send(403);
|
||||
return;
|
||||
}
|
||||
|
||||
AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F(""));
|
||||
// Echo the specific origin back so the response is valid even when auth (credentials) is enabled.
|
||||
response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str());
|
||||
response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true"));
|
||||
response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str());
|
||||
char mac_s[18];
|
||||
@@ -2448,6 +2497,21 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
|
||||
// Private Network Access preflight carries a cross-origin Origin by design; its handler does the
|
||||
// origin check itself, so let it run before the general enforcement below.
|
||||
if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) {
|
||||
this->handle_pna_cors_request(request);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Reject cross-origin browser requests unless the origin is explicitly allowed.
|
||||
if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) {
|
||||
request->send(403);
|
||||
return;
|
||||
}
|
||||
|
||||
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
|
||||
if (url == ESPHOME_F("/events")) {
|
||||
this->events_.add_new_client(this, request);
|
||||
@@ -2469,13 +2533,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
|
||||
if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) {
|
||||
this->handle_pna_cors_request(request);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Parse URL for component routing
|
||||
// Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action)
|
||||
UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST);
|
||||
|
||||
@@ -242,6 +242,22 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
|
||||
*/
|
||||
void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; }
|
||||
|
||||
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
|
||||
/** Set the origins that browsers are allowed to make cross-origin requests from.
|
||||
*
|
||||
* Requests without an `Origin` header (e.g. non-browser clients like curl or the native API)
|
||||
* are always allowed. Requests whose `Origin` matches the address the device is served on
|
||||
* (same-origin) are always allowed. Any other browser origin must appear in this list, or the
|
||||
* request is rejected. A single "*" entry allows any origin. Each other entry must exactly match
|
||||
* the requesting page's `Origin` header (e.g. "https://example.com").
|
||||
*
|
||||
* This list is also used to authorize Private Network Access requests when that feature is enabled.
|
||||
*
|
||||
* @param origins The list of allowed origins.
|
||||
*/
|
||||
void set_allowed_origins(std::initializer_list<const char *> origins) { this->allowed_origins_ = origins; }
|
||||
#endif
|
||||
|
||||
// ========== INTERNAL METHODS ==========
|
||||
// (In most use cases you won't need these)
|
||||
/// Setup the internal web server and register handlers.
|
||||
@@ -593,6 +609,16 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
|
||||
const char *js_include_{nullptr};
|
||||
#endif
|
||||
bool expose_log_{true};
|
||||
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
|
||||
// Extra origins allowed to make cross-origin browser requests ("*" means any origin).
|
||||
// Only compiled when allowed_origins is configured; same-origin is always allowed regardless.
|
||||
FixedVector<const char *> allowed_origins_;
|
||||
#endif
|
||||
|
||||
/// Check whether the given request Origin is permitted. Same-origin (matching the Host the
|
||||
/// request was sent to) and requests without an Origin header are always allowed; any other
|
||||
/// origin must be listed in allowed_origins. The caller passes the already-read Origin header.
|
||||
bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin);
|
||||
|
||||
private:
|
||||
#ifdef USE_SENSOR
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.7.0b2"
|
||||
__version__ = "2026.7.0b3"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -298,10 +298,12 @@
|
||||
#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
|
||||
#define USE_WEBSERVER_SORTING
|
||||
#define USE_WEBSERVER_ALLOWED_ORIGINS
|
||||
#define WEB_SERVER_DEFAULT_HEADERS_COUNT 1
|
||||
#define USE_CAPTIVE_PORTAL_GZIP
|
||||
#define USE_WIFI_11KV_SUPPORT
|
||||
@@ -407,6 +409,7 @@
|
||||
|
||||
#define USE_WEBSERVER
|
||||
#define USE_WEBSERVER_AUTH
|
||||
#define USE_WEBSERVER_AUTH_DIGEST
|
||||
#define USE_WEBSERVER_PORT 80 // NOLINT
|
||||
#endif
|
||||
|
||||
@@ -437,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -665,3 +665,78 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None:
|
||||
# No project version and no signing -> two distinct errors.
|
||||
errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False)
|
||||
assert len(errs) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
# V2 schemes: signing key (sign during build) or no key at all
|
||||
# (external signing; the public key travels in the signature block).
|
||||
{"signing_scheme": "rsa3072", "signing_key": "key.pem"},
|
||||
{"signing_scheme": "rsa3072"},
|
||||
{"signing_scheme": "ecdsa256", "signing_key": "key.pem"},
|
||||
{"signing_scheme": "ecdsa256"},
|
||||
# V1 ECDSA: exactly one of signing key / verification key.
|
||||
{"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"},
|
||||
{"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"},
|
||||
],
|
||||
)
|
||||
def test_signed_ota_keys_valid_combinations(config: dict) -> None:
|
||||
from esphome.components.esp32 import _validate_signed_ota_keys
|
||||
|
||||
assert _validate_signed_ota_keys(config) is config
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, {}])
|
||||
def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -> None:
|
||||
"""A bare `signed_ota_verification:` block is valid: the default V2
|
||||
scheme embeds the public key in the signature block, so verifying
|
||||
externally-signed binaries needs no keys in the config."""
|
||||
from esphome.components.esp32 import _validate_signed_ota_verification
|
||||
|
||||
config = _validate_signed_ota_verification(value)
|
||||
assert config == {"signing_scheme": "rsa3072"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "match"),
|
||||
[
|
||||
# A verification key is meaningless with the V2 schemes -- the public
|
||||
# key is embedded in each image's signature block.
|
||||
(
|
||||
{"signing_scheme": "rsa3072", "verification_key": "key.bin"},
|
||||
"only used with signing scheme 'ecdsa_v1'",
|
||||
),
|
||||
(
|
||||
{"signing_scheme": "ecdsa256", "verification_key": "key.bin"},
|
||||
"only used with signing scheme 'ecdsa_v1'",
|
||||
),
|
||||
# V1 ECDSA needs a key either way.
|
||||
(
|
||||
{"signing_scheme": "ecdsa_v1"},
|
||||
"Signing scheme 'ecdsa_v1' requires either",
|
||||
),
|
||||
# Never both keys at once.
|
||||
(
|
||||
{
|
||||
"signing_scheme": "rsa3072",
|
||||
"signing_key": "key.pem",
|
||||
"verification_key": "key.bin",
|
||||
},
|
||||
"not both",
|
||||
),
|
||||
(
|
||||
{
|
||||
"signing_scheme": "ecdsa_v1",
|
||||
"signing_key": "key.pem",
|
||||
"verification_key": "key.bin",
|
||||
},
|
||||
"not both",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
|
||||
from esphome.components.esp32 import _validate_signed_ota_keys
|
||||
|
||||
with pytest.raises(cv.Invalid, match=match):
|
||||
_validate_signed_ota_keys(config)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for web_server Private Network Access / allowed_origins validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.web_server import (
|
||||
CONF_ALLOWED_ORIGINS,
|
||||
validate_origin,
|
||||
validate_private_network_access,
|
||||
)
|
||||
from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
def test_pna_enabled_without_origins_fails() -> None:
|
||||
"""Enabling PNA without allowed_origins must fail validation."""
|
||||
config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True}
|
||||
|
||||
with pytest.raises(cv.Invalid) as exc_info:
|
||||
validate_private_network_access(config)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert CONF_ALLOWED_ORIGINS in error_msg
|
||||
assert "must be set" in error_msg
|
||||
|
||||
|
||||
def test_pna_enabled_with_origins_passes() -> None:
|
||||
"""Enabling PNA with at least one allowed origin passes validation."""
|
||||
config: ConfigType = {
|
||||
CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True,
|
||||
CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"],
|
||||
}
|
||||
assert validate_private_network_access(config) == config
|
||||
|
||||
|
||||
def test_origins_without_pna_passes() -> None:
|
||||
"""allowed_origins can be set without enabling PNA (they are independent)."""
|
||||
config: ConfigType = {
|
||||
CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False,
|
||||
CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"],
|
||||
}
|
||||
assert validate_private_network_access(config) == config
|
||||
|
||||
|
||||
def test_pna_disabled_without_origins_passes() -> None:
|
||||
"""PNA disabled and no origins specified passes validation."""
|
||||
config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False}
|
||||
assert validate_private_network_access(config) == config
|
||||
|
||||
|
||||
def test_validate_origin_wildcard() -> None:
|
||||
"""The '*' wildcard is accepted as-is."""
|
||||
assert validate_origin("*") == "*"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"https://example.com",
|
||||
"http://example.com:8080",
|
||||
"https://192.168.1.5",
|
||||
],
|
||||
)
|
||||
def test_validate_origin_valid(value: str) -> None:
|
||||
"""Well-formed origins pass through unchanged."""
|
||||
assert validate_origin(value) == value
|
||||
|
||||
|
||||
def test_validate_origin_lowercased() -> None:
|
||||
"""Scheme and host are normalized to lowercase to match the browser Origin header."""
|
||||
assert validate_origin("HTTPS://App.Example.com") == "https://app.example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"https://example.com/", # trailing slash
|
||||
"https://example.com/path", # path segment
|
||||
"example.com", # missing scheme
|
||||
"", # empty
|
||||
],
|
||||
)
|
||||
def test_validate_origin_invalid(value: str) -> None:
|
||||
"""Malformed origins are rejected at config time instead of silently 403ing."""
|
||||
with pytest.raises(cv.Invalid, match="not a valid origin"):
|
||||
validate_origin(value)
|
||||
@@ -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:
|
||||
@@ -0,0 +1,11 @@
|
||||
# Secure Boot V2 schemes carry the public key inside each image's signature
|
||||
# block, so verifying externally-signed binaries needs no key in the config:
|
||||
# a bare block enables verification with the default rsa3072 scheme.
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
signed_ota_verification:
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -1,5 +1,5 @@
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
|
||||
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
|
||||
|
||||
xl9535:
|
||||
@@ -10,6 +10,9 @@ display:
|
||||
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
|
||||
|
||||
@@ -5,3 +5,6 @@ web_server:
|
||||
port: 8080
|
||||
version: 2
|
||||
compression: br
|
||||
enable_private_network_access: true
|
||||
allowed_origins:
|
||||
- https://app.esphome.io
|
||||
|
||||
@@ -4,6 +4,9 @@ packages:
|
||||
web_server:
|
||||
port: 8080
|
||||
version: 3
|
||||
# allowed_origins can be set independently of Private Network Access
|
||||
allowed_origins:
|
||||
- https://app.esphome.io
|
||||
sorting_groups:
|
||||
- id: sorting_group_1
|
||||
name: "Group 1 Diplayed Last"
|
||||
|
||||
@@ -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,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"})
|
||||
Reference in New Issue
Block a user