Compare commits

...
97 Commits
Author SHA1 Message Date
Jesse HillsandGitHub b832e135cb Merge pull request #17794 from esphome/bump-2026.7.2
2026.7.2
2026-07-23 14:41:52 +12:00
Jesse Hills 66615a24a7 Bump version to 2026.7.2 2026-07-23 13:55:39 +12:00
esphome[bot]andJesse Hills 52ace9c448 Bump bundled esphome-device-builder to 1.6.9 (#17793) 2026-07-23 13:55:39 +12:00
J. Nick KostonandJesse Hills e1cedaba87 [bundle] Remap absolute file paths when compiling an extracted bundle (#17765) 2026-07-23 13:55:38 +12:00
f7bf4e9727 [mipi_spi] fix partial update bug (#17747)
Co-authored-by: clyde <2366188+clydebarrow@users.noreply.github.com>
2026-07-23 13:55:38 +12:00
Kevin AhrendtandJesse Hills e46bdf9e18 [sendspin] Bump sendspin-cpp to v0.7.0 (#17781) 2026-07-23 13:55:38 +12:00
Brandon HarveyandJesse Hills a08070a836 [sen5x] Add model option to override autodetection (#17764) 2026-07-23 13:55:38 +12:00
dependabot[bot]andJesse Hills 18f93c0e41 Bump aioesphomeapi from 45.6.2 to 45.7.0 (#17768)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 13:55:38 +12:00
Jesse HillsandGitHub bbc5669efa Merge pull request #17739 from esphome/bump-2026.7.1
2026.7.1
2026-07-21 10:44:34 +12:00
Guanzhong ChenandJesse Hills 718b04c15a [zephyr] implement ISRInternalGPIOPin::digital_write (#17601) 2026-07-21 09:27:55 +12:00
Jonathan SwobodaandJesse Hills 24a8634a4b [haier] Fix outdoor defrost temperature reporting the coil temperature (#17492) 2026-07-21 09:27:55 +12:00
Jonathan SwobodaandJesse Hills fc6664d737 [emc2101] Fix negative external temperatures reported as large positives (#17494) 2026-07-21 09:27:55 +12:00
dependabot[bot]andJesse Hills 388e411469 Bump aioesphomeapi from 45.6.1 to 45.6.2 (#17654)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 09:17:18 +12:00
Jesse Hills a8ebdcb8f2 Bump version to 2026.7.1 2026-07-21 08:25:51 +12:00
J. Nick KostonandJesse Hills f5f1c48f51 [esp8266] Fail fast when Rosetta 2 is missing on Apple Silicon Macs (#17737) 2026-07-21 08:25:51 +12:00
J. Nick KostonandJesse Hills 7c55de311f [wireguard] Mark private keys sensitive, stop redacting public keys (#17736) 2026-07-21 08:25:46 +12:00
J. Nick KostonandJesse Hills 307faa6c38 [espidf] Resume interrupted toolchain downloads instead of restarting (#17706) 2026-07-21 08:18:16 +12:00
J. Nick KostonandJesse Hills 5f2adcf9b3 [platformio] Include cache path in invalid library error (#17692) 2026-07-21 08:18:16 +12:00
7738464f0b [http_request] Fix usage of http response body (#17713)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-07-21 08:18:16 +12:00
J. Nick KostonandJesse Hills 5a86e26f68 [platformio] Re-download library when cached copy is missing its manifest (#17691)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-21 08:18:16 +12:00
J. Nick KostonJesse Hillspre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
786b47d8c2 [core] Auto-clean the PlatformIO build environment when the Python version changes (#17671)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills 83092ea05c Bump bundled esphome-device-builder to 1.6.8 (#17708) 2026-07-21 08:18:15 +12:00
629afd38f6 [light] Fix pulse and other effects (#17645)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 3ffc3a9610 [espidf] Make openocd-esp32 optional so its libusb check cannot break installs (#17686) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 9de7bd7461 [git] Detect interrupted clones and re-clone automatically (#17690) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 5df922e0df [platformio] Accept git URLs passed as the library name (#17697) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills b571d2a5ab [micro_wake_word] Download models in parallel (#17701) 2026-07-21 08:18:15 +12:00
Jesse Hills 231a2897c0 [ssd1306] Fix offset_x being ignored on SH1106/SH1107 displays (#17700) 2026-07-21 08:18:15 +12:00
Brandon HarveyandJesse Hills 2223b14794 Split multi-token build.flags entries when generating ESP-IDF component CMakeLists (#17649) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills 3d340f4d90 Bump bundled esphome-device-builder to 1.6.7 (#17696) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills 2f99466f3a Bump bundled esphome-device-builder to 1.6.6 (#17681) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills f05e15522c Bump bundled esphome-device-builder to 1.6.5 (#17675) 2026-07-21 08:18:15 +12:00
Jonathan SwobodaandJesse Hills f37dad683b [esp32] Bump recommended ESP-IDF to 5.5.5 and Arduino to 3.3.10 (#17669) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills a0fb14bf54 Bump bundled esphome-device-builder to 1.6.4 (#17662) 2026-07-21 08:18:15 +12:00
Jesse Hills d4443be0c1 [nrf52] Install PlatformIO toolchain Python packages into a dedicated venv (#17635) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 0dfb573e18 [logs] Cap the logs reconnect backoff for deep-sleep devices (#17656) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 572fc033cd Ship component requirements.txt files in the sdist and wheel (#17660) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 4062f0a323 Pin cryptography to 48.0.1 on Intel macOS (#17658) 2026-07-21 08:18:15 +12:00
Brandon HarveyandJesse Hills cd40fb1c68 [core] Fix srcFilter exclusions being silently ignored on Windows (#17648) 2026-07-21 08:18:15 +12:00
Jonathan SwobodaandJesse Hills 7afe7750cd [espidf] Suggest installing missing system libraries when the tools install fails (#17619) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills e08bdf8cca Bump bundled esphome-device-builder to 1.6.3 (#17651) 2026-07-21 08:18:15 +12:00
dependabot[bot]andJesse Hills b3172ecae8 Bump aioesphomeapi from 45.6.0 to 45.6.1 (#17653)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 08:18:14 +12:00
esphome[bot]Jesse Hillsesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2b4d9c0af9 Bump bundled esphome-device-builder to 1.6.2 (#17640)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-07-21 08:18:14 +12:00
Jesse Hills 3a10d2c187 [nrf52] Set ZEPHYR_SDK_INSTALL_DIR for Zephyr SDK discovery (#17633) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills 05e2c6b133 [web_server_idf] Use core format_hex_to helper for digest auth (fixes Arduino build) (#17608) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills 2797349c75 [http_request] Fix use-after-return of header collection state in IDF backend (#17627) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills 95a01ac2ab [core] Improve framework mirror selection, download errors, and version parsing (#17615) 2026-07-21 08:18:14 +12:00
J. Nick KostonandJesse Hills ea01c909b7 [micro_wake_word] Include the local model file in bundles (#17604) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills d3655597ea [as3935_i2c] Use repeated start when reading registers (#17584) 2026-07-21 08:18:14 +12:00
Jesse HillsandGitHub 920a8b761b Merge pull request #17583 from esphome/bump-2026.7.0
2026.7.0
2026-07-16 12:59:09 +12:00
Jonathan SwobodaandJesse Hills 5d2372937e [ci] Stop per-PR cache copies from crowding the 10GB Actions cache quota (#17463) 2026-07-16 10:16:18 +12:00
Jesse Hills 6c401d406f Bump version to 2026.7.0 2026-07-16 09:52:34 +12:00
Jesse HillsandGitHub 78180a40c4 Merge pull request #17582 from esphome/bump-2026.7.0b4
2026.7.0b4
2026-07-16 09:44:41 +12:00
Jesse Hills 63fae2c36a Bump version to 2026.7.0b4 2026-07-16 08:23:57 +12:00
Jesse Hills 878d8a2f6a [core] Fix wait_until crash when re-entered from its own continuation (#17571) 2026-07-16 08:23:57 +12:00
esphome[bot]Jesse Hillsesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
e056e99fd4 Bump bundled esphome-device-builder to 1.6.1 (#17575)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-07-16 08:23:57 +12:00
Jesse Hills 3b915cf16b [api] Warn when Home Assistant actions are sent with no subscribed client (#17560) 2026-07-16 08:23:57 +12:00
esphome[bot]andJesse Hills d1150d148d Bump bundled esphome-device-builder to 1.6.0 (#17573) 2026-07-16 08:23:57 +12:00
luar123andJesse Hills b5b426492a [zigbee] bump esp-zigbee-sdk to 2.0.3 (#17564) 2026-07-16 08:23:57 +12:00
Jonathan SwobodaandJesse Hills 724df7b11e [atc_mithermometer] Make duplicate-packet counter per-instance (#17496) 2026-07-16 08:23:57 +12:00
Daniele PalumboandJesse Hills 26db22546d [mcp23017] reset IPOL registers to 0x00 on setup (#17177)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-16 08:23:57 +12:00
Jesse HillsandGitHub 00d2d55e09 Merge pull request #17556 from esphome/bump-2026.7.0b3
2026.7.0b3
2026-07-14 16:45:55 +12:00
esphome[bot]andJesse Hills a833685a73 Bump bundled esphome-device-builder to 1.5.0 (#17558) 2026-07-14 14:51:55 +12:00
Jesse Hills 0a1065da75 [script] Recursively expand nested packages when merging component tests (#17557) 2026-07-14 14:11:18 +12:00
Jesse Hills 6fadf353b1 Bump version to 2026.7.0b3 2026-07-14 12:40:45 +12:00
Keith BurzinskiandJesse Hills 8da377ab43 [nextion] Fix unbounded queue growth and OOM crash when display sends no data (#17553) 2026-07-14 12:40:45 +12:00
Jonathan SwobodaandJesse Hills 1da8900ffc [veml7700][as7341][ltr501] Fix device class on raw-count sensors (#17549) 2026-07-14 12:40:45 +12:00
J. Nick KostonandJesse Hills 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 BurzinskiandJesse Hills 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
Jesse HillsandGitHub feb53a209b Merge pull request #17532 from esphome/bump-2026.7.0b2
2026.7.0b2
2026-07-13 13:24:45 +12:00
Jonathan SwobodaandJesse Hills c607f64288 [mipi_rgb] Test in isolation to avoid bus/pin merge conflicts (#17534) 2026-07-13 12:01:24 +12:00
Jesse HillsandJonathan Swoboda 7ee7a26cad [mipi_rgb] Use dict-style packages in test so it can be batch-grouped
Convert the i2c include to a named dict-style package key so CI can group
this component's build with others sharing the same bus, instead of flagging
it as needing migration.
2026-07-12 18:47:28 -04:00
Jesse Hills 2a67e5c599 Bump version to 2026.7.0b2 2026-07-13 09:19:24 +12:00
Keith BurzinskiandJesse Hills a9591d7aac [zwave_proxy] Fix parser gaps and harden frame and subscription handling (#17461) 2026-07-13 09:19:24 +12:00
J. Nick KostonandJesse Hills bcac3ebe2b [api] Provision encryption keys over an encrypted zero-PSK noise connection (#17482) 2026-07-13 09:19:23 +12:00
Jesse Hills 27b598c5aa [core] Classify entity metadata visibility for the visual editor (#17503) 2026-07-13 09:19:23 +12:00
luar123andJesse Hills 9ba2cbbfdd [zigbee] prevent task watchdog trigger with large configs. (#17506) 2026-07-13 09:19:23 +12:00
luar123andJesse Hills a4650a2345 [zigbee] Fix merge endpoint (#17511) 2026-07-13 09:19:23 +12:00
J. Nick KostonandJesse Hills 1e5cfe6b0f [web_server] Fix unused function warning for json_state_str (#17524) 2026-07-13 09:19:23 +12:00
J. Nick KostonandJesse Hills 665e788cc9 [mdns] Fix missing device info TXT records when native API is not enabled (#17520) 2026-07-13 09:19:23 +12:00
J. Nick KostonandJesse Hills d89b4c0b59 [core] Make config-hash independent of machine-local paths (#17523) 2026-07-13 09:19:23 +12:00
esphome[bot]andJesse Hills 5afe418a8e Bump bundled esphome-device-builder to 1.4.3 (#17522) 2026-07-13 09:19:23 +12:00
tomaszduda23andJesse Hills 312f6f2049 [deep_sleep] feed watchdog in deep sleep (#17516) 2026-07-13 09:19:23 +12:00
eb0848d538 [mipi_dsi] New model for M5Stack Tab5 (#17500)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:19:23 +12:00
Clyde StubbsJesse Hillspre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1a573919d1 [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-13 09:19:23 +12:00
esphome[bot]andJesse Hills 692cf7abd1 Bump bundled esphome-device-builder to 1.4.2 (#17512) 2026-07-13 09:19:23 +12:00
f0afd9e660 [mipi][mipi_spi][mipi_dsi][mipi_rgb] Transform cleanup (#17405)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:19:23 +12:00
esphome[bot]andJesse Hills 262ee421f6 Bump bundled esphome-device-builder to 1.4.1 (#17507) 2026-07-13 09:19:23 +12:00
esphome[bot]andJesse Hills 050a006459 Bump bundled esphome-device-builder to 1.4.0 (#17495) 2026-07-13 09:19:23 +12:00
Jonathan SwobodaandJesse Hills 8518d0633b [web_server] Serialize entity state strings without a copy buffer (#17488) 2026-07-13 09:19:23 +12:00
dependabot[bot]andJesse Hills 284fe85271 Bump aioesphomeapi from 45.5.2 to 45.6.0 (#17490)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-13 09:19:23 +12:00
Jonathan SwobodaandJesse Hills 020a6a8fd1 [mcp4461] Fix wiper increment/decrement write length (#17487) 2026-07-13 09:19:23 +12:00
Jonathan SwobodaandJesse Hills 196b979df8 [usb_uart] Fix output chunk length truncated to 8 bits (#17480) 2026-07-13 09:19:23 +12:00
207 changed files with 8675 additions and 866 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.
+59 -6
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.
@@ -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
@@ -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.0b1
PROJECT_NUMBER = 2026.7.2
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1
View File
@@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c
recursive-include esphome *.py.script
recursive-include esphome *.jinja
recursive-include esphome LICENSE.txt
recursive-include esphome requirements.txt
+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.3.1
RUN uv pip install --no-cache-dir esphome-device-builder==1.6.9
RUN \
platformio settings set enable_telemetry No \
+17 -2
View File
@@ -776,6 +776,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
check_placeholder_credentials(config)
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
# working on machines that cannot run the toolchain.
if CORE.is_esp8266:
from esphome.components.esp8266 import check_rosetta
check_rosetta()
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
@@ -1510,10 +1517,18 @@ def _redact_with_legacy_fallback(output: str) -> str:
m = _LEGACY_REDACTION_RE.search(line)
if m is None:
continue
key = m.group("key")
if not in_substitutions:
unmarked.add(m.group("key"))
# Public keys (e.g. wireguard's peer_public_key) are not secret;
# redacting them and telling maintainers to mark them cv.sensitive
# would be wrong on both counts. Substitution keys are user-named
# with no schema behind them, so anything secret-shaped there
# (public or not) stays conservatively redacted.
if "public" in key.split("_"):
continue
unmarked.add(key)
lines[i] = (
f"{line[: m.start()]}{m.group('key')}: "
f"{line[: m.start()]}{key}: "
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
)
output = "\n".join(lines)
+135 -3
View File
@@ -7,12 +7,12 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import StrEnum
import io
import json
import logging
from pathlib import Path
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
import re
import shutil
import tarfile
@@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError
_LOGGER = logging.getLogger(__name__)
DOMAIN = "bundle"
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1
@@ -49,6 +51,7 @@ class ManifestKey(StrEnum):
MANIFEST_VERSION = "manifest_version"
ESPHOME_VERSION = "esphome_version"
CONFIG_FILENAME = "config_filename"
CONFIG_DIR = "config_dir"
FILES = "files"
HAS_SECRETS = "has_secrets"
@@ -120,6 +123,126 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
return keys
@dataclass
class BundleData:
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
extra_files: list[Path] = field(default_factory=list)
# Original config dir parsed from an extracted bundle's manifest.json,
# kept in the path flavor of the machine the bundle was created on.
# The checked flag makes the manifest lookup happen at most once per run;
# CORE.data is cleared between runs.
original_config_dir: PurePath | None = None
original_config_dir_checked: bool = False
def _get_data() -> BundleData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = BundleData()
return CORE.data[DOMAIN]
def add_bundle_file(path: Path) -> None:
"""Register a file that a bundle must include.
Bundle discovery walks the validated config, so it only finds files the config
names. Components call this during validation for files it cannot see, such as a
file that is referenced from inside another file.
A relative path is taken as relative to the config directory. Files outside the
config directory are skipped when the bundle is built.
"""
_get_data().extra_files.append(CORE.relative_config_path(path))
# Windows paths start with a drive letter or contain backslashes; POSIX
# paths do neither in practice, so this is how the flavor of a recorded
# path string is recognized on any host.
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
def _path_flavor(value: str) -> type[PurePath]:
"""Pick the pure path class matching the flavor ``value`` was written in."""
if "\\" in value or _WINDOWS_DRIVE_RE.match(value):
return PureWindowsPath
return PurePosixPath
def _load_original_config_dir() -> PurePath | None:
"""Read the original config dir from an extracted bundle's manifest.
Returns None when the current config dir is not an extracted bundle or
the manifest does not record the original config dir.
"""
manifest_path = CORE.config_dir / MANIFEST_FILENAME
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError:
# The common case: this config dir is not an extracted bundle.
return None
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err:
# A manifest.json is present but unreadable or malformed. Say so
# instead of letting it look identical to "not a bundle".
_LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err)
return None
if not isinstance(manifest, dict):
return None
# A manifest.json in the config dir does not have to be ours. Only trust
# one that looks like a bundle manifest for exactly this config file.
version = manifest.get(ManifestKey.MANIFEST_VERSION)
if not isinstance(version, int) or version < 1:
return None
if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name:
return None
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
if not isinstance(config_dir, str) or not config_dir:
return None
return _path_flavor(config_dir)(config_dir)
def remap_bundle_path(value: str) -> Path | None:
"""Remap an absolute path from the machine a bundle was created on.
A bundled config may reference files by absolute path. The referenced
files ship inside the bundle at their config-relative locations, but the
YAML text is copied verbatim, so after extraction on another machine the
absolute reference points at a path that only existed on the creating
machine. The bundle manifest records that machine's config dir; when
``value`` names a path that lived under it, return the corresponding
file next to the extracted config.
``value`` is the raw path string from the config. It is parsed with the
original machine's path flavor, so a bundle created on Windows remaps on
a POSIX build server and vice versa.
Returns None when not compiling an extracted bundle, when ``value`` was
not under the original config dir, or when the bundle does not contain
the file.
"""
data = _get_data()
if not data.original_config_dir_checked:
data.original_config_dir_checked = True
data.original_config_dir = _load_original_config_dir()
original_dir = data.original_config_dir
if original_dir is None:
return None
path = type(original_dir)(value)
if not path.is_absolute():
return None
try:
rel = path.relative_to(original_dir)
except ValueError:
return None
# relative_to is lexical, so ".." segments survive it. Refuse them: the
# remapped file must land strictly inside the extracted config tree.
if ".." in rel.parts:
return None
remapped = CORE.relative_config_path(Path(*rel.parts))
if not remapped.exists():
return None
return remapped
@dataclass
class BundleFile:
"""A file to include in the bundle."""
@@ -146,6 +269,7 @@ class BundleManifest:
config_filename: str
files: list[str]
has_secrets: bool
config_dir: str | None = None
class ConfigBundleCreator:
@@ -286,13 +410,18 @@ class ConfigBundleCreator:
with known file extensions are also resolved and checked.
Core ESPHome concepts that use relative paths or directories
are handled explicitly.
are handled explicitly. Files the config does not name at all are
registered by their component with add_bundle_file().
"""
config = self._config
# Generic walk: find all file paths in the validated config
self._walk_config_for_files(config)
# Files registered by components during validation
for extra_file in _get_data().extra_files:
self._add_file(extra_file)
# --- Core ESPHome concepts needing explicit handling ---
# esphome.includes / includes_c - can be relative paths and directories
@@ -405,6 +534,7 @@ class ConfigBundleCreator:
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
ManifestKey.ESPHOME_VERSION: const.__version__,
ManifestKey.CONFIG_FILENAME: self._config_path.name,
ManifestKey.CONFIG_DIR: str(self._config_dir),
ManifestKey.FILES: [f.path for f in files],
ManifestKey.HAS_SECRETS: has_secrets,
}
@@ -489,12 +619,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
except tarfile.TarError as err:
raise EsphomeError(f"Failed to read bundle: {err}") from err
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
return BundleManifest(
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
files=manifest.get(ManifestKey.FILES, []),
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
config_dir=config_dir if isinstance(config_dir, str) else None,
)
+5 -2
View File
@@ -488,8 +488,11 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else:
# No key provided, but encryption desired
# This will allow a plaintext client to provide a noise key,
# send it to the device, and then switch to noise.
# Until a key is set, the device accepts both Noise connections
# using the well-known all-zeros PSK (preferred: the key travels
# encrypted, protecting against passive sniffing) and plaintext
# connections (deprecated, remove after 2027.2.0) so a client can
# provide a noise key and the device then switches to noise only.
# The key will be saved in flash and used for future connections
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
+5
View File
@@ -310,6 +310,11 @@ message DeviceInfoResponse {
// Serial proxy instance metadata
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
// Device is unprovisioned and accepts Noise handshakes with the well-known
// all-zeros PSK, so the api encryption key can be provisioned without being
// sent in plaintext (protects against passive sniffing, not active MITM)
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
}
message ListEntitiesRequest {
+50 -1
View File
@@ -198,6 +198,29 @@ APIConnection::~APIConnection() {
#endif
}
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
void APIConnection::upgrade_helper_to_noise_() {
// The client opened with a Noise hello while this device has no encryption
// key set. Replace the plaintext helper with a Noise helper so the key can
// be provisioned over an encrypted channel: the noise context PSK is all
// zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519
// exchange, so a passive listener cannot read the session. A publicly known
// PSK authenticates nobody; this protects against sniffing only.
auto *plaintext = static_cast<APIPlaintextFrameHelper *>(this->helper_.get());
uint8_t header[3];
uint8_t header_len = plaintext->get_consumed_header(header);
auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx());
// Carry over the peername-based client name (Hello has not arrived yet)
const char *name = plaintext->get_client_name();
noise->set_client_name(name, strlen(name));
this->helper_.reset(noise); // destroys the plaintext helper
APIError err = noise->init_from_handoff(header, header_len);
if (err != APIError::OK) {
this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err);
}
}
#endif // USE_API_NOISE && USE_API_PLAINTEXT
void APIConnection::destroy_active_iterator_() {
switch (this->active_iterator_) {
case ActiveIterator::LIST_ENTITIES:
@@ -256,6 +279,15 @@ void APIConnection::loop() {
// No more data available
break;
} else if (err != APIError::OK) {
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Checked inside the error branch to keep the hot err == OK path
// free of it; this can only fire on the first bytes of a plaintext
// helper on an unprovisioned device
if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) {
this->upgrade_helper_to_noise_();
return;
}
#endif
this->fatal_error_with_log_(LOG_STR("Reading failed"), err);
return;
} else {
@@ -1351,7 +1383,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet
#ifdef USE_ZWAVE_PROXY
void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len);
zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len);
}
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
@@ -1860,6 +1892,12 @@ bool APIConnection::send_device_info_response_() {
#endif
#ifdef USE_API_NOISE
resp.api_encryption_supported = true;
#ifndef USE_API_NOISE_PSK_FROM_YAML
// No key from YAML: while no key is set, the key can be provisioned over a
// zero-PSK Noise connection. Gated on the YAML define (not the plaintext
// one) so this advertisement survives the plaintext removal in 2027.2.0.
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
#endif
#endif
#ifdef USE_DEVICES
size_t device_index = 0;
@@ -2037,10 +2075,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
}
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
ESP_LOGW(TAG, "Invalid encryption key length");
} else if (APINoiseContext::is_all_zeros(psk)) {
// Accepting the reserved provisioning PSK would report success without
// enabling encryption (or silently clear an existing key)
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
} else if (!this->parent_->save_noise_psk(psk, true)) {
ESP_LOGW(TAG, "Failed to save encryption key");
} else {
resp.success = true;
#ifdef USE_API_PLAINTEXT
if (this->helper_->frame_footer_size() == 0) {
// Plaintext transport has no frame footer; Noise always has the MAC footer.
// Remove after 2027.2.0 together with plaintext support on keyless devices.
ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0");
}
#endif
}
return this->send_message(resp);
+11 -2
View File
@@ -166,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase {
#endif
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
#ifdef USE_API_HOMEASSISTANT_SERVICES
void send_homeassistant_action(const HomeassistantActionRequest &call) {
// Returns whether this client has subscribed to Home Assistant actions; the message
// is only handed to the send path when subscribed. A true return does not guarantee
// delivery - it lets the caller warn when no connected client has the subscription.
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
if (!this->flags_.service_call_subscription)
return;
return false;
this->send_message(call);
return true;
}
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
@@ -626,6 +630,11 @@ class APIConnection final : public APIServerConnectionBase {
void destroy_active_iterator_();
void begin_iterator_(ActiveIterator type);
void finalize_iterator_sync_();
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Swap the plaintext helper for a Noise helper after the client opened
// with a Noise hello on an unprovisioned device (zero-PSK provisioning).
void upgrade_helper_to_noise_();
#endif
#ifdef USE_CAMERA
std::unique_ptr<camera::CameraImageReader> image_reader_;
#endif
@@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) {
return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE");
}
#endif
// PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before
// any logging can happen, so it intentionally has no entry here.
return LOG_STR("UNKNOWN");
}
+11
View File
@@ -88,6 +88,11 @@ enum class APIError : uint16_t {
HANDSHAKESTATE_SPLIT_FAILED = 1020,
BAD_HANDSHAKE_ERROR_BYTE = 1021,
#endif
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Not an error: an unprovisioned device received a Noise client hello on a
// plaintext connection; the caller must hand the socket off to a Noise helper.
PROTOCOL_SWITCH_TO_NOISE = 1023,
#endif
};
const LogString *api_error_to_logstr(APIError err);
@@ -200,6 +205,12 @@ class APIFrameHelper {
// or track that they stopped early and retry without this check.
// See Socket::ready() for details.
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Move the socket out of this helper so a replacement helper can take it
// over (plaintext to Noise handoff on unprovisioned devices). The drained
// helper must be destroyed right after.
std::unique_ptr<socket::Socket> release_socket_for_switch() { return std::move(this->socket_); }
#endif
// Release excess memory from internal buffers after initial sync
void release_buffers() {
// rx_buf_: Safe to clear only if no partial read in progress.
@@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() {
state_ = State::CLIENT_HELLO;
return APIError::OK;
}
#ifdef USE_API_PLAINTEXT
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
APIError err = this->init();
if (err != APIError::OK) {
return err;
}
// Seed the header bytes the plaintext helper consumed before detecting the
// Noise indicator; try_read_frame_ resumes from rx_header_buf_len_.
std::memcpy(this->rx_header_buf_, header, header_len);
this->rx_header_buf_len_ = header_len;
// Pump the handshake without gating on socket_->ready(): on LWIP the
// plaintext helper's partial read can drain rcvevent while the rest of the
// client hello sits in the lastdata cache, so ready() may report false even
// though data is available.
return this->pump_handshake_();
}
#endif // USE_API_PLAINTEXT
/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal
/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK
/// and resume on the next loop().
APIError APINoiseFrameHelper::pump_handshake_() {
while (this->state_ != State::DATA) {
APIError err = this->state_action_();
if (err == APIError::WOULD_BLOCK) {
break;
}
if (err != APIError::OK) {
return err;
}
}
return APIError::OK;
}
// Helper for handling handshake frame errors
APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) {
if (aerr == APIError::BAD_INDICATOR) {
@@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
/// Run through handshake messages (if in that phase)
APIError APINoiseFrameHelper::loop() {
// Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
// the rx buffer is consumed. Re-checking each iteration would block handshake writes
// that must follow reads, deadlocking the handshake. state_action() will return
// WOULD_BLOCK when no more data is available to read.
bool socket_ready = this->socket_->ready();
while (state_ != State::DATA && socket_ready) {
APIError err = state_action_();
if (err == APIError::WOULD_BLOCK) {
break;
}
// Check ready() once, not per state transition. On ESP8266 LWIP raw TCP,
// ready() returns false once the rx buffer is consumed. Re-checking each
// iteration would block handshake writes that must follow reads,
// deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when
// no more data is available to read.
if (state_ != State::DATA && this->socket_->ready()) {
APIError err = this->pump_handshake_();
if (err != APIError::OK) {
return err;
}
@@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper {
}
~APINoiseFrameHelper() override;
APIError init() override;
#ifdef USE_API_PLAINTEXT
// Take over a connection whose first bytes were consumed by a plaintext
// helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE).
// Seeds the already-read header bytes and pumps the handshake state machine
// until it would block.
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
#endif
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
protected:
APIError pump_handshake_();
APIError state_action_();
APIError state_action_client_hello_();
APIError state_action_server_hello_();
@@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
// If this was the first read, validate the indicator byte
if (rx_header_buf_pos_ == 0 && received > 0) {
if (rx_header_buf_[0] != 0x00) {
#ifdef USE_API_NOISE
// Dual build (encryption supported but no key set): a 0x01 first byte
// is a Noise client hello. Hand the connection off to a Noise helper
// running the all-zeros provisioning PSK so the encryption key can be
// set without crossing the wire in plaintext. Preserve the bytes we
// already consumed; they are the start of the Noise 3-byte header.
if (rx_header_buf_[0] == 0x01) {
rx_header_buf_pos_ = static_cast<uint8_t>(received);
return APIError::PROTOCOL_SWITCH_TO_NOISE;
}
#endif
state_ = State::FAILED;
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
return APIError::BAD_INDICATOR;
@@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
#ifdef USE_API_NOISE
// After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
// header bytes already consumed from the socket (at most 3, the size of the
// Noise fixed header) so the replacement Noise helper can be seeded with them.
uint8_t get_consumed_header(uint8_t out[3]) const {
memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_);
return this->rx_header_buf_pos_;
}
#endif
protected:
APIError try_read_frame_();
+12 -5
View File
@@ -10,13 +10,20 @@ using psk_t = std::array<uint8_t, 32>;
class APINoiseContext {
public:
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
// doubles as the well-known provisioning PSK that unprovisioned devices
// accept for Noise handshakes (passive-sniffing protection only, no
// authentication). It is never a valid real key.
static bool is_all_zeros(const psk_t &psk) {
uint8_t acc = 0;
for (uint8_t b : psk) {
acc |= b;
}
return acc == 0;
}
void set_psk(psk_t psk) {
this->psk_ = psk;
bool has_psk = false;
for (auto i : psk) {
has_psk |= i;
}
this->has_psk_ = has_psk;
this->has_psk_ = !is_all_zeros(psk);
}
const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; }
+6
View File
@@ -170,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
for (const auto &it : this->serial_proxies) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it);
}
#endif
#ifdef USE_API_NOISE
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
#endif
return pos;
}
@@ -232,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const {
for (const auto &it : this->serial_proxies) {
size += ProtoSize::calc_message_force(2, it.calculate_size());
}
#endif
#ifdef USE_API_NOISE
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
#endif
return size;
}
+4 -1
View File
@@ -533,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 10;
static constexpr uint16_t ESTIMATED_SIZE = 309;
static constexpr uint16_t ESTIMATED_SIZE = 312;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif
@@ -588,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif
#ifdef USE_SERIAL_PROXY
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
#endif
#ifdef USE_API_NOISE
bool api_encryption_provisionable{false};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
+3
View File
@@ -982,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
it.dump_to(out);
out.append("\n");
}
#endif
#ifdef USE_API_NOISE
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif
return out.c_str();
}
+9 -1
View File
@@ -426,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat
#ifdef USE_API_HOMEASSISTANT_SERVICES
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
bool has_subscriber = false;
for (auto &client : this->active_clients()) {
client->send_homeassistant_action(call);
has_subscriber |= client->send_homeassistant_action(call);
}
if (!has_subscriber) {
// Home Assistant subscribes to actions shortly *after* authenticating, so actions
// fired right at connection time (on_client_connected, on_time_sync, ...) can
// arrive before the subscription and are lost - warn instead of failing silently.
ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(),
this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected");
}
}
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
+3
View File
@@ -152,6 +152,9 @@ async def async_run_logs(
name=name,
subscribe_states=subscribe_states,
allow_plaintext_fallback=True,
# A top-level ``deep_sleep:`` block means the device is only awake
# briefly; cap the reconnect backoff so a wake window is not missed.
deep_sleep="deep_sleep" in config,
)
try:
await asyncio.Event().wait()
+1 -5
View File
@@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits,
uint8_t I2CAS3935Component::read_register(uint8_t reg) {
uint8_t value;
if (write(&reg, 1) != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Writing register failed!");
return 0;
}
if (read(&value, 1) != i2c::ERROR_OK) {
if (!this->read_byte(reg, &value)) {
ESP_LOGW(TAG, "Reading register failed!");
return 0;
}
+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);
+3 -1
View File
@@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = (
cv.Exclusive(
CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
): cv.boolean,
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
+3 -1
View File
@@ -50,7 +50,9 @@ _BUTTON_SCHEMA = (
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
}
)
+3 -1
View File
@@ -131,7 +131,9 @@ _COVER_SCHEMA = (
cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All(
cv.requires_component("mqtt"), cv.boolean
),
cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All(
cv.requires_component("mqtt"), cv.subscribe_topic
),
@@ -1,13 +1,36 @@
#include "deep_sleep_component.h"
#ifdef USE_ZEPHYR
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/wake.h"
#include <zephyr/sys/poweroff.h>
#include <algorithm>
namespace esphome::deep_sleep {
static const char *const TAG = "deep_sleep";
// The Zephyr watchdog has a short window (2s, or 10s with Zigbee) and
// WDT_OPT_PAUSE_IN_SLEEP only pauses it during true hardware sleep — not while a
// radio thread (e.g. the Zigbee stack) keeps the CPU busy in k_sem_take(). Feed
// it at least this often while waiting so it does not reset the device.
static const uint32_t WDT_FEED_INTERVAL_MS = 1000;
static bool wakeable_delay_feed_wdt(uint32_t ms) {
while (ms > 0) {
const uint32_t step = std::min(ms, WDT_FEED_INTERVAL_MS);
esphome::internal::wakeable_delay(step);
esphome::arch_feed_wdt();
if (esphome::wake_request_take()) {
return true;
}
if (ms != UINT32_MAX) {
ms -= step;
}
}
return false;
}
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
void DeepSleepComponent::dump_config_platform_() {}
@@ -15,8 +38,9 @@ void DeepSleepComponent::dump_config_platform_() {}
bool DeepSleepComponent::prepare_to_sleep_() { return true; }
void DeepSleepComponent::deep_sleep_() {
bool woke = false;
if (this->sleep_duration_.has_value()) {
esphome::internal::wakeable_delay(static_cast<uint32_t>(*this->sleep_duration_ / 1000));
woke = wakeable_delay_feed_wdt(static_cast<uint32_t>(*this->sleep_duration_ / 1000));
} else {
#ifndef USE_ZIGBEE
// the device can be woken up through one of the following signals:
@@ -29,10 +53,9 @@ void DeepSleepComponent::deep_sleep_() {
// The system is reset when it wakes up from System OFF mode.
sys_poweroff();
#else
esphome::internal::wakeable_delay(UINT32_MAX);
woke = wakeable_delay_feed_wdt(UINT32_MAX);
#endif
}
const bool woke = esphome::wake_request_take();
if (woke) {
ESP_LOGD(TAG, "Woken up by another thread");
} else {
+3 -3
View File
@@ -145,9 +145,9 @@ float Emc2101Component::get_external_temperature() {
return NAN;
}
// join msb and lsb (5 least significant bits are not used)
uint16_t raw = (msb << 8 | lsb) >> 5;
return raw * 0.125;
// join msb and lsb (5 least significant bits are not used); msb is signed, so read as int16_t
int16_t raw = static_cast<int16_t>((msb << 8) | lsb) >> 5;
return raw * 0.125f;
}
float Emc2101Component::get_speed() {
+90 -24
View File
@@ -814,14 +814,15 @@ def _is_framework_url(source: str) -> bool:
# The default/recommended arduino framework version
# - https://github.com/espressif/arduino-esp32/releases
ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(3, 3, 9),
"latest": cv.Version(3, 3, 9),
"dev": cv.Version(3, 3, 9),
"recommended": cv.Version(3, 3, 10),
"latest": cv.Version(3, 3, 10),
"dev": cv.Version(3, 3, 10),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
cv.Version(
4, 0, 0, "alpha1"
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(3, 3, 10): cv.Version(55, 3, 39),
cv.Version(3, 3, 9): cv.Version(55, 3, 39),
cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"),
cv.Version(3, 3, 7): cv.Version(55, 3, 37),
@@ -844,6 +845,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
# See: https://github.com/pioarduino/esp-idf/releases
ARDUINO_IDF_VERSION_LOOKUP = {
cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1),
cv.Version(3, 3, 10): cv.Version(5, 5, 5),
cv.Version(3, 3, 9): cv.Version(5, 5, 4),
cv.Version(3, 3, 8): cv.Version(5, 5, 4),
cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"),
@@ -865,9 +867,9 @@ ARDUINO_IDF_VERSION_LOOKUP = {
# The default/recommended esp-idf framework version
# - https://github.com/espressif/esp-idf/releases
ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(5, 5, 4),
"latest": cv.Version(5, 5, 4),
"dev": cv.Version(5, 5, 4),
"recommended": cv.Version(5, 5, 5),
"latest": cv.Version(5, 5, 5),
"dev": cv.Version(5, 5, 5),
}
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
@@ -877,6 +879,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(
6, 0, 0
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(5, 5, 5): cv.Version(55, 3, 39),
cv.Version(5, 5, 4): cv.Version(55, 3, 39),
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
cv.Version(5, 5, 3): cv.Version(55, 3, 37),
@@ -1160,6 +1163,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 +1432,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 +1711,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 +2560,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")
+35 -2
View File
@@ -1,5 +1,6 @@
import logging
from pathlib import Path
import platform
import re
import subprocess
@@ -20,9 +21,15 @@ from esphome.const import (
PLATFORM_ESP8266,
ThreadModel,
)
from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority
from esphome.core import (
CORE,
CoroPriority,
EsphomeError,
Lambda,
coroutine_with_priority,
)
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed
from esphome.helpers import IS_MACOS, copy_file_if_changed
from esphome.types import ConfigType
from .boards import BOARDS, ESP8266_LD_SCRIPTS
@@ -237,6 +244,32 @@ CONFIG_SCHEMA = cv.All(
)
def check_rosetta() -> None:
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
There is no native arm64 build of the xtensa-lx106 toolchain; on Apple
Silicon it runs under Rosetta 2, which macOS updates can remove.
"""
if not IS_MACOS or platform.machine() != "arm64":
return
try:
result = subprocess.run(
["/usr/bin/arch", "-x86_64", "/usr/bin/true"],
capture_output=True,
close_fds=False,
check=False,
)
except OSError:
return # arch(1) unavailable; let the build proceed
if result.returncode != 0:
raise EsphomeError(
"ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) "
"compiler that requires Rosetta 2, which is not installed on "
"this system. Install it with:\n"
" softwareupdate --install-rosetta --agree-to-license"
)
@coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config):
cg.add(esp8266_ns.setup_preferences())
+3 -1
View File
@@ -50,7 +50,9 @@ _EVENT_SCHEMA = (
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent),
cv.GenerateID(): cv.declare_id(Event),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_ON_EVENT): automation.validate_automation({}),
}
)
+1 -1
View File
@@ -825,7 +825,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *
#ifdef USE_SENSOR
this->update_sub_sensor_(SubSensorType::INDOOR_COIL_TEMPERATURE, bd_packet->indoor_coil_temperature / 2.0 - 20);
this->update_sub_sensor_(SubSensorType::OUTDOOR_COIL_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_defrost_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_IN_AIR_TEMPERATURE, bd_packet->outdoor_in_air_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_OUT_AIR_TEMPERATURE, bd_packet->outdoor_out_air_temperature - 64);
this->update_sub_sensor_(SubSensorType::POWER, encode_uint16(bd_packet->power[0], bd_packet->power[1]));
@@ -488,10 +488,10 @@ template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...
body = this->body_.value(x...);
}
if (!this->json_.empty()) {
body = json::build_json([this, x...](JsonObject root) { this->encode_json_(x..., root); });
body = json::build_json([this, x...](JsonObject root) mutable { this->encode_json_(x..., root); });
}
if (this->json_func_ != nullptr) {
body = json::build_json([this, x...](JsonObject root) { this->json_func_(x..., root); });
body = json::build_json([this, x...](JsonObject root) mutable { this->json_func_(x..., root); });
}
std::vector<Header> request_headers;
request_headers.reserve(this->request_headers_.size());
@@ -19,11 +19,6 @@ namespace esphome::http_request {
static const char *const TAG = "http_request.idf";
static constexpr uint32_t ERROR_DURATION_MS = 1000;
struct UserData {
const std::vector<std::string> &lower_case_collect_headers;
std::vector<Header> &response_headers;
};
void HttpRequestIDF::dump_config() {
HttpRequestComponent::dump_config();
ESP_LOGCONFIG(TAG,
@@ -34,15 +29,15 @@ void HttpRequestIDF::dump_config() {
}
esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) {
UserData *user_data = (UserData *) evt->user_data;
auto *container = (HttpContainerIDF *) evt->user_data;
switch (evt->event_id) {
case HTTP_EVENT_ON_HEADER: {
const std::string header_name = str_lower_case(evt->header_key); // NOLINT
if (should_collect_header(user_data->lower_case_collect_headers, header_name)) {
if (should_collect_header(container->collect_headers_, header_name)) {
const std::string header_value = evt->header_value;
ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str());
user_data->response_headers.push_back({header_name, header_value});
container->response_headers_.push_back({header_name, header_value});
}
break;
}
@@ -124,8 +119,8 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
container->set_secure(secure);
auto user_data = UserData{lower_case_collect_headers, container->response_headers_};
esp_http_client_set_user_data(client, static_cast<void *>(&user_data));
container->collect_headers_ = lower_case_collect_headers;
esp_http_client_set_user_data(client, static_cast<void *>(container.get()));
for (const auto &header : request_headers) {
esp_http_client_set_header(client, header.name.c_str(), header.value.c_str());
@@ -24,6 +24,8 @@ class HttpContainerIDF : public HttpContainer {
protected:
friend class HttpRequestIDF;
esp_http_client_handle_t client_;
// Owned copy (not a reference): must outlive perform() for the response-header event handler
std::vector<std::string> collect_headers_;
};
class HttpRequestIDF final : public HttpRequestComponent {
+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 -8
View File
@@ -219,14 +219,11 @@ LightColorValues LightCall::validate_() {
this->set_flag_(FLAG_HAS_STATE);
}
// Make sure a turn-on makes the light visible: if the resulting brightness would be zero
// (e.g. restored from a brightness=0 turn-off), reset it to full brightness.
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) {
float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness();
if (brightness == 0.0f) {
this->brightness_ = 1.0f;
this->set_flag_(FLAG_HAS_BRIGHTNESS);
}
// Make sure a simple (no specific brightness) turn-on makes the light visible
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() &&
this->parent_->remote_values.get_brightness() == 0.0f) {
this->brightness_ = 1.0f;
this->set_flag_(FLAG_HAS_BRIGHTNESS);
}
// Set color brightness to 100% if currently zero and a color is set.
+8
View File
@@ -71,6 +71,14 @@ void LightState::setup() {
break;
}
// A light coming up on boot must never end up on-but-invisible: if the resolved restore
// state is on but its brightness is zero (e.g. a stale/persisted value from before a
// forced-on restore mode, or an inverted restore flipping a dim-to-0 off state to on),
// reset it to full brightness.
if (recovered.state && recovered.brightness == 0.0f) {
recovered.brightness = 1.0f;
}
call.set_color_mode_if_supported(recovered.color_mode);
call.set_state(recovered.state);
call.set_brightness_if_supported(recovered.brightness);
+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;
+2 -2
View File
@@ -342,7 +342,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) {
ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx);
uint8_t addr = this->get_wiper_address_(wiper_idx);
uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::INCREMENT);
auto err = this->write(&this->address_, reg);
auto err = this->write(&reg, 1);
if (err != i2c::ERROR_OK) {
this->error_code_ = MCP4461_STATUS_I2C_ERROR;
this->status_set_warning();
@@ -373,7 +373,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) {
ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx);
uint8_t addr = this->get_wiper_address_(wiper_idx);
uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::DECREMENT);
auto err = this->write(&this->address_, reg);
auto err = this->write(&reg, 1);
if (err != i2c::ERROR_OK) {
this->error_code_ = MCP4461_STATUS_I2C_ERROR;
this->status_set_warning();
+36 -10
View File
@@ -47,7 +47,7 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi
auto &services = services_storage;
#endif
#ifdef USE_API
#ifdef USE_MDNS_DEVICE_INFO_TXT
#ifdef USE_MDNS_STORE_SERVICES
get_mac_address_into_buffer(this->mac_address_);
char *mac_ptr = this->mac_address_;
@@ -70,17 +70,20 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi
platform_register(this, services);
}
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf,
char *config_hash_buf) {
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services,
const char *mac_address_buf, const char *config_hash_buf) {
// IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES
// in mdns/__init__.py. If you add a new service here, update both locations.
#ifdef USE_MDNS_DEVICE_INFO_TXT
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac");
MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash");
#endif
#ifdef USE_API
MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib");
MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name");
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash");
MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac");
MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform");
MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board");
MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network");
@@ -107,7 +110,13 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
txt_count++; // network
#endif
#ifdef USE_API_NOISE
const bool api_has_psk = api::global_api_server->get_noise_ctx().has_psk();
txt_count++; // api_encryption or api_encryption_supported
#ifndef USE_API_NOISE_PSK_FROM_YAML
if (!api_has_psk) {
txt_count++; // api_provisioning
}
#endif
#endif
#ifdef ESPHOME_PROJECT_NAME
txt_count += 2; // project_name and project_version
@@ -163,9 +172,18 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION, "api_encryption");
MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION_SUPPORTED, "api_encryption_supported");
MDNS_STATIC_CONST_CHAR(NOISE_ENCRYPTION, "Noise_NNpsk0_25519_ChaChaPoly_SHA256");
bool has_psk = api::global_api_server->get_noise_ctx().has_psk();
const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED;
const char *encryption_key = api_has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED;
txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)});
#ifndef USE_API_NOISE_PSK_FROM_YAML
if (!api_has_psk) {
// Unprovisioned device without a YAML key: advertise that the encryption
// key can be provisioned over a zero-PSK Noise connection. Gated on the
// YAML define so this survives the plaintext removal in 2027.2.0.
MDNS_STATIC_CONST_CHAR(TXT_API_PROVISIONING, "api_provisioning");
MDNS_STATIC_CONST_CHAR(VALUE_ZERO_PSK, "zero-psk");
txt_records.push_back({MDNS_STR(TXT_API_PROVISIONING), MDNS_STR(VALUE_ZERO_PSK)});
}
#endif
#endif
#ifdef ESPHOME_PROJECT_NAME
@@ -212,12 +230,18 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
web_service.service_type = MDNS_STR(SERVICE_HTTP);
web_service.proto = MDNS_STR(SERVICE_TCP);
web_service.port = []() -> uint16_t { return USE_WEBSERVER_PORT; };
#ifndef USE_API
// Without the native API there is no _esphomelib service, so publish the
// device info here for the device builder to discover.
web_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)},
{MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)},
{MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}};
#endif
#endif
#if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \
!defined(USE_MDNS_EXTRA_SERVICES)
MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http");
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
// Publish "http" service if not using native API or any other services
// This is just to have *some* mDNS service so that .local resolution works
@@ -225,7 +249,9 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
fallback_service.service_type = MDNS_STR(SERVICE_HTTP);
fallback_service.proto = MDNS_STR(SERVICE_TCP);
fallback_service.port = []() -> uint16_t { return USE_WEBSERVER_PORT; };
fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}};
fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)},
{MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)},
{MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}};
#endif
}
+12 -3
View File
@@ -22,6 +22,15 @@
#endif
#endif
// Device info TXT records (version, mac, config_hash) are published on the _esphomelib service
// when the native API is enabled, otherwise on the _http service (web_server's or the fallback one).
// When neither applies (only prometheus, sendspin or user-defined services are configured), no
// device info records are published and the buffers below are not needed.
#if defined(USE_API) || defined(USE_WEBSERVER) || \
(!defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_MDNS_EXTRA_SERVICES))
#define USE_MDNS_DEVICE_INFO_TXT
#endif
namespace esphome::mdns {
// Helper struct that identifies strings that may be stored in flash storage (similar to LogString)
@@ -136,7 +145,7 @@ class MDNSComponent final : public Component
StaticVector<std::string, MDNS_DYNAMIC_TXT_COUNT> dynamic_txt_values_;
#endif
#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES)
#if defined(USE_MDNS_DEVICE_INFO_TXT) && defined(USE_MDNS_STORE_SERVICES)
/// Fixed buffer for MAC address (only needed when services are stored)
char mac_address_[MAC_ADDRESS_BUFFER_SIZE];
/// Fixed buffer for config hash hex string (only needed when services are stored)
@@ -149,8 +158,8 @@ class MDNSComponent final : public Component
// RP2040 defers MDNS.begin() until the first IP-up event; this tracks that.
bool initialized_{false};
#endif
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf,
char *config_hash_buf);
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, const char *mac_address_buf,
const char *config_hash_buf);
};
} // namespace esphome::mdns
+1 -1
View File
@@ -12,7 +12,7 @@ namespace esphome::mdns {
void MDNSComponent::setup() {
#ifdef USE_MDNS_STORE_SERVICES
#ifdef USE_API
#ifdef USE_MDNS_DEVICE_INFO_TXT
get_mac_address_into_buffer(this->mac_address_);
char *mac_ptr = this->mac_address_;
format_hex_to(this->config_hash_str_, App.get_config_hash());
+107 -25
View File
@@ -2,12 +2,15 @@ import hashlib
import json
import logging
from pathlib import Path
import re
from urllib.parse import urljoin
from esphome import automation, external_files, git
from esphome.automation import register_action, register_condition
from esphome.bundle import add_bundle_file
import esphome.codegen as cg
from esphome.components import esp32, microphone, ota, psram
from esphome.components.http_request import validate_url
import esphome.config_validation as cv
from esphome.const import (
CONF_FILE,
@@ -28,6 +31,7 @@ from esphome.const import (
TYPE_LOCAL,
)
from esphome.core import CORE, HexInt
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -207,40 +211,62 @@ def _validate_manifest_version(manifest_data):
raise cv.Invalid("Invalid manifest file, missing 'version' key")
def _process_http_source(config):
url = config[CONF_URL]
path = _compute_local_file_path(config)
HTTP_SCHEMA = cv.Schema(
{
# validate_url only accepts http(s); the shorthand validator relies
# on this branch rejecting git shorthands ("github://...") so they
# fall through to the git branch.
cv.Required(CONF_URL): validate_url,
}
)
json_path = path / "manifest.json"
json_contents = external_files.download_content(url, json_path)
def _register_local_model_file(config: ConfigType) -> ConfigType:
"""Register the model file that the manifest points to, so bundles include it.
manifest_data = json.loads(json_contents)
if not isinstance(manifest_data, dict):
raise cv.Invalid("Manifest file must contain a JSON object")
model = manifest_data[CONF_MODEL]
model_url = urljoin(url, model)
model_path = path / model
external_files.download_content(str(model_url), model_path)
The manifest names its model file relative to itself, so that path never appears
in the YAML and bundle discovery cannot find it on its own.
Problems with the manifest are logged and ignored here rather than raised. Loading
the manifest later reports them with better messages, and raising would be
swallowed by the shorthand validator, which then reports a confusing error about a
missing file in a git repository. Logging keeps the skipped registration
diagnosable if the manifest is only briefly unreadable, since the bundle would
then be built without the model file.
"""
manifest_path: Path = config[CONF_PATH]
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
model = manifest[CONF_MODEL]
except (OSError, ValueError, KeyError, TypeError) as err:
_LOGGER.debug("Not registering a model file from %s: %s", manifest_path, err)
return config
if not isinstance(model, str):
_LOGGER.debug(
"Not registering a model file from %s: 'model' is %s, expected a string",
manifest_path,
type(model).__name__,
)
return config
add_bundle_file(manifest_path.parent / model)
return config
HTTP_SCHEMA = cv.All(
{
cv.Required(CONF_URL): cv.url,
},
_process_http_source,
LOCAL_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_),
}
),
_register_local_model_file,
)
LOCAL_SCHEMA = cv.Schema(
{
cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_),
}
)
# Bare model names in the official model repository ("okay_nabu"). Must not
# overlap with local paths, http(s) urls, or git shorthands
# ("github://user/repo/file.json@ref"), which the shorthand validator tries
# next; anything containing "/", ":" or "@" is not a model name.
_MODEL_NAME_RE = re.compile(r"[A-Za-z0-9_.-]+")
def _validate_source_model_name(value):
@@ -250,6 +276,9 @@ def _validate_source_model_name(value):
if value.endswith(".json"):
raise cv.Invalid("Model name must not end with .json")
if not _MODEL_NAME_RE.fullmatch(value):
raise cv.Invalid("Model name may only contain letters, numbers, . _ -")
return MODEL_SOURCE_SCHEMA(
{
CONF_TYPE: TYPE_HTTP,
@@ -339,6 +368,58 @@ def _maybe_empty_vad_schema(value):
return VAD_MODEL_SCHEMA(value)
def _download_http_models(config: ConfigType) -> ConfigType:
"""Download every http-sourced manifest and model file in two concurrent
batches (all manifests, then all model files).
The model file's URL only becomes known once its manifest has been
fetched and parsed, so the two stages cannot be merged into one batch.
"""
model_parameters = [*config[CONF_MODELS]]
if vad := config.get(CONF_VAD):
model_parameters.append(vad)
# Keyed by cache path so a URL referenced twice is fetched and parsed once
http_models: dict[Path, str] = {
_compute_local_file_path(model_config): model_config[CONF_URL]
for parameters in model_parameters
if (model_config := parameters.get(CONF_MODEL)) is not None
and model_config.get(CONF_TYPE) == TYPE_HTTP
}
if not http_models:
return config
external_files.download_content_many(
((url, path / "manifest.json") for path, url in http_models.items()),
description="wake word manifest(s)",
)
model_files: list[tuple[str, Path]] = []
errors: list[cv.Invalid] = []
for path, url in http_models.items():
try:
manifest_data = json.loads((path / "manifest.json").read_bytes())
except (OSError, ValueError) as e:
errors.append(cv.Invalid(f"Invalid manifest file at {url}: {e}"))
continue
if not isinstance(manifest_data, dict):
errors.append(
cv.Invalid(f"Manifest file at {url} must contain a JSON object")
)
continue
model = manifest_data.get(CONF_MODEL)
if not isinstance(model, str):
errors.append(
cv.Invalid(f"Manifest file at {url} is missing the 'model' key")
)
continue
model_files.append((urljoin(url, model), path / model))
if errors:
raise cv.MultipleInvalid(errors)
external_files.download_content_many(model_files, description="wake word model(s)")
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -372,6 +453,7 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
_download_http_models,
)
+84 -13
View File
@@ -26,16 +26,22 @@ from esphome.const import (
CONF_OFFSET_HEIGHT,
CONF_OFFSET_WIDTH,
CONF_PAGES,
CONF_RESET_PIN,
CONF_ROTATION,
CONF_SWAP_XY,
CONF_TRANSFORM,
CONF_WIDTH,
)
from esphome.core import TimePeriod
from esphome.core import CORE, TimePeriod
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
LOGGER = cv.logging.getLogger(__name__)
CONF_TRANSFORMS = "transforms"
# All axis transforms a model may support, in the order they appear in the schema.
ALL_TRANSFORMS = (CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY)
ColorOrder = display_ns.enum("ColorMode")
NOP = 0x00
@@ -302,7 +308,8 @@ class DriverChip:
"""
A class representing a MIPI DBI driver chip model.
The parameters supplied as defaults will be used to provide default values for the display configuration.
Setting swap_xy to cv.UNDEFINED will indicate that the model does not support swapping X and Y axes.
Pass a ``transforms`` set to restrict which axis transforms (mirror_x, mirror_y, swap_xy) the model
supports; by default all three are available.
"""
models: dict[str, Self] = {}
@@ -387,11 +394,15 @@ class DriverChip:
"""
Return the available transforms for this model.
"""
if (transforms := self.get_default(CONF_TRANSFORMS, None)) is not None:
return transforms
if self.get_default("no_transform", False):
return set()
if self.get_default(CONF_SWAP_XY) != cv.UNDEFINED:
return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY}
return {CONF_MIRROR_X, CONF_MIRROR_Y}
raise ValueError(
"Setting 'swap_xy' to 'cv.UNDEFINED' is no longer supported; set 'transforms' instead"
)
def has_hardware_transform(self, config) -> bool:
"""
@@ -533,17 +544,31 @@ class DriverChip:
transform[CONF_TRANSFORM] = self.rotation_as_transform(config)
return transform
def swap_xy_schema(self):
uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED
def transform_schema(self):
"""
Build the schema for the ``transform`` config option of this model.
def validator(value):
if value:
raise cv.Invalid("Axis swapping not supported by this model")
return cv.boolean(value)
Each transform the model supports is a required boolean. A transform the model does not
support may be omitted or set to ``false``; setting it to ``true`` reports a clear error
naming the unsupported transform instead of a generic "extra keys not allowed".
"""
supported = self.transforms
if uses_swap:
return {cv.Required(CONF_SWAP_XY): cv.boolean}
return {cv.Optional(CONF_SWAP_XY, default=False): validator}
def unsupported(name):
def validator(value):
if cv.boolean(value):
raise cv.Invalid(f"'{name}' is not supported by this model")
return False
return validator
schema = {}
for name in ALL_TRANSFORMS:
if name in supported:
schema[cv.Required(name)] = cv.boolean
else:
schema[cv.Optional(name, default=False)] = unsupported(name)
return cv.Any(cv.Schema(schema), cv.one_of(CONF_DISABLED, lower=True))
def get_madctl(self, transform: dict, config: dict) -> int:
"""
@@ -577,12 +602,15 @@ class DriverChip:
"""
return self.get_default(f"no_{command.lower()}", False)
def get_sequence(self, config, add_madctl=True) -> tuple[int, ...]:
def get_sequence(self, config, add_madctl=True, add_reset=False) -> tuple[int, ...]:
"""
Create the init sequence for the display.
Use the default sequence from the model, if any, and append any custom sequence provided in the config.
Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence
MADCTL will be set if add_madctl is True
If add_reset is True, a reset is prepended: a software reset when no reset pin
is configured (and the model doesn't skip it), followed by a settling delay that
both a software and a hardware reset require.
Returns the init sequence
"""
sequence = list(self.initsequence or ())
@@ -591,6 +619,15 @@ class DriverChip:
# Ensure each command is a tuple
sequence = [x if isinstance(x, tuple) else (x,) for x in sequence]
if add_reset:
reset: list = []
# A software reset is only needed when there is no hardware reset pin.
if CONF_RESET_PIN not in config and not self.skip_command("SWRESET"):
reset.append((SWRESET,))
# Both a software and a hardware reset need a settling delay before further commands.
reset.append(delay(10))
sequence = reset + sequence
# Set pixel format if not already in the custom sequence
pixel_mode = config[CONF_PIXEL_MODE]
if not isinstance(pixel_mode, int):
@@ -611,13 +648,47 @@ class DriverChip:
sequence.append((BRIGHTNESS, brightness))
# Add a SLPOUT command if required.
if not self.skip_command("SLPOUT"):
# A zero delay will delay until 120ms after reset
sequence.append(delay(0))
sequence.append((SLPOUT,))
sequence.append(delay(10))
sequence.append((DISPON,))
# Add a delay here because additional commands may be added after this at runtime.
sequence.append(delay(10))
# Flatten the sequence into a list of bytes, with the length of each command
# or the delay flag inserted where needed
return flatten_sequence(sequence)
def check_requirements(self) -> None:
"""
Raise a friendly error if any component this model requires is not configured.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
Also logs a warning if the model is deprecated.
"""
if deprecation_reason := self.get_default("deprecation_reason"):
LOGGER.warning(
"Display model %s is deprecated: %s", self.name, deprecation_reason
)
if requirements := self.get_default("requires", set()):
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
missing = {x for x in requirements if x not in global_config}
if missing:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
def requires_buffer(config) -> bool:
"""
+4 -18
View File
@@ -41,24 +41,21 @@ from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_COLOR_ORDER,
CONF_DIMENSIONS,
CONF_DISABLED,
CONF_ENABLE_PIN,
CONF_ID,
CONF_INIT_SEQUENCE,
CONF_INVERT_COLORS,
CONF_LAMBDA,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL,
CONF_RESET_PIN,
CONF_ROTATION,
CONF_SWAP_XY,
CONF_TRANSFORM,
CONF_WIDTH,
)
from esphome.final_validate import full_config
from . import mipi_dsi_ns, models
from .models import DsiDriverChip
# Currently only ESP32-P4 is supported, so esp_ldo and psram are required
DEPENDENCIES = ["esp32", "esp_ldo", "psram"]
@@ -73,7 +70,7 @@ ColorBitness = display.display_ns.enum("ColorBitness")
CONF_LANE_BIT_RATE = "lane_bit_rate"
CONF_LANES = "lanes"
DriverChip("CUSTOM")
DsiDriverChip("CUSTOM")
# Import all models dynamically from the models package
@@ -90,19 +87,7 @@ COLOR_DEPTHS = {
def model_schema(config):
model = MODELS[config[CONF_MODEL].upper()]
model.defaults[CONF_SWAP_XY] = cv.UNDEFINED
transform = cv.Any(
cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
cv.Required(CONF_MIRROR_Y): cv.boolean,
cv.Optional(CONF_SWAP_XY): cv.invalid(
"Axis swapping not supported by DSI displays"
),
}
),
cv.one_of(CONF_DISABLED, lower=True),
)
transform = model.transform_schema()
# CUSTOM model will need to provide a custom init sequence
iseqconf = (
cv.Required(CONF_INIT_SEQUENCE)
@@ -172,6 +157,7 @@ def _config_schema(config):
)(config)
config = model_schema(config)(config)
model = MODELS[config[CONF_MODEL].upper()]
model.check_requirements()
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
@@ -0,0 +1,14 @@
from esphome.components.mipi import DriverChip
from esphome.const import CONF_SWAP_XY
class DsiDriverChip(DriverChip):
"""A driver chip for MIPI DSI displays."""
@property
def transforms(self) -> set[str]:
"""
Return the set of transformations supported by this driver chip.
DSI displays do not support axis swapping, so this method removes CONF_SWAP_XY
"""
return super().transforms - {CONF_SWAP_XY}
@@ -1,8 +1,7 @@
from esphome.components.mipi import DriverChip
import esphome.config_validation as cv
from . import DsiDriverChip
# fmt: off
DriverChip(
DsiDriverChip(
"JC1060P470",
width=1024,
height=600,
@@ -14,7 +13,6 @@ DriverChip(
vsync_front_porch=12,
pclk_frequency="54MHz",
lane_bit_rate="750Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00),
@@ -46,7 +44,7 @@ DriverChip(
# * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42)
# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166)
# ----------------------------------------------------------------------------------------------------------------------
DriverChip(
DsiDriverChip(
"JC4880P443",
width=480,
height=800,
@@ -58,7 +56,6 @@ DriverChip(
vsync_front_porch=166,
pclk_frequency="34MHz",
lane_bit_rate="500Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
reset_pin=5,
initsequence=[
@@ -111,7 +108,7 @@ DriverChip(
# * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40)
# * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=8, vsync_front_porch=20)
# ----------------------------------------------------------------------------------------------------------------------
DriverChip(
DsiDriverChip(
"JC8012P4A1",
width=800,
height=1280,
@@ -123,7 +120,6 @@ DriverChip(
vsync_front_porch=20,
pclk_frequency="60MHz",
lane_bit_rate="1Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
reset_pin=27,
initsequence=[
+59 -7
View File
@@ -1,8 +1,7 @@
from esphome.components.mipi import DriverChip
import esphome.config_validation as cv
from . import DsiDriverChip
# fmt: off
DriverChip(
DsiDriverChip(
"M5STACK-TAB5",
height=1280,
width=720,
@@ -14,7 +13,6 @@ DriverChip(
vsync_front_porch=20,
pclk_frequency="60MHz",
lane_bit_rate="730Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
@@ -56,8 +54,8 @@ DriverChip(
],
)
DriverChip(
"M5STACK-TAB5-V2",
TAB5_ST7123 = DsiDriverChip(
"M5STACK-TAB5-ST7123",
height=1280,
width=720,
hsync_back_porch=40,
@@ -68,7 +66,6 @@ DriverChip(
vsync_front_porch=220,
pclk_frequency="80MHz",
lane_bit_rate="960Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0x01,),
@@ -97,3 +94,58 @@ DriverChip(
(0xC9, 0x00, 0x00, 0x10, 0x1F, 0x36, 0x00, 0x5D, 0x04, 0x9D, 0x05, 0x10, 0xF2, 0x06, 0x60, 0x03, 0x11, 0xAD, 0x00, 0xEF, 0x01, 0x22, 0x2E, 0x0E, 0x74, 0x08, 0x32, 0xDC, 0x09, 0x33, 0x0F, 0xF3, 0x77, 0x0D, 0xB0, 0xDC, 0x03, 0xFF),
],
)
TAB5_ST7123.extend(
"M5STACK-TAB5-V2",
deprecation_reason="Use 'M5STACK-TAB5-ST7123' or 'M5STACK-TAB5-ST7121' instead."
)
# Some Tab5 "v2" units ship with an ST7121 controller instead of the ST7123.
# The two are distinguishable at runtime by the touch controller firmware version (the M5
# factory firmware branches on it), but ESPHome selects the panel at compile time, so ST7121
# units must select this model explicitly. Values taken from M5's factory source
# (m5stack/M5Tab5-UserDemo: m5stack_tab5.c is_st7121 path + esp_lcd_st7121.c default table).
DsiDriverChip(
"M5STACK-TAB5-ST7121",
height=1280,
width=720,
hsync_back_porch=40,
hsync_pulse_width=2,
hsync_front_porch=40,
vsync_back_porch=24,
vsync_pulse_width=20,
vsync_front_porch=200,
pclk_frequency="70MHz",
lane_bit_rate="965Mbps",
color_order="RGB",
initsequence=[
(0x01,),
(0x60, 0x71, 0x21, 0xA2),
(0x60, 0x71, 0x21, 0xA3),
(0x60, 0x71, 0x21, 0xA4),
(0x78, 0x21),
(0x79, 0xEF),
(0xA4, 0x31),
(0xB7, 0x00, 0x00, 0x5F, 0x5F, 0x44, 0x1A),
(0xB0, 0x22, 0x6B, 0x11, 0x89, 0x25, 0x43, 0x43),
(0xBF, 0xA7, 0xA7),
(0xA5, 0xF0, 0x03),
(0xD7, 0x10, 0x2C, 0x14, 0x2A, 0x80, 0x80),
(0x90, 0x71, 0x23, 0x5A, 0x20, 0x24, 0x11, 0x21),
(0xA3, 0x80, 0x01, 0x8C, 0xFF, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0xEF, 0x58, 0x00, 0x00, 0x00, 0xFF),
(0xA6, 0x0A, 0x00, 0x24, 0x71, 0x36, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x37, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x00, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x2C, 0x71, 0x00, 0x01, 0x00, 0x00, 0x68, 0x68, 0xFF, 0xFF, 0x00, 0x08, 0x80, 0x08, 0x80, 0x06, 0x00, 0x00, 0x00, 0x00),
(0xA7, 0x1A, 0x1A, 0xC0, 0x64, 0x40, 0x04, 0x15, 0x40, 0x00, 0x40, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x26, 0x37, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x8C, 0x9D, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0xAE, 0xBF, 0x00, 0x00, 0x20, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x79),
(0xAC, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x04, 0x1C, 0x1D, 0x08, 0x0A, 0x10, 0x12, 0x0C, 0x0E, 0x14, 0x16, 0x00, 0x1D, 0x1D, 0x1D, 0x1D, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x06, 0x1C, 0x1D, 0x09, 0x0B, 0x11, 0x13, 0x0D, 0x0F, 0x15, 0x17, 0x02, 0x1D, 0x1D, 0x1D, 0x1D),
(0xAD, 0x0C, 0x40, 0x46, 0x00, 0x07, 0x4B, 0x4B, 0xFF, 0xFF, 0xF0, 0x40, 0x0E, 0x01, 0x07, 0x42, 0x42, 0xFF, 0xFF, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF),
(0xAE, 0xF0, 0xFF, 0x03, 0xF0, 0xFF, 0x03, 0x00),
(0xB2, 0x15, 0x19, 0x05, 0x23, 0x49, 0x2D, 0x03, 0x2E, 0x5C, 0xD2, 0xFF, 0x10, 0x60, 0xFD, 0x20, 0xC0, 0x00),
(0xE8, 0x20, 0x60, 0x04, 0x8E, 0x8E, 0x3E, 0x04, 0xDC, 0xDC, 0x3E, 0x06, 0xFA, 0x26, 0x3E),
(0x75, 0x03, 0x04),
(0xE7, 0x4B, 0x00, 0x00, 0xBE, 0x4B, 0x8C, 0x20, 0x1A, 0xF0, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0xFF, 0x00, 0x32, 0x30, 0x73, 0x00, 0x00, 0xC8, 0x6A, 0xFF, 0x5A, 0x64, 0x38, 0x88, 0x15, 0xB1, 0x01, 0x01, 0x64, 0x01, 0x01, 0x7C, 0xFF, 0x1A, 0x51),
(0xE1, 0x0C, 0x0C),
(0xEA, 0x15, 0x00, 0x01),
(0xC8, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF),
(0xC9, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF),
(0x60, 0x71, 0x21, 0x00),
],
)
+3 -4
View File
@@ -1,9 +1,8 @@
from esphome.components.mipi import DriverChip
import esphome.config_validation as cv
from . import DsiDriverChip
# Standalone display
# Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html
DriverChip(
DsiDriverChip(
"SEEED-RETERMINAL-D1001",
height=1280,
width=800,
@@ -15,10 +14,10 @@ DriverChip(
vsync_front_porch=30,
pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
reset_pin={"xl9535": None, "number": 2},
requires={"psram", "xl9535"},
initsequence=(
(0xE0, 0x00),
(0xE1, 0x93),
@@ -1,12 +1,11 @@
from esphome.components.mipi import DriverChip
import esphome.config_validation as cv
from . import DsiDriverChip
# fmt: off
# Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1
# Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage
JD9365_10_1_DSI_TOUCH_A = DriverChip(
JD9365_10_1_DSI_TOUCH_A = DsiDriverChip(
"WAVESHARE-P4-NANO-10.1",
height=1280,
width=800,
@@ -18,7 +17,6 @@ JD9365_10_1_DSI_TOUCH_A = DriverChip(
vsync_front_porch=30,
pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0xE0, 0x00), # select userpage
@@ -65,7 +63,7 @@ JD9365_10_1_DSI_TOUCH_A.extend(
# Source for parameters and initsequence:
# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703
# Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO
DriverChip(
DsiDriverChip(
"WAVESHARE-P4-86-PANEL",
height=720,
width=720,
@@ -77,7 +75,6 @@ DriverChip(
vsync_front_porch=20,
pclk_frequency="38MHz",
lane_bit_rate="480Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
reset_pin=27,
initsequence=[
@@ -109,7 +106,7 @@ DriverChip(
# Source for parameters and initsequence:
# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B
DriverChip(
DsiDriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B",
height=600,
width=1024,
@@ -139,7 +136,7 @@ DriverChip(
# Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C
JD9365_3_4_DSI_TOUCH_C = DriverChip(
JD9365_3_4_DSI_TOUCH_C = DsiDriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C",
height=800,
width=800,
@@ -151,7 +148,6 @@ JD9365_3_4_DSI_TOUCH_C = DriverChip(
vsync_front_porch=24,
pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0xE0, 0x00), # select userpage
@@ -197,7 +193,7 @@ JD9365_3_4_DSI_TOUCH_C.extend(
# Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C
JD9365_4_DSI_TOUCH_C = DriverChip(
JD9365_4_DSI_TOUCH_C = DsiDriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C",
height=720,
width=720,
@@ -209,7 +205,6 @@ JD9365_4_DSI_TOUCH_C = DriverChip(
vsync_front_porch=24,
pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0xE0, 0x00), # select userpage
@@ -255,7 +250,7 @@ JD9365_4_DSI_TOUCH_C.extend(
# Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
# Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A
DriverChip(
DsiDriverChip(
"WAVESHARE-8-DSI-TOUCH-A",
height=1280,
width=800,
@@ -267,7 +262,6 @@ DriverChip(
vsync_front_porch=30,
pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0xE0, 0x00), # select userpage
@@ -304,7 +298,7 @@ DriverChip(
# Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c
# Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A
DriverChip(
DsiDriverChip(
"WAVESHARE-7-DSI-TOUCH-A",
height=1280,
width=720,
+12 -19
View File
@@ -18,6 +18,8 @@ from esphome.components.mipi import (
CONF_HSYNC_BACK_PORCH,
CONF_HSYNC_FRONT_PORCH,
CONF_HSYNC_PULSE_WIDTH,
CONF_PCLK_FREQUENCY,
CONF_PCLK_INVERTED,
CONF_PCLK_PIN,
CONF_PIXEL_MODE,
CONF_USE_AXIS_FLIPS,
@@ -34,9 +36,11 @@ from esphome.components.mipi import (
power_of_two,
requires_buffer,
)
from esphome.components.rpi_dpi_rgb.display import (
CONF_PCLK_FREQUENCY,
CONF_PCLK_INVERTED,
from esphome.components.spi import (
CONF_SPI_MODE,
SPI_DATA_RATE_SCHEMA,
SPI_MODE_OPTIONS,
SPIComponent,
)
import esphome.config_validation as cv
from esphome.const import (
@@ -48,7 +52,6 @@ from esphome.const import (
CONF_DATA_RATE,
CONF_DC_PIN,
CONF_DIMENSIONS,
CONF_DISABLED,
CONF_ENABLE_PIN,
CONF_GREEN,
CONF_HSYNC_PIN,
@@ -57,8 +60,6 @@ from esphome.const import (
CONF_INIT_SEQUENCE,
CONF_INVERT_COLORS,
CONF_LAMBDA,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL,
CONF_NUMBER,
CONF_RED,
@@ -72,10 +73,10 @@ from esphome.const import (
)
from esphome.final_validate import full_config
from ..spi import CONF_SPI_MODE, SPI_DATA_RATE_SCHEMA, SPI_MODE_OPTIONS, SPIComponent
from . import models
from .models import RgbDriverChip
DEPENDENCIES = ["esp32", "psram"]
DEPENDENCIES = ["esp32"]
mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb")
mipi_rgb = mipi_rgb_ns.class_("MipiRgb", display.Display, cg.Component)
@@ -86,7 +87,7 @@ ColorOrder = display.display_ns.enum("ColorMode")
DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema
DriverChip("CUSTOM")
RgbDriverChip("CUSTOM")
# Import all models dynamically from the models package
@@ -120,16 +121,7 @@ def data_pin_set(length):
def model_schema(config):
model = MODELS[config[CONF_MODEL].upper()]
transform = cv.Any(
cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
cv.Required(CONF_MIRROR_Y): cv.boolean,
**model.swap_xy_schema(),
}
),
cv.one_of(CONF_DISABLED, lower=True),
)
transform = model.transform_schema()
# RPI model does not use an init sequence, indicates with empty list
if model.initsequence is None:
# Custom model requires an init sequence
@@ -235,6 +227,7 @@ def _config_schema(config):
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
)(config)
model = MODELS[config[CONF_MODEL].upper()]
model.check_requirements()
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
@@ -0,0 +1,14 @@
from esphome.components.mipi import DriverChip
from esphome.const import CONF_SWAP_XY
class RgbDriverChip(DriverChip):
"""A driver chip for MIPI RGB displays."""
@property
def transforms(self) -> set[str]:
"""
Return the set of transformations supported by this driver chip.
RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY
"""
return super().transforms - {CONF_SWAP_XY}
@@ -5,6 +5,7 @@ st7701s.extend(
width=480,
height=480,
data_rate="2MHz",
requires={"psram"},
cs_pin=39,
de_pin=18,
hsync_pin=16,
+2 -4
View File
@@ -1,5 +1,3 @@
from esphome.config_validation import UNDEFINED
from .st7701s import ST7701S
# fmt: off
@@ -8,10 +6,10 @@ ST7701S(
width=480,
height=480,
invert_colors=False,
swap_xy=UNDEFINED,
spi_mode="MODE3",
cs_pin={"xl9535": None, "number": 17},
reset_pin={"xl9535": None, "number": 5},
requires={"psram", "xl9535"},
hsync_pin=39,
vsync_pin=40,
pclk_pin=41,
@@ -57,9 +55,9 @@ t_rgb = ST7701S(
height=480,
pixel_mode="18bit",
invert_colors=False,
swap_xy=UNDEFINED,
spi_mode="MODE3",
cs_pin={"xl9535": None, "number": 3},
requires={"psram", "xl9535"},
de_pin=45,
hsync_pin=47,
vsync_pin=41,
+2 -4
View File
@@ -1,9 +1,7 @@
from esphome.components.mipi import DriverChip
from esphome.config_validation import UNDEFINED
from . import RgbDriverChip
# A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence
DriverChip(
RgbDriverChip(
"RPI",
swap_xy=UNDEFINED,
initsequence=(),
)
+8 -10
View File
@@ -1,17 +1,12 @@
from esphome.components.mipi import (
MADCTL,
MADCTL_ML,
MADCTL_XFLIP,
MODE_BGR,
DriverChip,
)
from esphome.config_validation import UNDEFINED
from esphome.components.mipi import MADCTL, MADCTL_ML, MADCTL_XFLIP, MODE_BGR
from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y
from . import RgbDriverChip
SDIR_CMD = 0xC7
class ST7701S(DriverChip):
class ST7701S(RgbDriverChip):
# The ST7701s does not use the standard MADCTL bits for x/y mirroring
def add_madctl(self, sequence: list, config: dict):
transform = self.get_transform(config)
@@ -45,7 +40,6 @@ st7701s = ST7701S(
"ST7701S",
width=480,
height=864,
swap_xy=UNDEFINED,
hsync_front_porch=20,
hsync_back_porch=10,
hsync_pulse_width=10,
@@ -85,6 +79,7 @@ st7701s.extend(
height=480,
invert_colors=True,
pixel_mode="18bit",
requires={"psram"},
cs_pin=1,
de_pin={
"number": 45,
@@ -117,6 +112,7 @@ st7701s.extend(
vsync_pulse_width=8,
vsync_back_porch=20,
cs_pin={"pca9554": None, "number": 4},
requires={"psram", "pca9554"},
de_pin=18,
hsync_pin=16,
vsync_pin=17,
@@ -134,6 +130,7 @@ st7701s.extend(
width=480,
height=480,
pixel_mode="18bit",
requires={"psram"},
cs_pin=18,
reset_pin=8,
de_pin=17,
@@ -177,6 +174,7 @@ st7701s.extend(
width=480,
height=480,
pixel_mode="18bit",
requires={"psram"},
cs_pin=21,
de_pin=39,
vsync_pin=48,
+3 -5
View File
@@ -1,14 +1,13 @@
from esphome.components.mipi import DriverChip
from esphome.config_validation import UNDEFINED
from . import RgbDriverChip
# fmt: off
sunton = DriverChip(
sunton = RgbDriverChip(
"ESP32-8048S070",
swap_xy=UNDEFINED,
initsequence=(),
width=800,
height=480,
pclk_frequency="12.5MHz",
requires={"psram"},
de_pin=41,
hsync_pin=39,
vsync_pin=40,
@@ -28,7 +27,6 @@ sunton = DriverChip(
sunton.extend(
"ESP32-8048S050",
swap_xy=UNDEFINED,
initsequence=(),
width=800,
height=480,
@@ -1,18 +1,18 @@
from esphome.components.mipi import DriverChip, delay
from esphome.config_validation import UNDEFINED
from esphome.components.mipi import delay
from . import RgbDriverChip
from .st7701s import st7701s
# fmt: off
wave_4_3 = DriverChip(
wave_4_3 = RgbDriverChip(
"ESP32-S3-TOUCH-LCD-4.3",
swap_xy=UNDEFINED,
initsequence=(),
width=800,
height=480,
pclk_frequency="16MHz",
reset_pin={"ch422g": None, "number": 3},
enable_pin={"ch422g": None, "number": 2},
requires={"psram", "ch422g"},
de_pin=5,
hsync_pin={"number": 46, "ignore_strapping_warning": True},
vsync_pin={"number": 3, "ignore_strapping_warning": True},
@@ -69,6 +69,7 @@ st7701s.extend(
pclk_pin=41,
pclk_frequency="12MHz",
pclk_inverted=False,
requires={"psram"},
data_pins={
"red": [46, 3, 8, 18, 17],
"green": [14, 13, 12, 11, 10, 9],
@@ -80,6 +81,7 @@ st7701s.extend(
"WAVESHARE-3.16-320X820",
width=320,
height=820,
requires={"psram"},
de_pin=40,
hsync_pin=38,
vsync_pin=39,
+3 -14
View File
@@ -41,14 +41,11 @@ from esphome.const import (
CONF_DATA_RATE,
CONF_DC_PIN,
CONF_DIMENSIONS,
CONF_DISABLED,
CONF_ENABLE_PIN,
CONF_ID,
CONF_INIT_SEQUENCE,
CONF_INVERT_COLORS,
CONF_LAMBDA,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL,
CONF_RESET_PIN,
CONF_ROTATION,
@@ -138,16 +135,7 @@ def denominator(config):
def model_schema(config):
model = MODELS[config[CONF_MODEL]]
bus_mode = config[CONF_BUS_MODE]
transform = cv.Any(
cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
cv.Required(CONF_MIRROR_Y): cv.boolean,
**model.swap_xy_schema(),
}
),
cv.one_of(CONF_DISABLED, lower=True),
)
transform = model.transform_schema()
# CUSTOM model will need to provide a custom init sequence
iseqconf = (
cv.Required(CONF_INIT_SEQUENCE)
@@ -265,6 +253,7 @@ def customise_schema(config):
extra=ALLOW_EXTRA,
)(config)
model = MODELS[config[CONF_MODEL]]
model.check_requirements()
bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL)
config = cv.Schema(
{
@@ -408,7 +397,7 @@ def get_instance(config):
async def to_code(config):
model = MODELS[config[CONF_MODEL]]
var_id = config[CONF_ID]
init_sequence = model.get_sequence(config, False)
init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True)
var_id.type, templateargs = get_instance(config)
var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs))
cg.add(var.set_init_sequence(init_sequence))
+13 -26
View File
@@ -13,6 +13,8 @@ constexpr static const char *const TAG = "display.mipi_spi";
// Maximum bytes to log for commands (truncated if larger)
static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64;
// Command codes for MIPI SPI displays. Not all currently used, kept here for reference.
static constexpr uint8_t SW_RESET_CMD = 0x01;
static constexpr uint8_t SLEEP_OUT = 0x11;
static constexpr uint8_t NORON = 0x13;
@@ -151,14 +153,11 @@ class MipiSpi : public display::Display,
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
} else {
// no reset pin, send software reset command
this->write_command_(SW_RESET_CMD);
// required delay after reset is already in the init sequence, don't duplicate
}
// need to know when the display is ready for SLPOUT command - will be 120ms after reset
auto when = millis() + 120;
delay(10);
size_t index = 0;
auto &vec = this->init_sequence_;
while (index != vec.size()) {
@@ -170,6 +169,9 @@ class MipiSpi : public display::Display,
uint8_t cmd = vec[index++];
uint8_t x = vec[index++];
if (x == DELAY_FLAG) {
if (cmd == 0) {
cmd = clamp_at_least((int) (when - millis()), 0);
}
esph_log_d(TAG, "Delay %dms", cmd);
delay(cmd);
} else {
@@ -179,24 +181,9 @@ class MipiSpi : public display::Display,
this->mark_failed();
return;
}
switch (cmd) {
case SLEEP_OUT: {
// are we ready, boots?
int duration = when - millis();
if (duration > 0) {
esph_log_d(TAG, "Sleep %dms", duration);
delay(duration);
}
} break;
default:
break;
}
const auto *ptr = vec.data() + index;
this->write_command_(cmd, ptr, num_args);
index += num_args;
if (cmd == SLEEP_OUT)
delay(10);
}
}
this->reset_params_();
@@ -398,10 +385,10 @@ class MipiSpi : public display::Display,
* @param ptr The pointer to the pixel data
* @param w Width of each line in bytes
* @param h Height of the buffer in rows
* @param pad Padding in bytes after each line
* @param stride Total length of each line in bytes, including any padding
*/
void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t pad) {
if (pad == 0) {
void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t stride) {
if (stride == w) {
if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) {
this->write_array(ptr, w * h);
} else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
@@ -418,7 +405,7 @@ class MipiSpi : public display::Display,
} else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) {
this->write_cmd_addr_data(0, 0, 0, 0, ptr, w, 8);
}
ptr += w + pad;
ptr += stride;
}
}
}
@@ -436,7 +423,7 @@ class MipiSpi : public display::Display,
ptr += y_offset * (x_offset + w + x_pad) + x_offset;
if constexpr (BUFFERPIXEL == DISPLAYPIXEL) {
this->write_display_data_(reinterpret_cast<const uint8_t *>(ptr), w * sizeof(BUFFERTYPE), h,
x_pad * sizeof(BUFFERTYPE));
(x_offset + w + x_pad) * sizeof(BUFFERTYPE));
} else {
// type conversion required, do it in chunks
uint8_t dbuffer[DISPLAYPIXEL * 48];
@@ -472,14 +459,14 @@ class MipiSpi : public display::Display,
}
// buffer full? Flush.
if (dptr == dbuffer + sizeof(dbuffer)) {
this->write_display_data_(dbuffer, sizeof(dbuffer), 1, 0);
this->write_display_data_(dbuffer, sizeof(dbuffer), 1, sizeof(dbuffer));
dptr = dbuffer;
}
}
}
// flush any remaining data
if (dptr != dbuffer) {
this->write_display_data_(dbuffer, dptr - dbuffer, 1, 0);
this->write_display_data_(dbuffer, dptr - dbuffer, 1, dptr - dbuffer);
}
}
this->disable();
@@ -13,6 +13,7 @@ ST7789V.extend(
mirror_x=True,
mirror_y=True,
data_rate="80MHz",
requires={"psram"},
)
ST7789V.extend(
@@ -25,4 +26,5 @@ ST7789V.extend(
dc_pin=39,
reset_pin=40,
invert_colors=True,
requires={"psram"},
)
+5 -2
View File
@@ -16,7 +16,7 @@ from esphome.components.mipi import (
delay,
)
from esphome.components.spi import TYPE_QUAD
from esphome.config_validation import UNDEFINED
from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y
DriverChip(
"T-DISPLAY-S3-AMOLED",
@@ -29,6 +29,7 @@ DriverChip(
brightness=0xD0,
color_order=MODE_RGB,
no_slpout=True, # SLPOUT is in the init sequence, early
requires={"psram"},
initsequence=(SLPOUT,),
)
@@ -43,6 +44,7 @@ DriverChip(
data_rate="40MHz",
brightness=0xD0,
color_order=MODE_RGB,
requires={"psram"},
initsequence=(
(PAGESEL, 4),
(0x6A, 0x00),
@@ -90,6 +92,7 @@ T4_S3_AMOLED = RM690B0.extend(
reset_pin=13,
enable_pin=9,
bus_mode=TYPE_QUAD,
requires={"psram"},
)
CO5300 = DriverChip(
@@ -98,7 +101,7 @@ CO5300 = DriverChip(
color_order=MODE_RGB,
bus_mode=TYPE_QUAD,
no_slpout=True,
swap_xy=UNDEFINED,
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
width=480,
height=480,
initsequence=(
@@ -314,6 +314,7 @@ DriverChip(
data_rate="40MHz",
dc_pin=4,
cs_pin=5,
requires={"psram"},
# reset_pin={CONF_INVERTED: True, CONF_NUMBER: 48},
initsequence=(
(0xEF, 0x03, 0x80, 0x02),
@@ -379,6 +380,7 @@ DriverChip(
cs_pin=5,
dc_pin=4,
reset_pin=48,
requires={"psram"},
initsequence=(
(0xEF, 0x03, 0x80, 0x02),
(0xCF, 0x00, 0xC1, 0x30),
@@ -711,6 +713,7 @@ ST7796.extend(
reset_pin=4,
dc_pin={"number": 0, "ignore_strapping_warning": True},
invert_colors=True,
requires={"psram"},
)
ST7789V.extend(
+12 -3
View File
@@ -1,14 +1,19 @@
from esphome.components.mipi import MODE_RGB, DriverChip
from esphome.components.spi import TYPE_QUAD
import esphome.config_validation as cv
from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER
from esphome.const import (
CONF_IGNORE_STRAPPING_WARNING,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_NUMBER,
)
AXS15231 = DriverChip(
"AXS15231",
draw_rounding=8,
swap_xy=cv.UNDEFINED,
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
color_order=MODE_RGB,
bus_mode=TYPE_QUAD,
no_swreset=True,
initsequence=(
(0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5),
(0xC1, 0x33),
@@ -22,6 +27,7 @@ AXS15231.extend(
height=480,
cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True},
data_rate="40MHz",
requires={"psram"},
)
DriverChip(
@@ -36,6 +42,7 @@ DriverChip(
color_order=MODE_RGB,
bus_mode=TYPE_QUAD,
data_rate="40MHz",
requires={"psram"},
initsequence=(
(0xF0, 0x08),
(0xF2, 0x08),
@@ -267,6 +274,7 @@ DriverChip(
color_order=MODE_RGB,
bus_mode=TYPE_QUAD,
data_rate="40MHz",
requires={"psram"},
initsequence=(
(0xF0, 0x28),
(0xF2, 0x28),
@@ -495,6 +503,7 @@ DriverChip(
color_order=MODE_RGB,
bus_mode=TYPE_QUAD,
data_rate="20MHz",
requires={"psram"},
initsequence=(
(0xFF, 0xA5),
(0x41, 0x03),
@@ -10,4 +10,5 @@ ST7789V.extend(
cs_pin=22,
dc_pin=21,
reset_pin=18,
requires={"psram"},
)
@@ -15,6 +15,7 @@ ST7789V.extend(
dc_pin=13,
reset_pin=9,
data_rate="80MHz",
requires={"psram"},
)
ST7789V.extend(
@@ -42,6 +43,7 @@ ST7789V.extend(
enable_pin=[9, 15],
data_rate="10MHz",
bus_mode=TYPE_OCTAL,
requires={"psram"},
)
ST7796.extend(
@@ -55,4 +57,5 @@ ST7796.extend(
dc_pin=9,
backlight_pin=48,
invert_colors=True,
requires={"psram"},
)
@@ -49,6 +49,7 @@ ILI9341.extend(
invert_colors=True,
pixel_mode="18bit",
data_rate="40MHz",
requires={"psram"},
)
GC9107 = ST7789V.extend(
@@ -68,4 +69,5 @@ GC9107.extend(
reset_pin=48,
dc_pin=42,
cs_pin=14,
requires={"psram"},
)
@@ -12,7 +12,7 @@ from esphome.components.mipi import (
PWSET,
DriverChip,
)
import esphome.config_validation as cv
from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y
from .amoled import CO5300
from .ili import ILI9488_A, ST7789V
@@ -155,7 +155,7 @@ ST7789P = DriverChip(
ILI9488_A.extend(
"PICO-RESTOUCH-LCD-3.5",
swap_xy=cv.UNDEFINED,
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
spi_16=True,
pixel_mode="16bit",
mirror_x=True,
@@ -175,6 +175,7 @@ CO5300.extend(
offset_width=6,
cs_pin=12,
reset_pin=39,
requires={"psram"},
)
# Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller)
@@ -189,6 +190,7 @@ CO5300.extend(
cs_pin=12,
reset_pin=39,
data_rate="40MHz",
requires={"psram"},
)
AXS15231.extend(
@@ -198,6 +200,7 @@ AXS15231.extend(
data_rate="80MHz",
cs_pin=9,
reset_pin=21,
requires={"psram"},
)
# Waveshare 1.83-v2
@@ -281,6 +284,7 @@ ST7789V.extend(
offset_height=40,
invert_colors=True,
data_rate="40MHz",
requires={"psram"},
)
CO5300.extend(
@@ -291,4 +295,5 @@ CO5300.extend(
cs_pin=9,
reset_pin=21,
enable_pin=1,
requires={"psram"},
)
+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
+11 -1
View File
@@ -69,7 +69,12 @@ from .const import (
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
)
from .framework import check_and_install, get_build_env, get_build_paths
from .framework import (
check_and_install,
get_build_env,
get_build_paths,
setup_platformio_python_env,
)
# force import gpio to register pin schema
from .gpio import nrf52_pin_to_code # noqa: F401
@@ -514,6 +519,7 @@ def _upload_using_platformio(
) -> int | str:
from esphome.platformio import toolchain
setup_platformio_python_env()
if port is not None:
upload_args += ["--upload-port", port]
return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args)
@@ -809,6 +815,10 @@ def _copy_if_exists(src: Path, dst: Path) -> None:
def run_compile(args, config: ConfigType) -> bool:
if CORE.using_toolchain_platformio:
# The actual build is done by PlatformIO (the caller falls through to
# it when this returns False); prepare the Python environment its
# Zephyr build script expects first.
setup_platformio_python_env()
return False
if not CORE.using_toolchain_sdk_nrf:
raise EsphomeError(
+91 -1
View File
@@ -4,6 +4,7 @@ import os
from pathlib import Path
import platform
import shutil
import sys
import tempfile
import platformdirs
@@ -27,6 +28,11 @@ _LOGGER = logging.getLogger(__name__)
_REQUIREMENTS = Path(__file__).parent / "requirements.txt"
TOOLCHAIN_VERSION = "0.17.4"
# Packages the PlatformIO toolchain's Zephyr build script needs beyond west
# (which comes from requirements.txt). Keep the pin in sync with
# framework-sdk-nrf scripts/platformio/platformio-build.py.
_PLATFORMIO_PENV_REQUIREMENTS: tuple[str, ...] = ("cbor2==5.6.5",)
SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
os.environ.get(
"ESPHOME_SDK_NG_TOOLCHAIN_MIRRORS",
@@ -133,10 +139,94 @@ def get_build_env() -> dict:
env = os.environ.copy()
env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "")
env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr")
env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION) / "cmake")
# ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr documents for pointing at
# the SDK: FindZephyr-sdk.cmake reads it (from the environment, via
# zephyr_get) and passes it straight to find_package as a HINT. This
# matters because the SDK lives in the esphome cache dir, which is not on
# the module's static search path (/usr, /opt, $HOME, ...). A generic
# "Zephyr-sdk_DIR" environment hint proved unreliable here: containerized
# non-root builds failed to locate the SDK with it, while
# ZEPHYR_SDK_INSTALL_DIR fixed the same invocation.
env["ZEPHYR_SDK_INSTALL_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION))
return env
def _get_platformio_penv_path() -> Path:
return get_sdk_nrf_tools_path() / "penvs" / "platformio"
def _get_penv_site_packages(penv_path: Path) -> Path:
if os.name == "nt":
return penv_path / "Lib" / "site-packages"
python_dir = f"python{sys.version_info.major}.{sys.version_info.minor}"
return penv_path / "lib" / python_dir / "site-packages"
def _prepend_env_path(name: str, entry: str) -> None:
"""Prepend ``entry`` to the ``os.pathsep``-separated env var ``name``."""
current = os.environ.get(name, "")
entries = current.split(os.pathsep) if current else []
if entry not in entries:
os.environ[name] = os.pathsep.join([entry, *entries])
def setup_platformio_python_env() -> None:
"""Make the Zephyr build's Python packages available to PlatformIO.
The PlatformIO toolchain's Zephyr framework build script pip-installs
west and cbor2 (and pyocd on x86_64) into the Python environment running
PlatformIO whenever they are not importable. That environment is not
always writable for example the docker image run as a non-root user,
where ESPHome lives in the system Python so the install fails with
"Permission denied". Instead, pre-install those packages into a dedicated
venv under the sdk-nrf tools dir and expose it to the PlatformIO
subprocesses through the environment:
* PYTHONPATH makes the venv's packages importable from the interpreter
that runs PlatformIO/SCons, so the build script skips its installs.
* VIRTUAL_ENV redirects any install the build script still performs via
uv (pyocd is fetched on demand) into the writable venv.
* PATH exposes console scripts installed into the venv (e.g. pyocd).
"""
penv_path = _get_platformio_penv_path()
env_python_path = get_python_env_executable_path(penv_path, "python")
sentinel = penv_path / ".ready"
# Include the Python version: the venv breaks when the interpreter it
# was created from is upgraded, so it must be rebuilt.
requirements_hash = hashlib.sha256(
_REQUIREMENTS.read_bytes()
+ "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode()
+ f"python{sys.version_info.major}.{sys.version_info.minor}".encode()
).hexdigest()
if (
not sentinel.exists()
or sentinel.read_text(encoding="utf-8") != requirements_hash
):
rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment")
create_venv(penv_path, msg="PlatformIO toolchain")
_LOGGER.info("Installing PlatformIO toolchain requirements ...")
cmd = [
str(env_python_path),
"-m",
"pip",
"install",
"-r",
str(_REQUIREMENTS),
*_PLATFORMIO_PENV_REQUIREMENTS,
]
if not run_command_ok(cmd):
raise EsphomeError(
"Install requirements for PlatformIO toolchain Python environment failure"
)
sentinel.write_text(requirements_hash, encoding="utf-8")
os.environ["VIRTUAL_ENV"] = str(penv_path)
_prepend_env_path("PYTHONPATH", str(_get_penv_site_packages(penv_path)))
_prepend_env_path("PATH", str(env_python_path.parent))
def _patch_uf2conv_escape_sequences(framework_path: Path) -> None:
# SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that
# Python 3.12+ flags with SyntaxWarning (a future version will reject it).
+9 -3
View File
@@ -212,9 +212,15 @@ _NUMBER_SCHEMA = (
},
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
),
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement,
cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
): validate_unit_of_measurement,
cv.Optional(
CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED
): cv.enum(NUMBER_MODES, upper=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
}
)
)
+24 -16
View File
@@ -101,25 +101,31 @@ void SEN5XComponent::setup() {
ESP_LOGV(TAG, "Serial number %s", this->serial_number_);
uint16_t raw_product_name[16];
if (!this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) {
ESP_LOGE(TAG, "Failed to read product name");
this->error_code_ = PRODUCT_NAME_FAILED;
this->mark_failed();
return;
Sen5xType detected_type = Sen5xType::UNKNOWN;
if (this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) {
const char *product_name = sensirion_convert_to_string_in_place(raw_product_name, 16);
if (strncmp(product_name, "SEN50", 5) == 0) {
detected_type = Sen5xType::SEN50;
} else if (strncmp(product_name, "SEN54", 5) == 0) {
detected_type = Sen5xType::SEN54;
} else if (strncmp(product_name, "SEN55", 5) == 0) {
detected_type = Sen5xType::SEN55;
}
}
const char *product_name = sensirion_convert_to_string_in_place(raw_product_name, 16);
if (strncmp(product_name, "SEN50", 5) == 0) {
this->type_ = Sen5xType::SEN50;
} else if (strncmp(product_name, "SEN54", 5) == 0) {
this->type_ = Sen5xType::SEN54;
} else if (strncmp(product_name, "SEN55", 5) == 0) {
this->type_ = Sen5xType::SEN55;
} else {
if (this->model_override_.has_value()) {
if (detected_type != this->model_override_.value()) {
ESP_LOGW(TAG, "Detected %s, using %s", LOG_STR_ARG(type_to_string(detected_type)),
LOG_STR_ARG(type_to_string(this->model_override_.value())));
}
this->type_ = this->model_override_.value();
} else if (detected_type == Sen5xType::UNKNOWN) {
this->type_ = Sen5xType::UNKNOWN;
ESP_LOGE(TAG, "Unknown product name: %.32s", product_name);
this->error_code_ = PRODUCT_NAME_FAILED;
this->mark_failed();
return;
} else {
this->type_ = detected_type;
}
ESP_LOGD(TAG, "Type: %s", LOG_STR_ARG(type_to_string(this->type_)));
@@ -255,10 +261,12 @@ void SEN5XComponent::dump_config() {
}
}
ESP_LOGCONFIG(TAG,
" Type: %s\n"
" Type: %s%s\n"
" Firmware version: %d\n"
" Serial number: %s",
LOG_STR_ARG(type_to_string(this->type_)), this->firmware_version_, this->serial_number_);
LOG_STR_ARG(type_to_string(this->type_)),
this->model_override_.has_value() ? LOG_STR_LITERAL(" (overridden)") : LOG_STR_LITERAL(""),
this->firmware_version_, this->serial_number_);
if (this->auto_cleaning_interval_.has_value()) {
ESP_LOGCONFIG(TAG, " Auto cleaning interval: %" PRId32 "s", this->auto_cleaning_interval_.value());
}
+2
View File
@@ -95,6 +95,7 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S
temp_comp.time_constant = time_constant;
this->temperature_compensation_ = temp_comp;
}
void set_model(Sen5xType model) { this->model_override_ = model; }
bool start_fan_cleaning();
protected:
@@ -126,6 +127,7 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S
optional<GasTuning> voc_tuning_params_;
optional<GasTuning> nox_tuning_params_;
optional<TemperatureCompensation> temperature_compensation_;
optional<Sen5xType> model_override_;
ESPPreferenceObject pref_;
};
+14 -1
View File
@@ -12,6 +12,7 @@ from esphome.const import (
CONF_INDEX_OFFSET,
CONF_LEARNING_TIME_GAIN_HOURS,
CONF_LEARNING_TIME_OFFSET_HOURS,
CONF_MODEL,
CONF_NORMALIZED_OFFSET_SLOPE,
CONF_NOX,
CONF_OFFSET,
@@ -39,6 +40,7 @@ from esphome.const import (
UNIT_MICROGRAMS_PER_CUBIC_METER,
UNIT_PERCENT,
)
from esphome.types import ConfigType
CODEOWNERS = ["@martgras"]
DEPENDENCIES = ["i2c"]
@@ -49,6 +51,7 @@ SEN5XComponent = sen5x_ns.class_(
"SEN5XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice
)
RhtAccelerationMode = sen5x_ns.enum("RhtAccelerationMode")
Sen5xType = sen5x_ns.enum("Sen5xType", is_class=True)
CONF_ACCELERATION_MODE = "acceleration_mode"
CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval"
@@ -63,6 +66,12 @@ ACCELERATION_MODES = {
"high": RhtAccelerationMode.HIGH_ACCELERATION,
}
MODELS = {
"SEN50": Sen5xType.SEN50,
"SEN54": Sen5xType.SEN54,
"SEN55": Sen5xType.SEN55,
}
def _gas_sensor(
*,
@@ -186,6 +195,7 @@ CONFIG_SCHEMA = (
}
),
cv.Optional(CONF_ACCELERATION_MODE): cv.enum(ACCELERATION_MODES),
cv.Optional(CONF_MODEL): cv.enum(MODELS, upper=True),
}
)
.extend(cv.polling_component_schema("60s"))
@@ -210,7 +220,7 @@ SETTING_MAP = {
}
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
@@ -219,6 +229,9 @@ async def to_code(config):
if cfg := config.get(key):
cg.add(getattr(var, funcName)(cfg))
if (model := config.get(CONF_MODEL)) is not None:
cg.add(var.set_model(model))
for key, funcName in SENSOR_MAP.items():
if cfg := config.get(key):
sens = await sensor.new_sensor(cfg)
+3 -6
View File
@@ -129,12 +129,12 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType:
"""
network.require_high_performance_networking()
# Socket consumption varies by mode:
# - Server mode: 1 listening socket + 2 client connections (for handoff)
# - Server mode: 1 listening socket + 4 client connections (established connection, unproven connections, and a spare)
# - Client mode: 1 outbound connection
socket.consume_sockets(
1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN
)(config)
socket.consume_sockets(2, "sendspin_websocket_server")(config)
socket.consume_sockets(4, "sendspin_websocket_server")(config)
socket.consume_sockets(1, "sendspin_websocket_client")(config)
wifi.enable_runtime_power_save_control()
@@ -198,7 +198,7 @@ async def to_code(config: ConfigType) -> None:
psram.request_external_task_stack()
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.0")
cg.add_define("USE_SENDSPIN", True) # for MDNS
@@ -255,9 +255,6 @@ async def to_code(config: ConfigType) -> None:
if psram_stack:
psram.request_external_task_stack()
# Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not
# starved by the HTTP server during the initial encoded-audio burst at stream start),
# decode buffer location PREFER_EXTERNAL.
player_struct_fields = [
("audio_formats", audio_format_structs),
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
+6 -1
View File
@@ -179,7 +179,12 @@ std::optional<uint32_t> SendspinHub::load_last_server_hash() {
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
std::optional<bool> mute) {
if (this->is_ready()) {
this->controller_role_->send_command(command, volume, mute);
sendspin::ClientCommandControllerObject obj = {
.command = command,
.volume = volume,
.muted = mute,
};
this->controller_role_->send_command(obj);
}
}
+19 -7
View File
@@ -321,13 +321,25 @@ _SENSOR_SCHEMA = (
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent),
cv.GenerateID(): cv.declare_id(Sensor),
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement,
cv.Optional(CONF_ACCURACY_DECIMALS): validate_accuracy_decimals,
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(CONF_STATE_CLASS): validate_state_class,
cv.Optional(CONF_ENTITY_CATEGORY): sensor_entity_category,
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean,
cv.Optional(CONF_EXPIRE_AFTER): cv.All(
cv.Optional(
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
): validate_unit_of_measurement,
cv.Optional(
CONF_ACCURACY_DECIMALS, visibility=cv.Visibility.ADVANCED
): validate_accuracy_decimals,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(
CONF_STATE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_state_class,
cv.Optional(
CONF_ENTITY_CATEGORY, visibility=cv.Visibility.ADVANCED
): sensor_entity_category,
cv.Optional(
CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED
): cv.boolean,
cv.Optional(CONF_EXPIRE_AFTER, visibility=cv.Visibility.ADVANCED): cv.All(
cv.requires_component("mqtt"),
cv.Any(None, cv.positive_time_period_milliseconds),
),
+10 -10
View File
@@ -41,17 +41,17 @@ void I2CSSD1306::command(uint8_t value) { this->write_byte(0x00, value); }
void HOT I2CSSD1306::write_display_data() {
if (this->is_sh1106_() || this->is_sh1107_()) {
uint32_t i = 0;
// Some panels wire their visible columns to a window of the controller RAM
// that does not start at column 0 (e.g. SH1107 M5Stack Unit OLED needs offset_x: 32).
// SH1106 keeps its historical 0x02 base column on top of any offset.
uint8_t start_column = this->offset_x_;
if (this->is_sh1106_()) {
start_column += 0x02;
}
for (uint8_t page = 0; page < (uint8_t) this->get_height_internal() / 8; page++) {
this->command(0xB0 + page); // row
if (this->is_sh1106_()) {
this->command(0x02); // lower column - 0x02 is historical SH1106 value
} else {
// Other SH1107 drivers use 0x00
// Column values dont change and it seems they can be set only once,
// but we follow SH1106 implementation and resend them
this->command(0x00);
}
this->command(0x10); // higher column
this->command(0xB0 + page); // row
this->command(start_column & 0x0F); // lower column
this->command(0x10 | (start_column >> 4)); // higher column
for (uint8_t x = 0; x < (uint8_t) this->get_width_internal() / 16; x++) {
uint8_t data[16];
for (uint8_t &j : data)
@@ -38,14 +38,17 @@ void SPISSD1306::command(uint8_t value) {
}
void HOT SPISSD1306::write_display_data() {
if (this->is_sh1106_() || this->is_sh1107_()) {
// Some panels wire their visible columns to a window of the controller RAM
// that does not start at column 0 (e.g. SH1107 M5Stack Unit OLED needs offset_x: 32).
// SH1106 keeps its historical 0x02 base column on top of any offset.
uint8_t start_column = this->offset_x_;
if (this->is_sh1106_()) {
start_column += 0x02;
}
for (uint8_t y = 0; y < (uint8_t) this->get_height_internal() / 8; y++) {
this->command(0xB0 + y);
if (this->is_sh1106_()) {
this->command(0x02);
} else {
this->command(0x00);
}
this->command(0x10);
this->command(start_column & 0x0F); // lower column
this->command(0x10 | (start_column >> 4)); // higher column
this->dc_pin_->digital_write(true);
for (uint8_t x = 0; x < (uint8_t) this->get_width_internal(); x++) {
this->enable();
+3 -1
View File
@@ -78,7 +78,9 @@ _SWITCH_SCHEMA = (
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}),
cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
}
)
)
+3 -1
View File
@@ -144,7 +144,9 @@ _TEXT_SENSOR_SCHEMA = (
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor),
cv.GenerateID(): cv.declare_id(TextSensor),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}),
+6 -2
View File
@@ -54,7 +54,9 @@ _UPDATE_SCHEMA = (
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTUpdateComponent),
cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(CONF_ON_UPDATE_AVAILABLE): automation.validate_automation(
single=True
),
@@ -136,7 +138,9 @@ async def to_code(config):
automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(UpdateEntity),
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean),
cv.Optional(
CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED
): cv.templatable(cv.boolean),
}
),
synchronous=True,
+1 -1
View File
@@ -160,7 +160,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) {
}
uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE);
memcpy(chunk->data, data, chunk_len);
chunk->length = static_cast<uint8_t>(chunk_len);
chunk->length = chunk_len;
// Push always succeeds: pool is sized to queue capacity (SIZE-1), so if
// allocate() returned non-null, the queue cannot be full.
this->output_queue_.push(chunk);
+3 -1
View File
@@ -87,7 +87,9 @@ _VALVE_SCHEMA = (
{
cv.GenerateID(): cv.declare_id(Valve),
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTValveComponent),
cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All(
cv.requires_component("mqtt"), cv.subscribe_topic
),
+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,
+88 -14
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(
@@ -172,7 +226,9 @@ sorting_group = {
WEBSERVER_SORTING_SCHEMA = cv.Schema(
{
cv.Optional(CONF_WEB_SERVER): cv.Schema(
# The per-entity web_server block is cosmetic dashboard ordering —
# mark the whole block advanced; the children inherit via the cascade.
cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema(
{
cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer),
cv.Optional(CONF_SORTING_WEIGHT): cv.All(
@@ -199,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
@@ -236,6 +301,7 @@ CONFIG_SCHEMA = cv.All(
validate_local,
validate_sorting_groups,
validate_ota,
validate_private_network_access,
_consume_web_server_sockets,
)
@@ -332,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])
+86 -37
View File
@@ -56,9 +56,8 @@ namespace esphome::web_server {
static const char *const TAG = "web_server";
// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up)
static constexpr size_t PSTR_LOCAL_SIZE = 18;
#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1)
// View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266.
[[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast<ProgmemStr>(s); }
// Parse URL and return match info
// URL formats (disambiguated by HTTP method for 3-segment case):
@@ -457,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];
@@ -578,9 +626,9 @@ static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix
root[ESPHOME_F("value")] = value;
}
template<typename T>
static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const char *state,
const T &value, JsonDetail start_config) {
template<typename S, typename T>
static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value,
JsonDetail start_config) {
set_json_value(root, obj, prefix, value, start_config);
root[ESPHOME_F("state")] = state;
}
@@ -1073,8 +1121,7 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail
set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position,
start_config);
char buf[PSTR_LOCAL_SIZE];
root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation));
root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation));
if (obj->get_traits().get_supports_position())
root[ESPHOME_F("position")] = obj->position;
@@ -1530,17 +1577,16 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json
const auto traits = obj->get_traits();
int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals();
int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals();
char buf[PSTR_LOCAL_SIZE];
char temp_buf[VALUE_ACCURACY_MAX_LEN];
if (start_config == DETAIL_ALL) {
JsonArray opt = root[ESPHOME_F("modes")].to<JsonArray>();
for (climate::ClimateMode m : traits.get_supported_modes())
opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m)));
opt.add(json_state_str(climate::climate_mode_to_string(m)));
if (traits.get_supports_fan_modes()) {
JsonArray opt = root[ESPHOME_F("fan_modes")].to<JsonArray>();
for (climate::ClimateFanMode m : traits.get_supported_fan_modes())
opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m)));
opt.add(json_state_str(climate::climate_fan_mode_to_string(m)));
}
if (!traits.get_supported_custom_fan_modes().empty()) {
@@ -1551,12 +1597,12 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json
if (traits.get_supports_swing_modes()) {
JsonArray opt = root[ESPHOME_F("swing_modes")].to<JsonArray>();
for (auto swing_mode : traits.get_supported_swing_modes())
opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode)));
opt.add(json_state_str(climate::climate_swing_mode_to_string(swing_mode)));
}
if (traits.get_supports_presets()) {
JsonArray opt = root[ESPHOME_F("presets")].to<JsonArray>();
for (climate::ClimatePreset m : traits.get_supported_presets())
opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m)));
opt.add(json_state_str(climate::climate_preset_to_string(m)));
}
if (!traits.get_supported_custom_presets().empty()) {
JsonArray opt = root[ESPHOME_F("custom_presets")].to<JsonArray>();
@@ -1572,26 +1618,26 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json
}
bool has_state = false;
root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode));
root[ESPHOME_F("mode")] = json_state_str(climate_mode_to_string(obj->mode));
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action));
root[ESPHOME_F("action")] = json_state_str(climate_action_to_string(obj->action));
root[ESPHOME_F("state")] = root[ESPHOME_F("action")];
has_state = true;
}
if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) {
root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value()));
root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value()));
}
if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) {
root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode();
}
if (traits.get_supports_presets() && obj->preset.has_value()) {
root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value()));
root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value()));
}
if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) {
root[ESPHOME_F("custom_preset")] = obj->get_custom_preset();
}
if (traits.get_supports_swing_modes()) {
root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode));
root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode));
}
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
root[ESPHOME_F("current_temperature")] =
@@ -1695,8 +1741,7 @@ json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockSta
json::JsonBuilder builder;
JsonObject root = builder.root();
char buf[PSTR_LOCAL_SIZE];
set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config);
set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -1777,8 +1822,7 @@ json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail
set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position,
start_config);
char buf[PSTR_LOCAL_SIZE];
root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation));
root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation));
if (obj->get_traits().get_supports_position())
root[ESPHOME_F("position")] = obj->position;
@@ -1863,9 +1907,8 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p
json::JsonBuilder builder;
JsonObject root = builder.root();
char buf[PSTR_LOCAL_SIZE];
set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)),
value, start_config);
set_json_icon_state_value(root, obj, "alarm-control-panel",
json_state_str(alarm_control_panel_state_to_string(value)), value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -1937,10 +1980,9 @@ json::SerializationBuffer<> WebServer::water_heater_all_json_generator(WebServer
json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) {
json::JsonBuilder builder;
JsonObject root = builder.root();
char buf[PSTR_LOCAL_SIZE];
const auto mode = obj->get_mode();
const char *mode_s = PSTR_LOCAL(water_heater::water_heater_mode_to_string(mode));
ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode));
set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config);
@@ -1949,7 +1991,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea
if (start_config == DETAIL_ALL) {
JsonArray modes = root[ESPHOME_F("modes")].to<JsonArray>();
for (auto m : traits.get_supported_modes())
modes.add(PSTR_LOCAL(water_heater::water_heater_mode_to_string(m)));
modes.add(json_state_str(water_heater::water_heater_mode_to_string(m)));
root[ESPHOME_F("min_temp")] = traits.get_min_temperature();
root[ESPHOME_F("max_temp")] = traits.get_max_temperature();
root[ESPHOME_F("step")] = traits.get_target_temperature_step();
@@ -2277,8 +2319,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J
json::JsonBuilder builder;
JsonObject root = builder.root();
char buf[PSTR_LOCAL_SIZE];
set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)),
set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)),
obj->update_info.latest_version, start_config);
if (start_config == DETAIL_ALL) {
root[ESPHOME_F("current_version")] = obj->update_info.current_version;
@@ -2456,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);
@@ -2477,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,125 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code
}
#ifdef USE_WEBSERVER_AUTH
#ifdef USE_WEBSERVER_AUTH_DIGEST
namespace {
// 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);
format_hex_to(ha1, digest, sizeof(digest));
// 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);
format_hex_to(ha2, digest, sizeof(digest));
// 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);
format_hex_to(expected, digest, sizeof(digest));
// 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 +507,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 +567,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));
format_hex_to(nonce, random_bytes, sizeof(random_bytes));
esp_fill_random(random_bytes, sizeof(random_bytes));
format_hex_to(opaque, random_bytes, sizeof(random_bytes));
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

Some files were not shown because too many files have changed in this diff Show More