Compare commits

..
Author SHA1 Message Date
Jesse Hills 2716c3161c [weikai] Rename check_channel_down_ for protected-method naming
Dropping `virtual` from the now-`final` WeikaiChannel turned check_channel_down
into a plain protected method, which clang-tidy requires to end with `_`.
2026-07-15 13:01:44 +12:00
Jesse Hills 9111779caa Merge remote-tracking branch 'origin/dev' into jesserockz-2026-392 2026-07-15 12:59:56 +12:00
Jesse Hills a5583dcba6 [tests] Add test_display component to free touchscreen tests from display pins (#17540) 2026-07-15 11:22:47 +12:00
luar123 e2b62bcd00 [zigbee] bump esp-zigbee-sdk to 2.0.3 (#17564) 2026-07-14 17:27:17 -04:00
Jonathan Swoboda 4275343231 [atc_mithermometer] Make duplicate-packet counter per-instance (#17496) 2026-07-15 09:23:37 +12:00
dependabot[bot] 4ebe49b141 Bump clang-tidy from 22.1.7 to 22.1.8 (#17565)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 09:54:02 -10:00
Jesse Hills d3adb6fb81 Merge branch 'beta' into dev 2026-07-14 16:46:31 +12:00
Jesse Hills 00d2d55e09 Merge pull request #17556 from esphome/bump-2026.7.0b3
2026.7.0b3
2026-07-14 16:45:55 +12:00
esphome[bot] a833685a73 Bump bundled esphome-device-builder to 1.5.0 (#17558) 2026-07-14 14:51:55 +12:00
esphome[bot] 2753ab1f45 Bump bundled esphome-device-builder to 1.5.0 (#17558) 2026-07-13 16:50:08 -10:00
Daniele PalumboandJesse Hills b6b5b61640 [mcp23017] reset IPOL registers to 0x00 on setup (#17177)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-14 02:38:07 +00:00
Jesse Hills 0a1065da75 [script] Recursively expand nested packages when merging component tests (#17557) 2026-07-14 14:11:18 +12:00
Jesse Hills db9a09d05a [script] Recursively expand nested packages when merging component tests (#17557) 2026-07-14 14:11:03 +12:00
Jesse Hills 6fadf353b1 Bump version to 2026.7.0b3 2026-07-14 12:40:45 +12:00
Keith Burzinski 8da377ab43 [nextion] Fix unbounded queue growth and OOM crash when display sends no data (#17553) 2026-07-14 12:40:45 +12:00
Jonathan Swoboda 1da8900ffc [veml7700][as7341][ltr501] Fix device class on raw-count sensors (#17549) 2026-07-14 12:40:45 +12:00
J. Nick Koston 519ce38b79 [libretiny] Keep renamed board generic-ln882hki validating against generic-ln882h (#17542) 2026-07-14 12:40:45 +12:00
Jesse Hills 989797be53 [web_server] Add HTTP digest authentication with selectable scheme (#17541) 2026-07-14 12:40:45 +12:00
Jesse Hills 583adc9e69 [web_server] Use dict-style packages in tests so they can be batch-grouped (#17544) 2026-07-14 12:40:45 +12:00
Keith Burzinski af9a0404d9 [esp32] Do not require verification_key with Secure Boot V2 signing schemes (#17497) 2026-07-14 12:40:45 +12:00
Jesse Hills f38e7f2de2 [web_server] Add CORS origin checking with allowed_origins (#17530) 2026-07-14 12:40:22 +12:00
Jesse Hills 3e75020007 [gsl3670] Fix i2c package variant in esp32-s3-idf test (#17535) 2026-07-14 12:39:14 +12:00
Keith Burzinski ca77cc585c [nextion] Fix unbounded queue growth and OOM crash when display sends no data (#17553) 2026-07-14 11:56:03 +12:00
Jonathan Swoboda f1e4726f4e [veml7700][as7341][ltr501] Fix device class on raw-count sensors (#17549) 2026-07-14 11:53:31 +12:00
J. Nick Koston 49bbceb1da [libretiny] Keep renamed board generic-ln882hki validating against generic-ln882h (#17542) 2026-07-14 11:51:54 +12:00
Jesse Hills 65d6c028ce [web_server] Add HTTP digest authentication with selectable scheme (#17541) 2026-07-14 10:33:44 +12:00
Clyde Stubbs 5e3e2f82c9 [script] Fix duplicate import in build_codeowners.py (#17543) 2026-07-13 10:16:53 -04:00
Jesse Hills d78cb09b17 [web_server] Use dict-style packages in tests so they can be batch-grouped (#17544) 2026-07-13 23:23:55 +12:00
Keith Burzinski bcfb438a81 [esp32] Do not require verification_key with Secure Boot V2 signing schemes (#17497) 2026-07-13 16:23:40 +12:00
Jesse Hills a8dfd00cc6 [web_server] Add CORS origin checking with allowed_origins (#17530) 2026-07-13 15:38:48 +12:00
Jesse Hills 4a82b10783 [ci] Group component test output into collapsible CI log sections (#17536) 2026-07-13 15:29:35 +12:00
Jesse Hills 07460ebee4 [gsl3670] Fix i2c package variant in esp32-s3-idf test (#17535) 2026-07-13 15:28:55 +12:00
Jesse Hills cdc42e5e55 Merge branch 'beta' into dev 2026-07-13 13:25:21 +12:00
Jonathan Swoboda b3e03868b3 [mipi_rgb] Test in isolation to avoid bus/pin merge conflicts (#17534) 2026-07-13 12:00:22 +12:00
Jesse Hills 0ff11674ef [mipi_rgb] Use dict-style packages in test so it can be batch-grouped (#17533) 2026-07-12 18:38:38 -04:00
Keith Burzinski 434cffb745 [zwave_proxy] Fix parser gaps and harden frame and subscription handling (#17461) 2026-07-13 08:58:17 +12:00
J. Nick Koston 2b3027a7fd [api] Provision encryption keys over an encrypted zero-PSK noise connection (#17482) 2026-07-13 08:55:30 +12:00
Jesse Hills e27a14ec70 [core] Classify entity metadata visibility for the visual editor (#17503) 2026-07-12 08:33:29 -04:00
luar123 91c42381f6 [zigbee] prevent task watchdog trigger with large configs. (#17506) 2026-07-12 08:32:01 -04:00
luar123 a39607476a [zigbee] Fix merge endpoint (#17511) 2026-07-12 08:31:39 -04:00
J. Nick Koston b098571a6f [web_server] Fix unused function warning for json_state_str (#17524) 2026-07-11 14:31:14 -10:00
J. Nick Koston 614fd88829 [mdns] Fix missing device info TXT records when native API is not enabled (#17520) 2026-07-11 14:31:01 -10:00
J. Nick Koston c0636e2bf7 [core] Make config-hash independent of machine-local paths (#17523) 2026-07-11 14:30:47 -10:00
esphome[bot] 65353006c8 Bump bundled esphome-device-builder to 1.4.3 (#17522) 2026-07-11 13:32:45 -10:00
tomaszduda23 35a99f478e [deep_sleep] feed watchdog in deep sleep (#17516) 2026-07-11 09:48:11 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b6a4dd237e Update tzdata requirement from >=2026.2 to >=2026.3 (#17510)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 09:45:42 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 0ef85783dc Bump actions/stale from 10.3.0 to 10.4.0 (#17509)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 09:45:23 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5020179210 Bump ruff from 0.15.20 to 0.15.21 (#17508)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 09:45:02 -04:00
Clyde StubbsandClaude Opus 4.8 54529412dc [mipi_dsi] New model for M5Stack Tab5 (#17500)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:38:01 -04:00
Clyde Stubbsandpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> e6525b5d93 [mipi][mipi_spi] SWRESET handling improved (#17504)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-07-11 09:36:26 -04:00
Raymond 9f62cf9243 [mipi_dsi] Add JC8012P4A1-V2 (#17457) 2026-07-11 21:24:00 +10:00
esphome[bot] 478bca026c Bump bundled esphome-device-builder to 1.4.2 (#17512) 2026-07-10 16:37:00 -10:00
Clyde StubbsandClaude Opus 4.8 e1719cd85d [mipi][mipi_spi][mipi_dsi][mipi_rgb] Transform cleanup (#17405)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:06:40 -04:00
esphome[bot] 83aaed71e1 Bump bundled esphome-device-builder to 1.4.1 (#17507) 2026-07-10 10:30:19 -10:00
Jesse Hills 88ca0d44e0 [docs] Document web server as an open HTTP API by design in threat model (#17465) 2026-07-10 16:38:23 +12:00
esphome[bot] 7a1e0bbbec Bump bundled esphome-device-builder to 1.4.0 (#17495) 2026-07-09 15:36:42 -10:00
Jonathan Swoboda b2226b91ff [web_server] Serialize entity state strings without a copy buffer (#17488) 2026-07-09 16:51:04 -04:00
dependabot[bot] ba84f2ec55 Bump aioesphomeapi from 45.5.2 to 45.6.0 (#17490)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 19:53:42 +00:00
dependabot[bot] 4b19de0c1a Bump CodSpeedHQ/action from 4.18.4 to 4.18.5 (#17489)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 19:35:16 +00:00
Jonathan Swoboda 4292e7988e [mcp4461] Fix wiper increment/decrement write length (#17487) 2026-07-09 15:27:05 -04:00
Jonathan Swoboda 9f21fd0b55 [usb_uart] Fix output chunk length truncated to 8 bits (#17480) 2026-07-09 15:04:42 -04:00
Jesse Hills 9327d011fc Merge branch 'beta' into dev 2026-07-09 16:54:49 +12:00
Jonathan Swoboda 435dde67d0 [ci] Stop per-PR cache copies from crowding the 10GB Actions cache quota (#17463) 2026-07-08 23:29:23 -04:00
Jonathan Swoboda 9c92ab63fb [gsl3670] Reference the test display explicitly so grouped CI builds validate (#17462) 2026-07-09 13:43:56 +12:00
Elvin Luff acc8381cbb [epaper_spi] Remove noop deep sleep command (#15595) 2026-07-09 11:29:39 +10:00
Jesse Hills 0a8a7e22d2 Bump version to 2026.8.0-dev 2026-07-09 12:30:54 +12:00
Jesse Hills 5a18d1ef50 [weikai] Mark configurable classes as final 2026-06-30 10:41:03 +12:00
75 changed files with 1816 additions and 242 deletions
@@ -35,6 +35,9 @@ runs:
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
+3
View File
@@ -32,6 +32,9 @@ jobs:
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
# would only consume quota.
save-cache: "false"
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
+60 -7
View File
@@ -52,6 +52,9 @@ jobs:
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
@@ -174,6 +177,9 @@ jobs:
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
@@ -375,6 +381,9 @@ jobs:
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
@@ -456,7 +465,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4
uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
with:
run: |
. venv/bin/activate
@@ -828,11 +837,12 @@ jobs:
- name: List components
run: echo ${{ matrix.batch.components }}
- name: Cache apt packages
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
- name: Install apt packages
# Not cached: this job is pull-request-only, so a cache save could
# never be shared and would only consume quota.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -1006,6 +1016,36 @@ jobs:
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
pre-commit-seed-cache:
name: Seed pre-commit cache
runs-on: ubuntu-latest
needs:
- common
# Saves a dev-scoped pre-commit cache that pull request runs can
# restore, since pre-commit.ci lite itself never runs on dev pushes.
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache pre-commit environments
id: cache-pre-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the restore key in pre-commit-ci-lite
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit hook environments
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
run: |
python -m pip install pre-commit
pre-commit install-hooks
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
@@ -1021,9 +1061,22 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache
# Inlined from esphome/pre-commit-action with a restore-only cache
# step: the pre-commit-seed-cache job owns saving this cache, so
# pull request runs never write per-PR copies.
- name: Restore pre-commit cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the key pre-commit-seed-cache saves
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
env:
SKIP: pylint,ci-custom
run: |
python -m pip install pre-commit
pre-commit run --show-diff-on-failure --color=always --all-files
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Stale
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
+1 -1
View File
@@ -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.8.0-dev
# 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
+46
View File
@@ -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
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);
@@ -14,10 +14,9 @@ void EPaperMono::refresh_screen(bool partial) {
}
void EPaperMono::deep_sleep() {
ESP_LOGV(TAG, "Deep sleep");
if (this->is_using_partial_update_()) {
this->cmd_data(0x10, {0x00}); // sleep in power on mode
} else {
// Deep sleep loses RAM so cannot be used with partial update
if (!this->is_using_partial_update_()) {
ESP_LOGV(TAG, "Deep sleep");
this->cmd_data(0x10, {0x03}); // deep sleep
}
}
+81 -18
View File
@@ -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")
+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;
@@ -318,4 +318,232 @@ DsiDriverChip(
(0xE0, 0x00),
]
)
# JC8012P4A1 V2 Driver Configuration (jd9365)
# Some units of this model have a different LCD panel but still use the same JD9365 driver chip.
# Using parameters from esp_lcd_jd9365.h and the working full init sequence
# ----------------------------------------------------------------------------------------------------------------------
# * Resolution: 800x1280
# * PCLK Frequency: 70 MHz
# * DSI Lane Bit Rate: 1.5 Gbps (using 2-Lane DSI configuration)
# * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40)
# * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=10, vsync_front_porch=20)
# ----------------------------------------------------------------------------------------------------------------------
DsiDriverChip(
"JC8012P4A1-V2",
width=800,
height=1280,
hsync_back_porch=20,
hsync_pulse_width=20,
hsync_front_porch=40,
vsync_back_porch=10,
vsync_pulse_width=4,
vsync_front_porch=20,
pclk_frequency="70MHz",
lane_bit_rate="1500Mbps",
color_order="RGB",
reset_pin=27,
initsequence=[
(0xE0, 0x00),
(0xE1, 0x93),
(0xE2, 0x65),
(0xE3, 0xF8),
(0x80, 0x01),
(0xE0, 0x01),
(0x00, 0x00),
(0x01, 0x44),
(0x03, 0x10),
(0x04, 0x38),
(0x0C, 0x74),
(0x17, 0x00),
(0x18, 0xAF),
(0x19, 0x00),
(0x1A, 0x00),
(0x1B, 0xAF),
(0x1C, 0x00),
(0x35, 0x26),
(0x37, 0x09),
(0x38, 0x04),
(0x39, 0x00),
(0x3A, 0x01),
(0x3C, 0x78),
(0x3D, 0xFF),
(0x3E, 0xFF),
(0x3F, 0x7F),
(0x40, 0x06),
(0x41, 0xA0),
(0x42, 0x81),
(0x43, 0x1E),
(0x44, 0x0D),
(0x45, 0x28),
(0x55, 0x02),
(0x57, 0x69),
(0x59, 0x0A),
(0x5A, 0x2A),
(0x5B, 0x17),
(0x5D, 0x7F),
(0x5E, 0x6B),
(0x5F, 0x5C),
(0x60, 0x50),
(0x61, 0x4C),
(0x62, 0x3E),
(0x63, 0x41),
(0x64, 0x2B),
(0x65, 0x43),
(0x66, 0x42),
(0x67, 0x43),
(0x68, 0x62),
(0x69, 0x52),
(0x6A, 0x5A),
(0x6B, 0x4C),
(0x6C, 0x48),
(0x6D, 0x3A),
(0x6E, 0x28),
(0x6F, 0x10),
(0x70, 0x7F),
(0x71, 0x6B),
(0x72, 0x5C),
(0x73, 0x50),
(0x74, 0x4C),
(0x75, 0x3E),
(0x76, 0x41),
(0x77, 0x2B),
(0x78, 0x43),
(0x79, 0x42),
(0x7A, 0x43),
(0x7B, 0x62),
(0x7C, 0x52),
(0x7D, 0x5A),
(0x7E, 0x4C),
(0x7F, 0x48),
(0x80, 0x3A),
(0x81, 0x28),
(0x82, 0x10),
(0xE0, 0x02),
(0x00, 0x42),
(0x01, 0x42),
(0x02, 0x40),
(0x03, 0x40),
(0x04, 0x5E),
(0x05, 0x5E),
(0x06, 0x5F),
(0x07, 0x5F),
(0x08, 0x5F),
(0x09, 0x57),
(0x0A, 0x57),
(0x0B, 0x77),
(0x0C, 0x77),
(0x0D, 0x47),
(0x0E, 0x47),
(0x0F, 0x45),
(0x10, 0x45),
(0x11, 0x4B),
(0x12, 0x4B),
(0x13, 0x49),
(0x14, 0x49),
(0x15, 0x5F),
(0x16, 0x41),
(0x17, 0x41),
(0x18, 0x40),
(0x19, 0x40),
(0x1A, 0x5E),
(0x1B, 0x5E),
(0x1C, 0x5F),
(0x1D, 0x5F),
(0x1E, 0x5F),
(0x1F, 0x57),
(0x20, 0x57),
(0x21, 0x77),
(0x22, 0x77),
(0x23, 0x46),
(0x24, 0x46),
(0x25, 0x44),
(0x26, 0x44),
(0x27, 0x4A),
(0x28, 0x4A),
(0x29, 0x48),
(0x2A, 0x48),
(0x2B, 0x5F),
(0x2C, 0x01),
(0x2D, 0x01),
(0x2E, 0x00),
(0x2F, 0x00),
(0x30, 0x1F),
(0x31, 0x1F),
(0x32, 0x1E),
(0x33, 0x1E),
(0x34, 0x1F),
(0x35, 0x17),
(0x36, 0x17),
(0x37, 0x37),
(0x38, 0x37),
(0x39, 0x08),
(0x3A, 0x08),
(0x3B, 0x0A),
(0x3C, 0x0A),
(0x3D, 0x04),
(0x3E, 0x04),
(0x3F, 0x06),
(0x40, 0x06),
(0x41, 0x1F),
(0x42, 0x02),
(0x43, 0x02),
(0x44, 0x00),
(0x45, 0x00),
(0x46, 0x1F),
(0x47, 0x1F),
(0x48, 0x1E),
(0x49, 0x1E),
(0x4A, 0x1F),
(0x4B, 0x17),
(0x4C, 0x17),
(0x4D, 0x37),
(0x4E, 0x37),
(0x4F, 0x09),
(0x50, 0x09),
(0x51, 0x0B),
(0x52, 0x0B),
(0x53, 0x05),
(0x54, 0x05),
(0x55, 0x07),
(0x56, 0x07),
(0x57, 0x1F),
(0x58, 0x40),
(0x5B, 0x30),
(0x5C, 0x00),
(0x5D, 0x34),
(0x5E, 0x05),
(0x5F, 0x02),
(0x63, 0x00),
(0x64, 0x6A),
(0x67, 0x73),
(0x68, 0x07),
(0x69, 0x08),
(0x6A, 0x6A),
(0x6B, 0x08),
(0x6C, 0x00),
(0x6D, 0x00),
(0x6E, 0x00),
(0x6F, 0x88),
(0x75, 0xFF),
(0x77, 0xDD),
(0x78, 0x2C),
(0x79, 0x15),
(0x7A, 0x17),
(0x7D, 0x14),
(0x7E, 0x82),
(0xE0, 0x04),
(0x00, 0x0E),
(0x02, 0xB3),
(0x09, 0x60),
(0x0E, 0x48),
(0x37, 0x58),
(0x2B, 0x0F),
(0xE0, 0x05),
(0x15, 0x1D),
(0xE0, 0x00),
(0xE6, 0x02),
(0xE7, 0x0C)
]
)
# fmt: on
+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,
+85 -13
View File
@@ -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])
+64 -7
View File
@@ -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);
+2 -2
View File
@@ -254,7 +254,7 @@ size_t WeikaiGPIOPin::dump_summary(char *buffer, size_t len) const {
void WeikaiChannel::setup_channel() {
ESP_LOGCONFIG(TAG, " Setting up UART %s:%s", this->parent_->get_name(), this->get_channel_name());
// we enable transmit and receive on this channel
if (this->check_channel_down()) {
if (this->check_channel_down_()) {
ESP_LOGCONFIG(TAG, " Error channel %s not working", this->get_channel_name());
}
this->reset_fifo_();
@@ -374,7 +374,7 @@ size_t WeikaiChannel::rx_in_fifo_() {
return available;
}
bool WeikaiChannel::check_channel_down() {
bool WeikaiChannel::check_channel_down_() {
// to check if we channel is up we write to the LCR W/R register
// note that this will put a break on the tx line for few ms
WeikaiRegister &lcr = this->reg(WKREG_LCR);
+5 -5
View File
@@ -266,7 +266,7 @@ class WeikaiComponent : public Component {
///////////////////////////////////////////////////////////////////////////////
/// @brief Helper class to expose a WeiKai family IO pin as an internal GPIO pin.
///////////////////////////////////////////////////////////////////////////////
class WeikaiGPIOPin : public GPIOPin {
class WeikaiGPIOPin final : public GPIOPin {
public:
void set_parent(WeikaiComponent *parent) { this->parent_ = parent; }
void set_pin(uint8_t pin) { this->pin_ = pin; }
@@ -293,7 +293,7 @@ class WeikaiGPIOPin : public GPIOPin {
/// uart::UARTComponent virtual class. This class is common to the different members of the Weikai
/// components family and therefore avoid code duplication.
///////////////////////////////////////////////////////////////////////////////////////////////////
class WeikaiChannel : public uart::UARTComponent {
class WeikaiChannel final : public uart::UARTComponent {
public:
/// @brief We belongs to this WeikaiComponent
/// @param parent pointer to the component we belongs to
@@ -315,10 +315,10 @@ class WeikaiChannel : public uart::UARTComponent {
const char *get_channel_name() { return this->name_.c_str(); }
/// @brief Setup the channel
void virtual setup_channel();
void setup_channel();
/// @brief dump channel information
void virtual dump_channel();
void dump_channel();
/// @brief Factory method to create a WeikaiRegister proxy object
/// @param reg address of the register
@@ -423,7 +423,7 @@ class WeikaiChannel : public uart::UARTComponent {
/// @brief check if channel is alive
/// @return true if OK
bool virtual check_channel_down();
bool check_channel_down_();
#ifdef TEST_COMPONENT
/// @ingroup test_
+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
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.7.0b2"
__version__ = "2026.8.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+4
View File
@@ -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
+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
@@ -4,7 +4,7 @@ PyYAML==6.0.3
paho-mqtt==1.6.1
colorama==0.4.6
tzlocal==5.4.4 # from time
tzdata>=2026.2 # from time
tzdata>=2026.3 # from time
pyserial==3.5
platformio==6.1.19
esptool==5.3.1
+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
+1 -1
View File
@@ -1,6 +1,6 @@
pylint==4.0.6
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.15.20 # also change in .pre-commit-config.yaml when updating
ruff==0.15.21 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit
+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)
+92 -72
View File
@@ -88,6 +88,38 @@ def show_disk_space_if_ci(esphome_command: str) -> None:
sys.stdout.flush()
def start_log_group(title: str) -> None:
"""Begin a collapsible log group in the GitHub Actions log viewer.
Everything printed until the matching :func:`end_log_group` is folded away
by default, so the full ``esphome config``/``compile`` dump for one
configuration no longer pushes the pass/fail result thousands of lines down
the log. Outside CI this is a no-op so local runs stay plain.
Args:
title: Text shown on the (collapsed) group header line.
"""
if not os.environ.get("GITHUB_ACTIONS"):
return
# Flush so the marker is ordered correctly relative to the child process
# output that follows (the subprocess writes straight to our stdout).
sys.stdout.flush()
print(f"::group::{title}")
sys.stdout.flush()
def end_log_group() -> None:
"""Close the collapsible log group opened by :func:`start_log_group`.
Outside CI this is a no-op.
"""
if not os.environ.get("GITHUB_ACTIONS"):
return
sys.stdout.flush()
print("::endgroup::")
sys.stdout.flush()
def find_component_tests(
components_dir: Path,
component_pattern: str = "*",
@@ -383,54 +415,48 @@ def run_esphome_test(
# Build command string for display/logging
cmd_str = " ".join(cmd)
# Run command
print(f"> [{component}] [{test_name}] [{platform_with_version}]")
# Run command inside a collapsible CI log group so the full esphome output
# for this configuration can be folded away by default.
group_title = f"[{component}] [{test_name}] [{platform_with_version}]"
start_log_group(group_title)
print(f"> {group_title}")
if use_testing_mode:
print(" (using --testing-mode)")
start_time = time.time()
test_id = f"{component}.{test_name}.{platform_with_version}"
# Always close the group, even if the subprocess or disk-space reporting
# raises, so later output is never folded into the wrong CI log section.
try:
result = subprocess.run(cmd, check=False)
success = result.returncode == 0
duration = time.time() - start_time
# Show disk space after build in CI during compile
show_disk_space_if_ci(esphome_command)
finally:
end_log_group()
if not success and not continue_on_fail:
# Print command immediately for failed tests
print(f"\n{'=' * 80}")
print("FAILED - Command to reproduce:")
print(f"{'=' * 80}")
print(cmd_str)
print()
raise subprocess.CalledProcessError(result.returncode, cmd)
success = result.returncode == 0
duration = time.time() - start_time
return TestResult(
test_id=test_id,
components=[component],
platform=platform_with_version,
success=success,
duration=duration,
command=cmd_str,
test_type=esphome_command,
)
except subprocess.CalledProcessError:
duration = time.time() - start_time
# Re-raise if we're not continuing on fail
if not continue_on_fail:
raise
return TestResult(
test_id=test_id,
components=[component],
platform=platform_with_version,
success=False,
duration=duration,
command=cmd_str,
test_type=esphome_command,
)
if not success and not continue_on_fail:
# Print command immediately for failed tests. The group is already
# closed, so the failure and reproduce command stay visible.
print(f"\n{'=' * 80}")
print("FAILED - Command to reproduce:")
print(f"{'=' * 80}")
print(cmd_str)
print()
raise subprocess.CalledProcessError(result.returncode, cmd)
return TestResult(
test_id=test_id,
components=[component],
platform=platform_with_version,
success=success,
duration=duration,
command=cmd_str,
test_type=esphome_command,
)
def run_grouped_test(
@@ -534,54 +560,48 @@ def run_grouped_test(
# Build command string for display/logging
cmd_str = " ".join(cmd)
# Run command
# Run command inside a collapsible CI log group so the full esphome output
# for this grouped configuration can be folded away by default.
components_str = ", ".join(components)
print(f"> [GROUPED: {components_str}] [{platform_with_version}]")
group_title = f"[GROUPED: {components_str}] [{platform_with_version}]"
start_log_group(group_title)
print(f"> {group_title}")
print(" (using --testing-mode)")
start_time = time.time()
test_id = f"GROUPED[{','.join(components)}].{platform_with_version}"
# Always close the group, even if the subprocess or disk-space reporting
# raises, so later output is never folded into the wrong CI log section.
try:
result = subprocess.run(cmd, check=False)
success = result.returncode == 0
duration = time.time() - start_time
# Show disk space after build in CI during compile
show_disk_space_if_ci(esphome_command)
finally:
end_log_group()
if not success and not continue_on_fail:
# Print command immediately for failed tests
print(f"\n{'=' * 80}")
print("FAILED - Command to reproduce:")
print(f"{'=' * 80}")
print(cmd_str)
print()
raise subprocess.CalledProcessError(result.returncode, cmd)
success = result.returncode == 0
duration = time.time() - start_time
return TestResult(
test_id=test_id,
components=components,
platform=platform_with_version,
success=success,
duration=duration,
command=cmd_str,
test_type=esphome_command,
)
except subprocess.CalledProcessError:
duration = time.time() - start_time
# Re-raise if we're not continuing on fail
if not continue_on_fail:
raise
return TestResult(
test_id=test_id,
components=components,
platform=platform_with_version,
success=False,
duration=duration,
command=cmd_str,
test_type=esphome_command,
)
if not success and not continue_on_fail:
# Print command immediately for failed tests. The group is already
# closed, so the failure and reproduce command stay visible.
print(f"\n{'=' * 80}")
print("FAILED - Command to reproduce:")
print(f"{'=' * 80}")
print(cmd_str)
print()
raise subprocess.CalledProcessError(result.returncode, cmd)
return TestResult(
test_id=test_id,
components=components,
platform=platform_with_version,
success=success,
duration=duration,
command=cmd_str,
test_type=esphome_command,
)
def run_grouped_component_tests(
+75
View File
@@ -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,29 +1,20 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
i2c: !include ../../test_build_components/common/i2c/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
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
@@ -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"}
+238
View File
@@ -0,0 +1,238 @@
"""Unit tests for script/test_build_components.py logging helpers."""
from pathlib import Path
import sys
import pytest
# Add the script directory to the path so we can import the module under test.
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
import test_build_components as tbc # noqa: E402
class _FakeCompleted:
"""Minimal stand-in for subprocess.CompletedProcess."""
def __init__(self, returncode: int) -> None:
self.returncode = returncode
@pytest.fixture
def _no_ci(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure GITHUB_ACTIONS is unset so group markers are suppressed."""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
@pytest.fixture
def _ci(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pretend we are running inside GitHub Actions."""
monkeypatch.setenv("GITHUB_ACTIONS", "true")
def test_start_log_group_outside_ci_is_silent(
_no_ci: None, capsys: pytest.CaptureFixture[str]
) -> None:
tbc.start_log_group("hello")
assert capsys.readouterr().out == ""
def test_end_log_group_outside_ci_is_silent(
_no_ci: None, capsys: pytest.CaptureFixture[str]
) -> None:
tbc.end_log_group()
assert capsys.readouterr().out == ""
def test_start_log_group_in_ci_emits_marker(
_ci: None, capsys: pytest.CaptureFixture[str]
) -> None:
tbc.start_log_group("hello")
assert capsys.readouterr().out == "::group::hello\n"
def test_end_log_group_in_ci_emits_marker(
_ci: None, capsys: pytest.CaptureFixture[str]
) -> None:
tbc.end_log_group()
assert capsys.readouterr().out == "::endgroup::\n"
def _make_base_file(tmp_path: Path) -> Path:
base_file = tmp_path / "base.yaml"
base_file.write_text("esphome:\n name: $component_test_file\n")
return base_file
def test_run_esphome_test_wraps_output_in_group(
_ci: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A passing single-component test is bracketed by group markers."""
monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(0))
repo_root = Path(tbc.__file__).parent.parent
test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml"
result = tbc.run_esphome_test(
component="foo",
test_file=test_file,
platform="esp32-idf",
platform_with_version="esp32-idf",
base_file=_make_base_file(tmp_path),
build_dir=tmp_path,
esphome_command="config",
continue_on_fail=True,
)
out = capsys.readouterr().out
assert result.success is True
assert "::group::[foo] [test] [esp32-idf]" in out
assert "::endgroup::" in out
# The header line is printed inside the group.
assert out.index("::group::") < out.index("> [foo]") < out.index("::endgroup::")
def test_run_esphome_test_closes_group_before_failure_report(
_ci: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""On a fail-fast failure the group closes before the reproduce report."""
monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(1))
repo_root = Path(tbc.__file__).parent.parent
test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml"
# continue_on_fail=False makes the failure raise after printing the
# reproduce block, which is the path that must stay outside the group.
with pytest.raises(tbc.subprocess.CalledProcessError):
tbc.run_esphome_test(
component="foo",
test_file=test_file,
platform="esp32-idf",
platform_with_version="esp32-idf",
base_file=_make_base_file(tmp_path),
build_dir=tmp_path,
esphome_command="config",
continue_on_fail=False,
)
out = capsys.readouterr().out
assert "::endgroup::" in out
assert "FAILED - Command to reproduce:" in out
# The group must be closed before the failure report is printed.
assert out.index("::endgroup::") < out.index("FAILED - Command to reproduce:")
def test_run_esphome_test_closes_group_when_subprocess_raises(
_ci: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""If the subprocess raises, the group is still closed (via finally)."""
def _boom(*a: object, **k: object) -> None:
raise OSError("boom")
monkeypatch.setattr(tbc.subprocess, "run", _boom)
repo_root = Path(tbc.__file__).parent.parent
test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml"
with pytest.raises(OSError, match="boom"):
tbc.run_esphome_test(
component="foo",
test_file=test_file,
platform="esp32-idf",
platform_with_version="esp32-idf",
base_file=_make_base_file(tmp_path),
build_dir=tmp_path,
esphome_command="config",
continue_on_fail=True,
)
assert "::endgroup::" in capsys.readouterr().out
def test_run_grouped_test_wraps_output_in_group(
_ci: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A grouped test is bracketed by group markers listing its components."""
monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(0))
monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None)
result = tbc.run_grouped_test(
components=["foo", "bar"],
platform="esp32-idf",
platform_with_version="esp32-idf",
base_file=_make_base_file(tmp_path),
build_dir=tmp_path,
tests_dir=tmp_path,
esphome_command="config",
continue_on_fail=True,
)
out = capsys.readouterr().out
assert result.success is True
assert "::group::[GROUPED: foo, bar] [esp32-idf]" in out
assert out.index("::group::") < out.index("> [GROUPED") < out.index("::endgroup::")
def test_run_grouped_test_closes_group_before_failure_report(
_ci: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A fail-fast grouped failure closes the group before the report."""
monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(1))
monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None)
with pytest.raises(tbc.subprocess.CalledProcessError):
tbc.run_grouped_test(
components=["foo", "bar"],
platform="esp32-idf",
platform_with_version="esp32-idf",
base_file=_make_base_file(tmp_path),
build_dir=tmp_path,
tests_dir=tmp_path,
esphome_command="config",
continue_on_fail=False,
)
out = capsys.readouterr().out
assert out.index("::endgroup::") < out.index("FAILED - Command to reproduce:")
def test_run_grouped_test_closes_group_when_subprocess_raises(
_ci: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""If the grouped subprocess raises, the group is still closed (finally)."""
def _boom(*a: object, **k: object) -> None:
raise OSError("boom")
monkeypatch.setattr(tbc.subprocess, "run", _boom)
monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None)
with pytest.raises(OSError, match="boom"):
tbc.run_grouped_test(
components=["foo", "bar"],
platform="esp32-idf",
platform_with_version="esp32-idf",
base_file=_make_base_file(tmp_path),
build_dir=tmp_path,
tests_dir=tmp_path,
esphome_command="config",
continue_on_fail=True,
)
assert "::endgroup::" in capsys.readouterr().out
@@ -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"})