mirror of
https://github.com/esphome/esphome.git
synced 2026-09-07 05:26:01 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d34d3994e1 | ||
|
|
9a877a067c | ||
|
|
9ba4477ada | ||
|
|
8434dc5474 | ||
|
|
e0e85db822 | ||
|
|
5d2ddc658c | ||
|
|
c1aa41f276 | ||
|
|
96b1a03ea4 | ||
|
|
95ab3fb4f2 | ||
|
|
18220e0b39 | ||
|
|
011497d6ee | ||
|
|
7089dae3b6 | ||
|
|
745eb30109 | ||
|
|
e36445fa5f | ||
|
|
cb0c2bdaca | ||
|
|
e47247486b | ||
|
|
657116a213 | ||
|
|
3321566cc0 | ||
|
|
d58b37faa1 | ||
|
|
8966567be0 | ||
|
|
20c7dcb1dd | ||
|
|
688af60cbf | ||
|
|
9c00f13606 | ||
|
|
833dd0e812 | ||
|
|
8e1044e8ea | ||
|
|
e5200db6fd | ||
|
|
e3dd2f44a4 | ||
|
|
3ef7460fca | ||
|
|
ae187f81f2 | ||
|
|
84f78831f9 | ||
|
|
13dbbcaa32 | ||
|
|
b66822d9bd | ||
|
|
d1829c495d | ||
|
|
ce87bf9b17 | ||
|
|
51ea97deff |
@@ -1,39 +0,0 @@
|
|||||||
name: Cache Arduino ESP8266
|
|
||||||
description: >
|
|
||||||
Resolve the pinned Arduino core and xtensa toolchain versions and cache the
|
|
||||||
native ESP8266 install (~110 MB framework + toolchain; no ccache store, the
|
|
||||||
seed job saves before any compile runs). Exports
|
|
||||||
ESPHOME_ARDUINO8266_PREFIX to the job so every later step installs into
|
|
||||||
the cached path; the Python venv must already be restored. Mirrors
|
|
||||||
cache-esp-idf: only dev-branch pushes write the shared cache, everything
|
|
||||||
else restores.
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: Resolve the native toolchain cache key
|
|
||||||
# Versions are pinned in code, not a hashable file; resolve them so a
|
|
||||||
# bump changes the cache key. Assignment form so errexit catches a
|
|
||||||
# resolver failure.
|
|
||||||
id: version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# One owner for the install prefix: exported here and referenced by
|
|
||||||
# the cache steps below via env, so the caller's install and the
|
|
||||||
# cached path cannot diverge.
|
|
||||||
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
|
|
||||||
. venv/bin/activate
|
|
||||||
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")')
|
|
||||||
[ -n "$key" ] || exit 1
|
|
||||||
echo "key=$key" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Cache the native toolchain (write on dev)
|
|
||||||
if: github.ref == 'refs/heads/dev'
|
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
||||||
with:
|
|
||||||
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
|
|
||||||
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
|
|
||||||
- name: Restore the native toolchain (off dev)
|
|
||||||
if: github.ref != 'refs/heads/dev'
|
|
||||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
||||||
with:
|
|
||||||
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
|
|
||||||
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
|
|
||||||
@@ -102,8 +102,6 @@ jobs:
|
|||||||
device-builder: ${{ steps.determine.outputs.device-builder }}
|
device-builder: ${{ steps.determine.outputs.device-builder }}
|
||||||
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
|
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
|
||||||
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }}
|
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }}
|
||||||
esp8266-native: ${{ steps.determine.outputs.esp8266-native }}
|
|
||||||
esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }}
|
|
||||||
changed-components: ${{ steps.determine.outputs.changed-components }}
|
changed-components: ${{ steps.determine.outputs.changed-components }}
|
||||||
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
||||||
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
|
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
|
||||||
@@ -167,8 +165,6 @@ jobs:
|
|||||||
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
||||||
echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT
|
echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT
|
||||||
echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT
|
echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT
|
||||||
echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT
|
|
||||||
echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT
|
|
||||||
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
||||||
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
|
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||||
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
|
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||||
@@ -187,32 +183,6 @@ jobs:
|
|||||||
path: .temp/components_graph.json
|
path: .temp/components_graph.json
|
||||||
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
|
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
|
||||||
|
|
||||||
seed-esp8266-native-cache:
|
|
||||||
name: Seed the esp8266 native toolchain cache
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs:
|
|
||||||
- common
|
|
||||||
# PR-branch cache saves are invisible to other PRs, so dev pushes seed
|
|
||||||
# the shared entry test-esp8266-native restores. Only dev: the composite
|
|
||||||
# action saves nowhere else, so a beta/release push would download the
|
|
||||||
# toolchain and discard it.
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Check out code from GitHub
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
- name: Restore Python
|
|
||||||
uses: ./.github/actions/restore-python
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
|
||||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
|
||||||
- name: Cache the native toolchain
|
|
||||||
uses: ./.github/actions/cache-arduino8266
|
|
||||||
- name: Install the native toolchain
|
|
||||||
run: |
|
|
||||||
. venv/bin/activate
|
|
||||||
python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)"
|
|
||||||
|
|
||||||
ci-custom:
|
ci-custom:
|
||||||
name: Run script/ci-custom
|
name: Run script/ci-custom
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -1258,7 +1228,7 @@ jobs:
|
|||||||
|
|
||||||
# compile validates config first, so a separate config pass is
|
# compile validates config first, so a separate config pass is
|
||||||
# redundant for this smoke test. ESP-IDF framework via PlatformIO:
|
# redundant for this smoke test. ESP-IDF framework via PlatformIO:
|
||||||
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests
|
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
|
echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
|
||||||
@@ -1267,40 +1237,6 @@ jobs:
|
|||||||
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
|
# 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
|
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||||
|
|
||||||
test-esp8266-native:
|
|
||||||
name: Test esp8266 components with the native toolchain
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs:
|
|
||||||
- common
|
|
||||||
- determine-jobs
|
|
||||||
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
|
|
||||||
env:
|
|
||||||
# Computed by script/determine-jobs.py (ESP8266_NATIVE_TEST_COMPONENTS)
|
|
||||||
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp8266-native-components }}
|
|
||||||
steps:
|
|
||||||
- name: Check out code from GitHub
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
|
|
||||||
- name: Restore Python
|
|
||||||
uses: ./.github/actions/restore-python
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
|
||||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
|
||||||
|
|
||||||
- name: Cache the native toolchain
|
|
||||||
uses: ./.github/actions/cache-arduino8266
|
|
||||||
|
|
||||||
- name: Run native toolchain compile test
|
|
||||||
run: |
|
|
||||||
. venv/bin/activate
|
|
||||||
|
|
||||||
echo "Testing components: $TEST_COMPONENTS"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ESP8266 Arduino built directly (no PlatformIO); compile validates
|
|
||||||
# config first, so a separate config pass is redundant.
|
|
||||||
python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
|
|
||||||
|
|
||||||
device-builder:
|
device-builder:
|
||||||
name: Test downstream esphome/device-builder
|
name: Test downstream esphome/device-builder
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -1663,7 +1599,6 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- common
|
- common
|
||||||
- seed-apt-cache
|
- seed-apt-cache
|
||||||
- seed-esp8266-native-cache
|
|
||||||
- determine-jobs
|
- determine-jobs
|
||||||
- ci-custom
|
- ci-custom
|
||||||
- pylint
|
- pylint
|
||||||
@@ -1679,7 +1614,6 @@ jobs:
|
|||||||
- clang-tidy-esp32-variants
|
- clang-tidy-esp32-variants
|
||||||
- test-build-components-split
|
- test-build-components-split
|
||||||
- test-esp32-platformio
|
- test-esp32-platformio
|
||||||
- test-esp8266-native
|
|
||||||
- device-builder
|
- device-builder
|
||||||
- memory-impact-target-branch
|
- memory-impact-target-branch
|
||||||
- memory-impact-pr-branch
|
- memory-impact-pr-branch
|
||||||
|
|||||||
@@ -553,6 +553,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
|||||||
4. **Lint:** Run `prek` to ensure code is compliant.
|
4. **Lint:** Run `prek` to ensure code is compliant.
|
||||||
5. **Commit:** Commit your changes. There is no strict format for commit messages.
|
5. **Commit:** Commit your changes. There is no strict format for commit messages.
|
||||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||||
|
7. **Comments:** When commenting on GitHub PRs or issues, don't tag contributors, especially bots. Avoid referring to list items (e.g. from reviews) with the form #nn - this will be interpreted by GitHub as a reference to issue or PR nn. Keep comments short and exclude irrelevant details, backstories, restatement of previous comments and anything that is already obvious to the reader.
|
||||||
|
|
||||||
* **Documentation Contributions:**
|
* **Documentation Contributions:**
|
||||||
* Documentation is hosted in the separate `esphome/esphome.io` repository.
|
* Documentation is hosted in the separate `esphome/esphome.io` repository.
|
||||||
|
|||||||
+36
-19
@@ -125,30 +125,47 @@ design is optimal or that it will not change.
|
|||||||
## OTA update encryption
|
## OTA update encryption
|
||||||
|
|
||||||
The `esphome` OTA platform optionally encrypts updates with the same Noise
|
The `esphome` OTA platform optionally encrypts updates with the same Noise
|
||||||
`NNpsk0` pattern the native API uses; one key protects the device. With an
|
`NNpsk0` pattern the native API uses; one key protects the device. A device
|
||||||
`encryption:` block configured the guarantees are: the firmware image is
|
whose `api:` block has an encryption key, static in the YAML or provisioned at
|
||||||
confidential in transit, the uploader is authenticated by the pre-shared key,
|
runtime, compiles in the transport and offers it on every OTA connection once
|
||||||
and the plaintext negotiation preceding the handshake is bound into the
|
it holds a key, so an uploader presenting that key gets the guarantees below
|
||||||
handshake prologue, so stripping or tampering with it fails the first MAC.
|
even without an `ota: encryption:` block; only that block makes the device
|
||||||
Both ends fail closed with no override: a device built with a key refuses
|
require encryption. The guarantees are: the firmware image is confidential in
|
||||||
|
transit, the uploader is authenticated by the pre-shared key, and the plaintext
|
||||||
|
negotiation preceding the handshake is bound into the handshake prologue, so
|
||||||
|
stripping or tampering with it fails the first MAC. With `ota: encryption:`
|
||||||
|
configured both ends fail closed with no override: the device refuses
|
||||||
plaintext uploads, and the CLI refuses to send plaintext when a key is
|
plaintext uploads, and the CLI refuses to send plaintext when a key is
|
||||||
configured.
|
configured. Without that block the CLI tries a static api key when the device
|
||||||
|
offers and, until 2027.3.0, falls back to plaintext with a warning when the
|
||||||
|
offer is missing or the handshake fails; a runtime provisioned key never
|
||||||
|
reaches the CLI, so those uploads stay plaintext.
|
||||||
|
|
||||||
Defeating any of that without the key is in scope: a keyed device accepting a
|
Defeating any of that without the key is in scope: a device that requires
|
||||||
plaintext or downgraded upload, getting past the MAC, or recovering image
|
encryption accepting a plaintext or downgraded upload, getting past the MAC,
|
||||||
contents from captured traffic.
|
or recovering image contents from captured traffic.
|
||||||
|
|
||||||
The following are **not** vulnerabilities, by design:
|
The following are **not** vulnerabilities, by design:
|
||||||
|
|
||||||
- Plaintext OTA on a device with no `encryption:` block. That is the
|
- Plaintext OTA on a device with no `ota: encryption:` block, including one
|
||||||
documented default, authenticated (if at all) by the OTA password.
|
that offers encryption because it has an api key. That is the documented
|
||||||
- The enablement window: turning encryption on takes one last upload of the
|
default, authenticated (if at all) by the OTA password. An uploader that
|
||||||
encryption-enabled firmware over the existing plaintext channel, with the
|
takes the offer skips the password; the key authenticates it. With a
|
||||||
pre-existing plaintext exposure.
|
runtime provisioned key and no `provisioning:` window, whoever provisions
|
||||||
- The web OTA `/update` endpoint alongside encryption. The `web_server`
|
the key gains that upload path too; validation warns about the pair.
|
||||||
component keeps it always reachable, and `captive_portal:` auto-loads it
|
- The CLI plaintext fallback until 2027.3.0: without `ota: encryption:` an
|
||||||
for the fallback AP window; validation warns about both combinations, and
|
active attacker who strips the offer or breaks the handshake can make a
|
||||||
the operator keeps the recovery path.
|
keyed CLI upload plaintext, with the pre-existing plaintext exposure. A
|
||||||
|
device that requires encryption still refuses that upload.
|
||||||
|
- The enablement window: firmware built with a static api key already offers
|
||||||
|
encryption, so turning on `ota: encryption:` is itself an encrypted upload.
|
||||||
|
Older firmware needs one last plaintext upload of an offering build, with
|
||||||
|
the pre-existing plaintext exposure.
|
||||||
|
- The web OTA `/update` endpoint alongside encryption. With the `web_server`
|
||||||
|
or `prometheus` component the shared listener is always up, so the endpoint
|
||||||
|
stays reachable and validation warns about that combination;
|
||||||
|
`captive_portal:` alone brings the listener up only for the fallback AP
|
||||||
|
window, which is the intended recovery path, so that is not warned about.
|
||||||
- CLI retry behavior on transport or MAC failures; every attempt renegotiates
|
- CLI retry behavior on transport or MAC failures; every attempt renegotiates
|
||||||
a fresh handshake with fresh ephemerals, so retrying does not weaken
|
a fresh handshake with fresh ephemerals, so retrying does not weaken
|
||||||
authentication.
|
authentication.
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
|||||||
-r /requirements.txt
|
-r /requirements.txt
|
||||||
|
|
||||||
# Install the ESPHome Device Builder dashboard.
|
# Install the ESPHome Device Builder dashboard.
|
||||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.0
|
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.4
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
platformio settings set enable_telemetry No \
|
platformio settings set enable_telemetry No \
|
||||||
|
|||||||
+44
-84
@@ -817,9 +817,7 @@ def write_cpp_file() -> int:
|
|||||||
from esphome.build_gen import espidf
|
from esphome.build_gen import espidf
|
||||||
|
|
||||||
espidf.write_project()
|
espidf.write_project()
|
||||||
elif not CORE.using_native_toolchain:
|
else:
|
||||||
# Other native builds generate their project at compile time;
|
|
||||||
# never write a platformio.ini for them
|
|
||||||
from esphome.build_gen import platformio
|
from esphome.build_gen import platformio
|
||||||
|
|
||||||
platformio.write_project()
|
platformio.write_project()
|
||||||
@@ -861,14 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
|||||||
toolchain.create_factory_bin()
|
toolchain.create_factory_bin()
|
||||||
toolchain.create_ota_bin()
|
toolchain.create_ota_bin()
|
||||||
toolchain.create_elf_copy()
|
toolchain.create_elf_copy()
|
||||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
|
||||||
|
|
||||||
warn_if_idedata_missing(toolchain.get_idedata)
|
try:
|
||||||
elif CORE.using_native_toolchain:
|
if toolchain.get_idedata() is None:
|
||||||
raise EsphomeError(
|
_LOGGER.warning("No idedata was generated for this build")
|
||||||
f"Toolchain '{CORE.toolchain.value}' resolved but no platform "
|
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||||
"backend claimed the build"
|
# The firmware already built; an idedata failure must not fail
|
||||||
)
|
# a successful build.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||||
|
"memory-analysis data will be unavailable for this build)",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||||
else:
|
else:
|
||||||
from esphome.platformio import toolchain
|
from esphome.platformio import toolchain
|
||||||
|
|
||||||
@@ -971,15 +975,12 @@ def upload_using_esptool(
|
|||||||
|
|
||||||
if file is not None:
|
if file is not None:
|
||||||
flash_images = [FlashImage(path=file, offset="0x0")]
|
flash_images = [FlashImage(path=file, offset="0x0")]
|
||||||
elif (native := _native_toolchain_module()) is not None:
|
elif CORE.using_toolchain_esp_idf:
|
||||||
# Every native backend supplies its own 0x0 flash image (bootloader
|
from esphome.espidf import toolchain
|
||||||
# and partitions included where the target needs them)
|
|
||||||
image = native.get_factory_firmware_path()
|
flash_images = [
|
||||||
if not image.is_file():
|
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
|
||||||
raise EsphomeError(
|
]
|
||||||
f"{image} does not exist; compile the configuration first"
|
|
||||||
)
|
|
||||||
flash_images = [FlashImage(path=image, offset="0x0")]
|
|
||||||
else:
|
else:
|
||||||
from esphome.platformio import toolchain
|
from esphome.platformio import toolchain
|
||||||
|
|
||||||
@@ -1334,12 +1335,14 @@ def _upload_via_native_api(
|
|||||||
break
|
break
|
||||||
|
|
||||||
from esphome import espota2
|
from esphome import espota2
|
||||||
|
from esphome.components.noise import static_encryption_key
|
||||||
|
|
||||||
remote_port = int(ota_conf[CONF_PORT])
|
remote_port = int(ota_conf[CONF_PORT])
|
||||||
password = ota_conf.get(CONF_PASSWORD)
|
password = ota_conf.get(CONF_PASSWORD)
|
||||||
# Fail closed: an encryption block whose key did not resolve must never
|
# Fail closed: an encryption block whose key did not resolve must never
|
||||||
# fall back to a plaintext upload
|
# fall back to a plaintext upload
|
||||||
noise_psk = None
|
noise_psk = None
|
||||||
|
plaintext_fallback = False
|
||||||
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||||
noise_psk = encryption_conf.get(CONF_KEY)
|
noise_psk = encryption_conf.get(CONF_KEY)
|
||||||
if not noise_psk:
|
if not noise_psk:
|
||||||
@@ -1350,6 +1353,10 @@ def _upload_via_native_api(
|
|||||||
# Ensure the key is a string, as required by the underlying OTA implementation.
|
# Ensure the key is a string, as required by the underlying OTA implementation.
|
||||||
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
|
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
|
||||||
noise_psk = str(noise_psk)
|
noise_psk = str(noise_psk)
|
||||||
|
elif api_key := static_encryption_key(config.get(CONF_API) or {}):
|
||||||
|
# Remove before 2027.3.0: the api key is tried, falling back to plaintext
|
||||||
|
noise_psk = str(api_key)
|
||||||
|
plaintext_fallback = True
|
||||||
|
|
||||||
def check_partition_access(option_string: str) -> None:
|
def check_partition_access(option_string: str) -> None:
|
||||||
if not ota_conf.get("allow_partition_access"):
|
if not ota_conf.get("allow_partition_access"):
|
||||||
@@ -1381,7 +1388,13 @@ def _upload_via_native_api(
|
|||||||
_validate_bootloader_binary(binary)
|
_validate_bootloader_binary(binary)
|
||||||
|
|
||||||
return espota2.run_ota(
|
return espota2.run_ota(
|
||||||
network_devices, remote_port, password, binary, ota_type, noise_psk
|
network_devices,
|
||||||
|
remote_port,
|
||||||
|
password,
|
||||||
|
binary,
|
||||||
|
ota_type,
|
||||||
|
noise_psk,
|
||||||
|
plaintext_fallback=plaintext_fallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1949,39 +1962,15 @@ def command_update_all(args: ArgsProtocol) -> int | None:
|
|||||||
return run_multiple_configs(files, build_command)
|
return run_multiple_configs(files, build_command)
|
||||||
|
|
||||||
|
|
||||||
# Native build backend per (target platform, toolchain). Keyed here rather
|
|
||||||
# than through a platform hook so the serial upload/logs fast path never
|
|
||||||
# imports the platform component package (see the esp32 variant comment in
|
|
||||||
# upload_using_esptool); the platform half comes from CORE.data the same way.
|
|
||||||
_NATIVE_TOOLCHAIN_MODULES = {
|
|
||||||
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
|
|
||||||
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _native_toolchain_module():
|
|
||||||
"""The native build backend module for the resolved toolchain."""
|
|
||||||
if not CORE.using_native_toolchain:
|
|
||||||
return None
|
|
||||||
key = (CORE.target_platform, CORE.toolchain)
|
|
||||||
if (module_path := _NATIVE_TOOLCHAIN_MODULES.get(key)) is None:
|
|
||||||
# Degrading to the PlatformIO path would build with the wrong backend
|
|
||||||
raise EsphomeError(
|
|
||||||
f"Toolchain '{CORE.toolchain.value}' has no native build backend "
|
|
||||||
f"module for platform {CORE.target_platform}"
|
|
||||||
)
|
|
||||||
return importlib.import_module(module_path)
|
|
||||||
|
|
||||||
|
|
||||||
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
||||||
import json
|
import json
|
||||||
|
|
||||||
native_toolchain = _native_toolchain_module()
|
if CORE.using_toolchain_esp_idf:
|
||||||
|
# Native ESP-IDF derives idedata from the build's compile_commands.json,
|
||||||
|
# so the configuration must already be compiled.
|
||||||
|
from esphome.espidf import toolchain as espidf_toolchain
|
||||||
|
|
||||||
if native_toolchain is not None:
|
idedata = espidf_toolchain.get_idedata()
|
||||||
# Native toolchains derive idedata from the build's
|
|
||||||
# compile_commands.json, so the configuration must already be compiled.
|
|
||||||
idedata = native_toolchain.get_idedata()
|
|
||||||
if idedata is None:
|
if idedata is None:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"No idedata available; compile the configuration first",
|
"No idedata available; compile the configuration first",
|
||||||
@@ -2020,17 +2009,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
|||||||
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
|
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
|
||||||
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
|
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
|
||||||
|
|
||||||
# Refuse an unsupported toolchain before paying for a full compile
|
|
||||||
native_toolchain = _native_toolchain_module()
|
|
||||||
if native_toolchain is None and not CORE.using_toolchain_platformio:
|
|
||||||
_LOGGER.error(
|
|
||||||
"analyze-memory is not supported with the '%s' toolchain on %s; "
|
|
||||||
"re-run with --toolchain platformio",
|
|
||||||
CORE.toolchain.value if CORE.toolchain else "unresolved",
|
|
||||||
CORE.target_platform,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||||
exit_code = write_cpp(config)
|
exit_code = write_cpp(config)
|
||||||
if exit_code != 0:
|
if exit_code != 0:
|
||||||
@@ -2042,31 +2020,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
|||||||
|
|
||||||
# Get idedata for analysis
|
# Get idedata for analysis
|
||||||
idedata = None
|
idedata = None
|
||||||
if native_toolchain is not None:
|
if CORE.using_toolchain_esp_idf:
|
||||||
objdump = native_toolchain.get_objdump_path()
|
from esphome.espidf import toolchain
|
||||||
readelf = native_toolchain.get_readelf_path()
|
|
||||||
for tool in (objdump, readelf):
|
|
||||||
if not tool.is_file():
|
|
||||||
# The analyzer would silently fall back to host binutils,
|
|
||||||
# which cannot read the target ELF. clean-all is heavy for
|
|
||||||
# ESP-IDF, so suggest a recompile first.
|
|
||||||
_LOGGER.error(
|
|
||||||
"%s is missing; the toolchain install may be incomplete "
|
|
||||||
"(recompile, or run 'esphome clean-all' if it persists)",
|
|
||||||
tool,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
objdump_path = str(objdump)
|
|
||||||
readelf_path = str(readelf)
|
|
||||||
|
|
||||||
firmware_elf = native_toolchain.get_elf_path()
|
objdump_path = str(toolchain.get_objdump_path())
|
||||||
if not firmware_elf.is_file():
|
readelf_path = str(toolchain.get_readelf_path())
|
||||||
# The analyzer swallows tool failures, so a missing ELF would
|
|
||||||
# produce an exit-0 zeroed report
|
firmware_elf = toolchain.get_elf_path()
|
||||||
_LOGGER.error(
|
|
||||||
"%s is missing; compile the configuration first", firmware_elf
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
else:
|
else:
|
||||||
from esphome.platformio import toolchain
|
from esphome.platformio import toolchain
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from typing import NamedTuple
|
|||||||
|
|
||||||
from esphome.build_helpers.ccache import ccache_defaults_env
|
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||||
from esphome.build_helpers.ninja import find_ninja
|
from esphome.build_helpers.ninja import find_ninja
|
||||||
from esphome.build_helpers.pch import ccache_pch_env
|
|
||||||
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||||
from esphome.core import EsphomeError, Version
|
from esphome.core import EsphomeError, Version
|
||||||
from esphome.framework_helpers import str_to_lst_of_str
|
from esphome.framework_helpers import str_to_lst_of_str
|
||||||
@@ -157,6 +156,4 @@ def ccache_env(ccache: str | None) -> dict[str, str]:
|
|||||||
"""
|
"""
|
||||||
if ccache is None:
|
if ccache is None:
|
||||||
return {}
|
return {}
|
||||||
env = ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||||
env.update(ccache_pch_env())
|
|
||||||
return env
|
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
"""Native Arduino ESP8266 build driver (the PlatformIO ``run`` equivalent)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from esphome.arduino8266 import framework
|
|
||||||
from esphome.build_helpers.ccache import resolve_ccache_path
|
|
||||||
from esphome.const import (
|
|
||||||
CONF_COMPILE_PROCESS_LIMIT,
|
|
||||||
CONF_ESPHOME,
|
|
||||||
KEY_CORE,
|
|
||||||
KEY_FRAMEWORK_VERSION,
|
|
||||||
)
|
|
||||||
from esphome.core import CORE, EsphomeError
|
|
||||||
from esphome.helpers import write_file_if_changed
|
|
||||||
from esphome.types import ConfigType
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest)
|
|
||||||
_MAX_RAM_SIZE = 81920
|
|
||||||
|
|
||||||
|
|
||||||
def _warn_ignored_platformio_options() -> None:
|
|
||||||
"""Warn for component-added platformio options the native build drops.
|
|
||||||
|
|
||||||
The consumed set is exported by core/config.py next to the routing that
|
|
||||||
stores these options, so the two cannot drift; YAML upload_speed never
|
|
||||||
reaches CORE.platformio_options here.
|
|
||||||
"""
|
|
||||||
from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
|
||||||
|
|
||||||
consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
|
||||||
for key in sorted(CORE.platformio_options or {}):
|
|
||||||
if key not in consumed:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"platformio_options->%s is ignored when building with the "
|
|
||||||
"native 'arduino' toolchain",
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_RAM_SECTIONS = (".data", ".rodata", ".bss")
|
|
||||||
_FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata")
|
|
||||||
|
|
||||||
|
|
||||||
def get_build_dir() -> Path:
|
|
||||||
return CORE.relative_pioenvs_path(CORE.name)
|
|
||||||
|
|
||||||
|
|
||||||
def get_elf_path() -> Path:
|
|
||||||
return get_build_dir() / "firmware.elf"
|
|
||||||
|
|
||||||
|
|
||||||
def _toolchain_tool(name: str) -> Path:
|
|
||||||
return framework.toolchain_tool(framework.get_toolchain_path(), name)
|
|
||||||
|
|
||||||
|
|
||||||
def get_factory_firmware_path() -> Path:
|
|
||||||
"""The image to serial-flash at 0x0 (same bytes as firmware.bin: the
|
|
||||||
8266 factory copy exists for artifact-contract parity, not content)."""
|
|
||||||
return get_build_dir() / "firmware.factory.bin"
|
|
||||||
|
|
||||||
|
|
||||||
def get_addr2line_path() -> Path:
|
|
||||||
return _toolchain_tool("addr2line")
|
|
||||||
|
|
||||||
|
|
||||||
def get_objdump_path() -> Path:
|
|
||||||
return _toolchain_tool("objdump")
|
|
||||||
|
|
||||||
|
|
||||||
def get_readelf_path() -> Path:
|
|
||||||
return _toolchain_tool("readelf")
|
|
||||||
|
|
||||||
|
|
||||||
def run_compile(config: ConfigType, verbose: bool) -> int:
|
|
||||||
from esphome.build_gen import arduino8266 as build_gen
|
|
||||||
|
|
||||||
_warn_ignored_platformio_options()
|
|
||||||
paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
|
|
||||||
# Resolved once per build: the resolution probes PATH and spawns the
|
|
||||||
# runnability check, and three consumers need the same answer
|
|
||||||
ccache = resolve_ccache_path()
|
|
||||||
ninja_changed = build_gen.write_project(paths, ccache)
|
|
||||||
|
|
||||||
build_dir = get_build_dir()
|
|
||||||
env = framework.get_build_env(paths.toolchain, ccache)
|
|
||||||
|
|
||||||
# Regenerate the compile DB before the build (a pure function of
|
|
||||||
# build.ninja); skip only when it is at least as fresh as build.ninja
|
|
||||||
# (an interrupted previous run may have rewritten the manifest without
|
|
||||||
# regenerating the DB).
|
|
||||||
compdb = build_dir / "compile_commands.json"
|
|
||||||
compdb_stamp = build_dir / ".compile_commands.stamp"
|
|
||||||
ninja_file = build_dir / "build.ninja"
|
|
||||||
# Freshness rides a stamp: the DB itself is written through
|
|
||||||
# write_file_if_changed (its mtime feeds get_idedata's cache), so a
|
|
||||||
# regeneration with identical content would stay "stale" forever
|
|
||||||
if (
|
|
||||||
ninja_changed
|
|
||||||
or not compdb.is_file()
|
|
||||||
or not compdb_stamp.is_file()
|
|
||||||
or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime
|
|
||||||
):
|
|
||||||
_write_compile_commands(paths.ninja, build_dir, env)
|
|
||||||
compdb_stamp.touch()
|
|
||||||
|
|
||||||
cmd = [str(paths.ninja)]
|
|
||||||
if verbose:
|
|
||||||
cmd.append("-v")
|
|
||||||
if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT):
|
|
||||||
cmd += ["-j", str(jobs)]
|
|
||||||
# Explicit targets, not the default statement: a generator defect that
|
|
||||||
# drops them fails loudly with "unknown target" instead of a green
|
|
||||||
# no-op run that leaves stale artifacts in place
|
|
||||||
targets = ["firmware.factory.bin", "firmware.ota.bin"]
|
|
||||||
cmd += targets
|
|
||||||
|
|
||||||
# A dry-run probe keeps a no-op rebuild quiet: ninja would only print
|
|
||||||
# "no work to do". A freshly rewritten manifest all but guarantees work,
|
|
||||||
# so skip the probe (and its full stat pass) on that path. cwd instead
|
|
||||||
# of -C also drops the "Entering directory" banner on real builds.
|
|
||||||
skip_build = False
|
|
||||||
if not ninja_changed:
|
|
||||||
probe = subprocess.run(
|
|
||||||
[str(paths.ninja), "-n", *targets],
|
|
||||||
cwd=build_dir,
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
close_fds=False,
|
|
||||||
)
|
|
||||||
if probe.stderr.strip():
|
|
||||||
# A load-time diagnostic (e.g. "multiple rules generate X")
|
|
||||||
# flags a generator bug; the skip branch would otherwise
|
|
||||||
# swallow it forever
|
|
||||||
_LOGGER.warning("ninja: %s", probe.stderr.strip())
|
|
||||||
if probe.returncode != 0:
|
|
||||||
# An unknown target here is the defective-manifest case; fall
|
|
||||||
# through to the real build so the error prints attributably
|
|
||||||
_LOGGER.debug("ninja probe failed; running the full build")
|
|
||||||
skip_build = probe.returncode == 0 and "no work to do" in probe.stdout
|
|
||||||
if skip_build:
|
|
||||||
_LOGGER.debug("ninja: nothing to rebuild")
|
|
||||||
else:
|
|
||||||
_LOGGER.debug("Running: %s", " ".join(cmd))
|
|
||||||
rc = subprocess.run(
|
|
||||||
cmd, cwd=build_dir, env=env, check=False, close_fds=False
|
|
||||||
).returncode
|
|
||||||
if rc != 0:
|
|
||||||
return rc
|
|
||||||
|
|
||||||
# ninja already refused a manifest missing the explicit targets above;
|
|
||||||
# existence covers the remaining hole (a rule that ran but wrote
|
|
||||||
# elsewhere). The factory/ota copies are what upload and OTA consume.
|
|
||||||
build_dir_artifacts = (
|
|
||||||
get_elf_path(),
|
|
||||||
build_dir / "firmware.bin",
|
|
||||||
get_factory_firmware_path(),
|
|
||||||
build_dir / "firmware.ota.bin",
|
|
||||||
)
|
|
||||||
for artifact in build_dir_artifacts:
|
|
||||||
if not artifact.is_file():
|
|
||||||
_LOGGER.error("Build produced no %s", artifact)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not _print_size_summary(build_dir, paths):
|
|
||||||
# The cause was already warned; name the consequence so a build
|
|
||||||
# contributing no RAM/Flash metric is visible to CI harnesses
|
|
||||||
_LOGGER.warning("Firmware size summary unavailable for this build")
|
|
||||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
|
||||||
|
|
||||||
warn_if_idedata_missing(lambda: get_idedata(ccache))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _write_compile_commands(
|
|
||||||
ninja_path: Path, build_dir: Path, env: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
compdb = build_dir / "compile_commands.json"
|
|
||||||
result = subprocess.run(
|
|
||||||
[str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"],
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
close_fds=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
# Drop any stale database so consumers (IDE integration, clang-tidy,
|
|
||||||
# the memory analyzer) can't silently read outdated data.
|
|
||||||
compdb.unlink(missing_ok=True)
|
|
||||||
raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}")
|
|
||||||
try:
|
|
||||||
entries = json.loads(result.stdout)
|
|
||||||
except ValueError as err:
|
|
||||||
compdb.unlink(missing_ok=True)
|
|
||||||
raise EsphomeError(
|
|
||||||
f"ninja produced an unparsable compile database: {err} "
|
|
||||||
f"(output starts {result.stdout[:120]!r})"
|
|
||||||
) from err
|
|
||||||
if not entries:
|
|
||||||
# compdb exits 0 with [] for unknown rule names; a renamed compile
|
|
||||||
# rule must fail the build, not silently strand every consumer
|
|
||||||
compdb.unlink(missing_ok=True)
|
|
||||||
raise EsphomeError(
|
|
||||||
"ninja produced an empty compile database; the generator's rule "
|
|
||||||
"names no longer match"
|
|
||||||
)
|
|
||||||
# write_file_if_changed keeps the mtime stable on no-op builds so the
|
|
||||||
# idedata cache in get_idedata() stays valid.
|
|
||||||
write_file_if_changed(compdb, result.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | None:
|
|
||||||
"""Read the app flash budget (irom0_0_seg length) from the linker script."""
|
|
||||||
from esphome.build_gen.arduino8266 import get_flash_ld_path
|
|
||||||
from esphome.components.esp8266.build_surgery import segment_length
|
|
||||||
|
|
||||||
# Warnings, not debug: without the app size the Flash summary line is
|
|
||||||
# dropped and CI's memory-impact extraction loses its flash metric.
|
|
||||||
ld_path = get_flash_ld_path(build_dir, paths)
|
|
||||||
try:
|
|
||||||
ld_text = ld_path.read_text(encoding="utf-8")
|
|
||||||
except (OSError, UnicodeDecodeError) as err:
|
|
||||||
# UnicodeDecodeError: a truncated/corrupt script must degrade to
|
|
||||||
# the same warning, never abort an already-linked build
|
|
||||||
_LOGGER.warning("Cannot read linker script for the Flash summary: %s", err)
|
|
||||||
return None
|
|
||||||
app_size = segment_length(ld_text, "irom0_0_seg")
|
|
||||||
if app_size is None:
|
|
||||||
_LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path)
|
|
||||||
return None
|
|
||||||
if app_size == 0:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"irom0_0_seg has zero length in %s; skipping Flash summary", ld_path
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
return app_size
|
|
||||||
|
|
||||||
|
|
||||||
def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool:
|
|
||||||
"""Print the PlatformIO-shaped RAM/Flash lines; False when skipped.
|
|
||||||
|
|
||||||
The exact shape (including the bar) is parsed by
|
|
||||||
``script/ci_memory_impact_extract.py``; ``print_size_line`` matches it.
|
|
||||||
"""
|
|
||||||
from esphome.build_helpers.size_summary import print_size_line
|
|
||||||
|
|
||||||
size_tool = _toolchain_tool("size")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[str(size_tool), "-A", "-d", str(get_elf_path())],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
close_fds=False,
|
|
||||||
)
|
|
||||||
except OSError as err:
|
|
||||||
# The summary is a bonus artifact like idedata; a truncated
|
|
||||||
# toolchain extraction must not discard an already-linked build
|
|
||||||
_LOGGER.warning("Could not summarize firmware size: %s", err)
|
|
||||||
return False
|
|
||||||
if result.returncode != 0:
|
|
||||||
_LOGGER.warning("Could not summarize firmware size: %s", result.stderr)
|
|
||||||
return False
|
|
||||||
sections: dict[str, int] = {}
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) >= 2 and parts[0].startswith("."):
|
|
||||||
try:
|
|
||||||
sections[parts[0]] = int(parts[1])
|
|
||||||
except ValueError:
|
|
||||||
# An unparsed RAM/Flash section trips the missing-sections
|
|
||||||
# guard below, so no total is built on a dropped value
|
|
||||||
_LOGGER.warning("Unparsable size output for section %s", parts[0])
|
|
||||||
if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections):
|
|
||||||
# A defaulted 0 would print a confidently wrong total for CI's metric
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Size output is missing section(s) %s; skipping the size summary",
|
|
||||||
", ".join(sorted(missing)),
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
# Resolve the flash budget before printing anything: a RAM line without
|
|
||||||
# its Flash line would let CI's memory-impact extraction sum the two
|
|
||||||
# metrics over different build counts (_parse_app_size already warned).
|
|
||||||
app_size = _parse_app_size(build_dir, paths)
|
|
||||||
if not app_size:
|
|
||||||
return False
|
|
||||||
ram = sum(sections[s] for s in _RAM_SECTIONS)
|
|
||||||
flash = sum(sections[s] for s in _FLASH_SECTIONS)
|
|
||||||
print_size_line("RAM", ram, _MAX_RAM_SIZE)
|
|
||||||
print_size_line("Flash", flash, app_size)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# Sentinel: "resolve for me"; None is a real value meaning disabled.
|
|
||||||
_CCACHE_UNRESOLVED: Any = object()
|
|
||||||
|
|
||||||
|
|
||||||
def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None:
|
|
||||||
"""Derive idedata from the build's compile_commands.json.
|
|
||||||
|
|
||||||
Same contract as ``espidf.toolchain.get_idedata``: the fields IDE
|
|
||||||
integrations, clang-tidy, and the memory analyzer expect.
|
|
||||||
"""
|
|
||||||
from esphome.build_helpers.idedata import load_or_build_idedata
|
|
||||||
|
|
||||||
if ccache is _CCACHE_UNRESOLVED:
|
|
||||||
# Deliberately uncached: env/PATH can change between builds in a
|
|
||||||
# long-lived host process
|
|
||||||
ccache = resolve_ccache_path()
|
|
||||||
return load_or_build_idedata(
|
|
||||||
get_build_dir() / "compile_commands.json",
|
|
||||||
get_elf_path(),
|
|
||||||
# Suffixed so a platformio->arduino->platformio round trip on one
|
|
||||||
# config never serves the other toolchain's cache shape
|
|
||||||
CORE.relative_internal_path("idedata", f"{CORE.name}.arduino.json"),
|
|
||||||
# The compile DB's commands carry the same ccache prefix the ninja
|
|
||||||
# rules were generated with
|
|
||||||
launcher=str(ccache) if ccache else None,
|
|
||||||
)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from esphome.build_helpers import pch
|
|
||||||
from esphome.build_helpers.pch import (
|
|
||||||
PCH_DEFAULT_HEADERS,
|
|
||||||
PCH_HEADER_NAME,
|
|
||||||
mark_pch_emitted,
|
|
||||||
pch_enabled,
|
|
||||||
pch_header_text,
|
|
||||||
)
|
|
||||||
from esphome.components.esp32 import (
|
from esphome.components.esp32 import (
|
||||||
get_esp32_variant,
|
get_esp32_variant,
|
||||||
get_excluded_builtin_components,
|
get_excluded_builtin_components,
|
||||||
@@ -287,67 +279,9 @@ idf_component_register(
|
|||||||
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||||
{link_opts_str}
|
{link_opts_str}
|
||||||
)
|
)
|
||||||
{_pch_cmake()}"""
|
|
||||||
|
|
||||||
|
|
||||||
def _pch_cmake() -> str:
|
|
||||||
"""The src component's precompiled-header block (C++ TUs only).
|
|
||||||
|
|
||||||
The -include stays relative (resolved from the compiler cwd, the build
|
|
||||||
dir); an absolute path would poison ccache keys.
|
|
||||||
"""
|
|
||||||
if not pch_enabled():
|
|
||||||
return ""
|
|
||||||
return f"""
|
|
||||||
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
|
|
||||||
# OBJECT_DEPENDS is on the header, not the .gch: pch-baked headers drop
|
|
||||||
# out of TU depfiles, and prepare_pch() touches the header on rebuild.
|
|
||||||
target_compile_options(${{COMPONENT_LIB}} PRIVATE
|
|
||||||
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
|
|
||||||
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
|
|
||||||
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
|
|
||||||
)
|
|
||||||
set_source_files_properties(${{app_sources}} PROPERTIES
|
|
||||||
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def discard_pch() -> None:
|
|
||||||
"""Drop the pch sidecars in the IDF build dir."""
|
|
||||||
pch.discard_pch(CORE.relative_build_path("build"))
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_pch() -> None:
|
|
||||||
"""Build the .gch right before ninja, after every reconfigure, so the
|
|
||||||
compile_commands.json flags and the sdkconfig are the settled ones."""
|
|
||||||
if not pch_enabled():
|
|
||||||
# Self-cleaning escape hatch: drop any previously built .gch
|
|
||||||
pch.discard_pch(CORE.relative_build_path("build"))
|
|
||||||
return
|
|
||||||
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
|
|
||||||
try:
|
|
||||||
sdkconfig = sdkconfig_path.read_text(encoding="utf-8")
|
|
||||||
except OSError as err:
|
|
||||||
# Fail closed: the sdkconfig is the .sum's only config identity for
|
|
||||||
# sdkconfig.h-only options; a stand-in marker would collide
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Could not read %s; compiling without the pch: %s", sdkconfig_path, err
|
|
||||||
)
|
|
||||||
pch.discard_pch(CORE.relative_build_path("build"))
|
|
||||||
return
|
|
||||||
pch.prepare_pch(
|
|
||||||
CORE.relative_build_path("build"),
|
|
||||||
PCH_DEFAULT_HEADERS,
|
|
||||||
(
|
|
||||||
str(idf_version()),
|
|
||||||
CORE.cpp_standard or "",
|
|
||||||
sdkconfig,
|
|
||||||
*get_project_compile_flags(),
|
|
||||||
*get_project_cxx_compile_flags(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def write_project(
|
def write_project(
|
||||||
minimal: bool = False, builtin_components: list[str] | None = None
|
minimal: bool = False, builtin_components: list[str] | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -367,14 +301,6 @@ def write_project(
|
|||||||
get_component_cmakelists(),
|
get_component_cmakelists(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if pch_enabled():
|
|
||||||
write_file_if_changed(
|
|
||||||
CORE.relative_build_path("build", PCH_HEADER_NAME),
|
|
||||||
pch_header_text(PCH_DEFAULT_HEADERS),
|
|
||||||
)
|
|
||||||
# Consumers carry the -include; gate the ccache relaxation on it
|
|
||||||
mark_pch_emitted()
|
|
||||||
|
|
||||||
# Snapshot the exclusion set so has_outdated_files() can trigger a
|
# Snapshot the exclusion set so has_outdated_files() can trigger a
|
||||||
# discovery reconfigure when it changes. Excluded components never
|
# discovery reconfigure when it changes. Excluded components never
|
||||||
# register in project_description.json, so re-including one (e.g. a
|
# register in project_description.json, so re-including one (e.g. a
|
||||||
|
|||||||
@@ -87,19 +87,6 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
|||||||
"CCACHE_DIR": str(cache_dir),
|
"CCACHE_DIR": str(cache_dir),
|
||||||
"CCACHE_NOHASHDIR": "true",
|
"CCACHE_NOHASHDIR": "true",
|
||||||
"CCACHE_DEPEND": "1",
|
"CCACHE_DEPEND": "1",
|
||||||
# A user value wins via the filter below
|
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||||
"CCACHE_BASEDIR": effective_ccache_basedir(),
|
|
||||||
}
|
}
|
||||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||||
|
|
||||||
|
|
||||||
def effective_ccache_basedir() -> str:
|
|
||||||
"""The prefix ccache rewrites out of hashed paths: a user CCACHE_BASEDIR
|
|
||||||
wins, else the resolved build path (matching ccache_defaults_env)."""
|
|
||||||
from esphome.core import CORE
|
|
||||||
|
|
||||||
raw = os.environ.get("CCACHE_BASEDIR")
|
|
||||||
if raw is not None and Path(raw).is_absolute() and len(Path(raw).parts) > 1:
|
|
||||||
return raw
|
|
||||||
# Unset or degenerate ("", "/", relative): fall back to the build path
|
|
||||||
return str(Path(CORE.build_path).resolve())
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ consumers (IDE integration, clang-tidy) expect:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -22,8 +21,6 @@ import subprocess
|
|||||||
from esphome.core import EsphomeError
|
from esphome.core import EsphomeError
|
||||||
from esphome.helpers import write_file
|
from esphome.helpers import write_file
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Everything idedata generation may raise after a successful link; idedata
|
# Everything idedata generation may raise after a successful link; idedata
|
||||||
# is a bonus artifact, so consumers warn instead of failing the build
|
# is a bonus artifact, so consumers warn instead of failing the build
|
||||||
IDEDATA_BEST_EFFORT_ERRORS = (
|
IDEDATA_BEST_EFFORT_ERRORS = (
|
||||||
@@ -34,36 +31,13 @@ IDEDATA_BEST_EFFORT_ERRORS = (
|
|||||||
ValueError,
|
ValueError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None:
|
# C++ translation-unit suffixes used to identify ESPHome source files.
|
||||||
"""Run an idedata generator, downgrading any failure to a warning.
|
_CXX_SUFFIXES = (".cpp", ".cc")
|
||||||
|
|
||||||
Shared by the native backends: the firmware already built, so a missing
|
|
||||||
or broken idedata must not fail a successful build.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if get_idedata() is None:
|
|
||||||
_LOGGER.warning("No idedata was generated for this build")
|
|
||||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
|
||||||
"memory-analysis data will be unavailable for this build)",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
if isinstance(err, (EsphomeError, OSError)):
|
|
||||||
# Routine environmental failures keep the detail at debug
|
|
||||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
|
||||||
else:
|
|
||||||
# LookupError/ValueError/RuntimeError smell like a parsing bug;
|
|
||||||
# a permanently masked traceback would hide it on every build
|
|
||||||
_LOGGER.warning("Idedata failure detail", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
# C++ translation-unit suffixes.
|
|
||||||
CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx")
|
|
||||||
# Suffixes of input/output files that appear bare on the command line (and so
|
# Suffixes of input/output files that appear bare on the command line (and so
|
||||||
# must not be mistaken for compiler flags).
|
# must not be mistaken for compiler flags).
|
||||||
_INPUT_FILE_SUFFIXES = (*CXX_SOURCE_SUFFIXES, ".c", ".o", ".S", ".s")
|
_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s")
|
||||||
# Path marker identifying an ESPHome source translation unit.
|
# Path marker identifying an ESPHome source translation unit.
|
||||||
_ESPHOME_SRC_MARKER = "/src/esphome/"
|
_ESPHOME_SRC_MARKER = "/src/esphome/"
|
||||||
|
|
||||||
@@ -72,11 +46,11 @@ def _is_esphome_src(file: str) -> bool:
|
|||||||
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
|
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
|
||||||
``/`` first since Windows compile DBs use backslashes."""
|
``/`` first since Windows compile DBs use backslashes."""
|
||||||
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
|
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
|
||||||
CXX_SOURCE_SUFFIXES
|
_CXX_SUFFIXES
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def split_command(command: str) -> list[str]:
|
def _split_command(command: str) -> list[str]:
|
||||||
r"""Tokenize a compile_commands.json / response-file command string.
|
r"""Tokenize a compile_commands.json / response-file command string.
|
||||||
|
|
||||||
On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``.
|
On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``.
|
||||||
@@ -112,7 +86,7 @@ def split_command(command: str) -> list[str]:
|
|||||||
ctypes.windll.kernel32.LocalFree(argv)
|
ctypes.windll.kernel32.LocalFree(argv)
|
||||||
|
|
||||||
|
|
||||||
def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||||
"""Inline any ``@response-file`` arguments (paths relative to ``directory``).
|
"""Inline any ``@response-file`` arguments (paths relative to ``directory``).
|
||||||
|
|
||||||
GCC response files embed flags that must be expanded so GCC-only flags
|
GCC response files embed flags that must be expanded so GCC-only flags
|
||||||
@@ -127,8 +101,8 @@ def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
|||||||
rf = directory / rf
|
rf = directory / rf
|
||||||
try:
|
try:
|
||||||
out.extend(
|
out.extend(
|
||||||
expand_response_files(
|
_expand_response_files(
|
||||||
split_command(rf.read_text(encoding="utf-8")), directory
|
_split_command(rf.read_text(encoding="utf-8")), directory
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -147,7 +121,7 @@ def _pick_entry(entries: list[dict]) -> dict:
|
|||||||
if _is_esphome_src(entry["file"]):
|
if _is_esphome_src(entry["file"]):
|
||||||
return entry
|
return entry
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if entry["file"].endswith(CXX_SOURCE_SUFFIXES):
|
if entry["file"].endswith(_CXX_SUFFIXES):
|
||||||
return entry
|
return entry
|
||||||
raise ValueError("no C++ translation unit found in compile_commands.json")
|
raise ValueError("no C++ translation unit found in compile_commands.json")
|
||||||
|
|
||||||
@@ -157,25 +131,16 @@ def _pick_entry(entries: list[dict]) -> dict:
|
|||||||
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
|
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
|
||||||
|
|
||||||
|
|
||||||
def is_launcher(token: str) -> bool:
|
def _is_launcher(token: str) -> bool:
|
||||||
return Path(token).stem.lower() in _LAUNCHER_STEMS
|
return Path(token).stem.lower() in _LAUNCHER_STEMS
|
||||||
|
|
||||||
|
|
||||||
def is_joined_include(tok: str) -> bool:
|
|
||||||
"""The joined ``-includefoo.h`` spelling; excludes clang's -include-pch."""
|
|
||||||
return (
|
|
||||||
tok.startswith("-include")
|
|
||||||
and tok != "-include"
|
|
||||||
and not tok.startswith("-include-")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_entry(
|
def parse_entry(
|
||||||
entry: dict, launcher: str | None = None
|
entry: dict, launcher: str | None = None
|
||||||
) -> tuple[str, list[str], list[str], list[str]]:
|
) -> tuple[str, list[str], list[str], list[str]]:
|
||||||
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
||||||
directory = Path(entry["directory"])
|
directory = Path(entry["directory"])
|
||||||
tokens = expand_response_files(split_command(entry["command"]), directory)
|
tokens = _expand_response_files(_split_command(entry["command"]), directory)
|
||||||
|
|
||||||
def _include(raw: str) -> str:
|
def _include(raw: str) -> str:
|
||||||
# Resolve against the entry's ``directory`` so cached idedata works
|
# Resolve against the entry's ``directory`` so cached idedata works
|
||||||
@@ -191,7 +156,7 @@ def parse_entry(
|
|||||||
if not tokens:
|
if not tokens:
|
||||||
# An empty command, or one that was only the launcher; fail by name
|
# An empty command, or one that was only the launcher; fail by name
|
||||||
raise ValueError(f"empty compile command for {entry.get('file')}")
|
raise ValueError(f"empty compile command for {entry.get('file')}")
|
||||||
if is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
||||||
# Stale DB built with a launcher this run no longer configures; the
|
# Stale DB built with a launcher this run no longer configures; the
|
||||||
# real compiler is the next token
|
# real compiler is the next token
|
||||||
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
|
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
|
||||||
@@ -204,23 +169,11 @@ def parse_entry(
|
|||||||
defines: list[str] = []
|
defines: list[str] = []
|
||||||
includes: list[str] = []
|
includes: list[str] = []
|
||||||
cxx_flags: list[str] = []
|
cxx_flags: list[str] = []
|
||||||
unresolved_force_includes: list[str] = []
|
|
||||||
|
|
||||||
it = iter(tokens[1:])
|
it = iter(tokens[1:])
|
||||||
for tok in it:
|
for tok in it:
|
||||||
if tok in ("-c", "-o"):
|
if tok in ("-c", "-o"):
|
||||||
next(it, None) # drop the flag and its argument (input/output)
|
next(it, None) # drop the flag and its argument (input/output)
|
||||||
elif tok == "-include" or is_joined_include(tok):
|
|
||||||
# Re-anchor only names next to the compile (the pch); a name
|
|
||||||
# meant for the -I chain must stay untouched
|
|
||||||
raw = next(it, "") if tok == "-include" else tok[len("-include") :]
|
|
||||||
if not raw:
|
|
||||||
_LOGGER.warning("Dropping -include with no argument")
|
|
||||||
elif Path(resolved := _include(raw)).is_file():
|
|
||||||
cxx_flags.extend(("-include", resolved))
|
|
||||||
else:
|
|
||||||
unresolved_force_includes.append(raw)
|
|
||||||
cxx_flags.extend(("-include", raw))
|
|
||||||
elif tok.startswith("-D"):
|
elif tok.startswith("-D"):
|
||||||
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
|
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
|
||||||
# quoted arg with a space after -D) that some flags arrive as.
|
# quoted arg with a space after -D) that some flags arrive as.
|
||||||
@@ -239,14 +192,6 @@ def parse_entry(
|
|||||||
pass # input/output files
|
pass # input/output files
|
||||||
else:
|
else:
|
||||||
cxx_flags.append(tok)
|
cxx_flags.append(tok)
|
||||||
for raw in unresolved_force_includes:
|
|
||||||
# A deleted build artifact would otherwise surface only downstream
|
|
||||||
if not any((Path(inc) / raw).is_file() for inc in includes):
|
|
||||||
_LOGGER.warning(
|
|
||||||
"-include %s found neither next to the compile nor on the "
|
|
||||||
"include path; cached idedata may not resolve it",
|
|
||||||
raw,
|
|
||||||
)
|
|
||||||
return cxx_path, defines, includes, cxx_flags
|
return cxx_path, defines, includes, cxx_flags
|
||||||
|
|
||||||
|
|
||||||
@@ -311,7 +256,7 @@ def _cache_usable(cached: object) -> bool:
|
|||||||
if not isinstance(cached, dict) or "cc_path" not in cached:
|
if not isinstance(cached, dict) or "cc_path" not in cached:
|
||||||
return False
|
return False
|
||||||
cxx_path = cached.get("cxx_path")
|
cxx_path = cached.get("cxx_path")
|
||||||
if not isinstance(cxx_path, str) or is_launcher(cxx_path):
|
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
|
||||||
return False
|
return False
|
||||||
includes = cached.get("includes")
|
includes = cached.get("includes")
|
||||||
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
|
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
|
||||||
@@ -359,7 +304,7 @@ def load_or_build_idedata(
|
|||||||
def reject_launcher_compiler(cxx_path: str) -> None:
|
def reject_launcher_compiler(cxx_path: str) -> None:
|
||||||
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
|
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
|
||||||
must never be probed, cached, or consumed."""
|
must never be probed, cached, or consumed."""
|
||||||
if is_launcher(cxx_path):
|
if _is_launcher(cxx_path):
|
||||||
raise EsphomeError(
|
raise EsphomeError(
|
||||||
f"compile_commands.json names the launcher {cxx_path} as the "
|
f"compile_commands.json names the launcher {cxx_path} as the "
|
||||||
"compiler; the compile database is unusable"
|
"compiler; the compile database is unusable"
|
||||||
|
|||||||
@@ -1,423 +0,0 @@
|
|||||||
"""Shared precompiled-header policy for the build backends.
|
|
||||||
|
|
||||||
The prefix either mirrors the TUs' own force-includes (ESP8266) or is a
|
|
||||||
curated core-header set (ESP-IDF). ``esphome: includes:`` sources receive
|
|
||||||
it too; Arduino.h visibility there is intended (esphome#8693).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import posixpath
|
|
||||||
import re
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
|
|
||||||
from esphome.build_helpers.idedata import (
|
|
||||||
CXX_SOURCE_SUFFIXES,
|
|
||||||
expand_response_files,
|
|
||||||
is_launcher,
|
|
||||||
split_command,
|
|
||||||
)
|
|
||||||
|
|
||||||
_DOMAIN = "pch"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _PCHData:
|
|
||||||
emitted: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def _pch_data() -> _PCHData:
|
|
||||||
from esphome.core import CORE
|
|
||||||
|
|
||||||
if _DOMAIN not in CORE.data:
|
|
||||||
CORE.data[_DOMAIN] = _PCHData()
|
|
||||||
return CORE.data[_DOMAIN]
|
|
||||||
|
|
||||||
|
|
||||||
def mark_pch_emitted() -> None:
|
|
||||||
"""Record that this build's consumers reference the pch."""
|
|
||||||
_pch_data().emitted = True
|
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# The header and its .gch/.sum sidecars live in the build directory.
|
|
||||||
PCH_HEADER_NAME = "esphome_pch.h"
|
|
||||||
|
|
||||||
# Every artifact the pch machinery can leave behind, for cleanup.
|
|
||||||
PCH_ARTIFACT_NAMES = (
|
|
||||||
PCH_HEADER_NAME,
|
|
||||||
f"{PCH_HEADER_NAME}.gch",
|
|
||||||
f"{PCH_HEADER_NAME}.gch.sum",
|
|
||||||
f"{PCH_HEADER_NAME}.gch.failed",
|
|
||||||
)
|
|
||||||
|
|
||||||
# The core defines header every backend anchors its prefix on.
|
|
||||||
PCH_CORE_HEADER = "esphome/core/defines.h"
|
|
||||||
|
|
||||||
# Prefix-header contents for backends that inject a curated set (rather
|
|
||||||
# than mirroring the TUs' own force-includes), defines.h first so USE_*
|
|
||||||
# macros exist for the rest. Deliberately hard-coded: frequency-derived
|
|
||||||
# sets measured no better and kept selecting headers that cannot compile
|
|
||||||
# standalone (X-macro, platform-variant). Every entry must be safe to
|
|
||||||
# include first in an empty TU. Caveat: application.h/automation.h become
|
|
||||||
# ambiently visible, so a TU missing those #includes still builds on such
|
|
||||||
# backends; ESPHOME_PCH_ENABLE=0 restores the strict view.
|
|
||||||
PCH_DEFAULT_HEADERS = (
|
|
||||||
PCH_CORE_HEADER,
|
|
||||||
"esphome/core/component.h",
|
|
||||||
"esphome/core/helpers.h",
|
|
||||||
"esphome/core/log.h",
|
|
||||||
"esphome/core/application.h",
|
|
||||||
"esphome/core/automation.h",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ccache cannot hash through a .gch; CCACHE_PCH_EXTSUM makes it hash the
|
|
||||||
# .sum sidecar instead of the .gch bytes, which are not reproducible.
|
|
||||||
# Keep in sync with the literals in platformio/pch.py.script.
|
|
||||||
_CCACHE_PCH_ENV = {
|
|
||||||
"CCACHE_SLOPPINESS": "pch_defines,time_macros",
|
|
||||||
"CCACHE_PCH_EXTSUM": "true",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Both include forms: an angle include resolving under src/ must enter the
|
|
||||||
# digest too; ones that do not resolve simply end the walk
|
|
||||||
# Compiler failures that clear on their own must not latch the .failed marker
|
|
||||||
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
|
|
||||||
|
|
||||||
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
def pch_enabled() -> bool:
|
|
||||||
"""Precompiled-header knob: default on, ``ESPHOME_PCH_ENABLE=0`` opts out."""
|
|
||||||
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
|
|
||||||
|
|
||||||
|
|
||||||
def ccache_pch_env() -> dict[str, str]:
|
|
||||||
"""Settings ccache needs to cache compiles that consume the .gch;
|
|
||||||
empty unless this build actually emitted one. User-set values win.
|
|
||||||
Native backends export these process-wide; only time_macros affects
|
|
||||||
non-pch TUs."""
|
|
||||||
if not (pch_enabled() and _pch_data().emitted):
|
|
||||||
return {}
|
|
||||||
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
|
|
||||||
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
|
|
||||||
# ccache then hashes the non-reproducible .gch bytes: permanent misses
|
|
||||||
_LOGGER.warning("CCACHE_PCH_EXTSUM=%s disables pch caching", extsum)
|
|
||||||
env = {k: v for k, v in _CCACHE_PCH_ENV.items() if k not in os.environ}
|
|
||||||
user_sloppiness = os.environ.get("CCACHE_SLOPPINESS")
|
|
||||||
if user_sloppiness is not None and (
|
|
||||||
missing := [
|
|
||||||
t
|
|
||||||
for t in ("pch_defines", "time_macros")
|
|
||||||
if t not in {tok.strip() for tok in user_sloppiness.split(",")}
|
|
||||||
]
|
|
||||||
):
|
|
||||||
# Without these ccache declines every pch-consuming compile
|
|
||||||
env["CCACHE_SLOPPINESS"] = ",".join((user_sloppiness, *missing))
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Adding %s to CCACHE_SLOPPINESS so ccache can cache compiles "
|
|
||||||
"that use the precompiled header",
|
|
||||||
",".join(missing),
|
|
||||||
)
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def pch_extra_scripts() -> list[str]:
|
|
||||||
"""The extra_scripts entries a PlatformIO platform registers for the
|
|
||||||
pch; empty when disabled (the script itself has no enable check)."""
|
|
||||||
return ["post:pch.py"] if pch_enabled() else []
|
|
||||||
|
|
||||||
|
|
||||||
def pch_header_text(include_headers: Iterable[str]) -> str:
|
|
||||||
"""The prefix-header source: exactly these includes, in order."""
|
|
||||||
return "".join(f'#include "{name}"\n' for name in include_headers)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolves(path: Path) -> bool:
|
|
||||||
"""False when missing; other stat failures propagate (identity unknown,
|
|
||||||
unlike is_file(), which would silently drop the header)."""
|
|
||||||
try:
|
|
||||||
return stat.S_ISREG(path.stat().st_mode)
|
|
||||||
except (FileNotFoundError, NotADirectoryError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]:
|
|
||||||
"""Include closure of ``roots``: src-relative name -> contents.
|
|
||||||
|
|
||||||
Resolution mirrors the compiler (includer's dir, then src root); names
|
|
||||||
outside ``src_dir`` end the walk and are versioned by the caller. No
|
|
||||||
#ifdef evaluation: over-approximating is the safe direction.
|
|
||||||
"""
|
|
||||||
seen: dict[str, bytes] = {}
|
|
||||||
stack: list[tuple[str, str]] = [(name, "") for name in roots]
|
|
||||||
while stack:
|
|
||||||
name, from_dir = stack.pop()
|
|
||||||
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
|
|
||||||
rel = posixpath.normpath(candidate)
|
|
||||||
if not rel.startswith("..") and _resolves(src_dir / rel):
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if rel in seen:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
data = (src_dir / rel).read_bytes()
|
|
||||||
except OSError as err:
|
|
||||||
# A marker would truncate the transitive walk; fail closed
|
|
||||||
_LOGGER.warning("Could not read %s for the pch checksum: %s", rel, err)
|
|
||||||
raise
|
|
||||||
seen[rel] = data
|
|
||||||
parent = posixpath.dirname(rel)
|
|
||||||
stack.extend(
|
|
||||||
# surrogateescape: a non-UTF-8 name just fails to resolve
|
|
||||||
(inc.decode(errors="surrogateescape"), parent)
|
|
||||||
for inc in _INCLUDE_RE.findall(data)
|
|
||||||
)
|
|
||||||
return seen
|
|
||||||
|
|
||||||
|
|
||||||
def pch_checksum(
|
|
||||||
src_dir: Path, include_headers: Iterable[str], extra: Iterable[str]
|
|
||||||
) -> str:
|
|
||||||
"""Digest standing in for the .gch in ccache's hash: the include closure
|
|
||||||
of the prefix header plus caller-supplied identity strings (versioned
|
|
||||||
install paths, flags). Raises OSError when a header's identity cannot
|
|
||||||
be established at all; callers must then compile without a pch."""
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
closure = _include_closure(src_dir, include_headers)
|
|
||||||
for name in sorted(closure):
|
|
||||||
digest.update(name.encode(errors="surrogateescape"))
|
|
||||||
digest.update(closure[name])
|
|
||||||
digest.update(b"\0")
|
|
||||||
for item in extra:
|
|
||||||
digest.update(item.encode(errors="surrogateescape"))
|
|
||||||
digest.update(b"\0")
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
# Tokens dropped when retargeting a TU's flags at the prefix header
|
|
||||||
# (the pch compile must not touch depfiles)
|
|
||||||
_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"})
|
|
||||||
_PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"})
|
|
||||||
|
|
||||||
|
|
||||||
def pch_compile_command(
|
|
||||||
build_dir: Path, header: Path, gch: Path
|
|
||||||
) -> tuple[list[str], Path] | None:
|
|
||||||
"""The exact src C++ flags from compile_commands.json retargeted at the
|
|
||||||
header, with the directory they resolve against (relative -I paths must
|
|
||||||
be expanded and executed from the same root); None (logged) when no
|
|
||||||
configured C++ TU is available yet."""
|
|
||||||
from esphome.core import CORE
|
|
||||||
|
|
||||||
try:
|
|
||||||
entries = json.loads(
|
|
||||||
(build_dir / "compile_commands.json").read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
except (OSError, json.JSONDecodeError) as err:
|
|
||||||
# Configure already succeeded, so an unusable DB is a real anomaly
|
|
||||||
_LOGGER.warning("No usable compile database, skipping pch: %s", err)
|
|
||||||
return None
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
_LOGGER.warning("Malformed compile database, skipping pch")
|
|
||||||
return None
|
|
||||||
# CMake may spell paths through a symlink differently than CORE does
|
|
||||||
# (macOS /tmp vs /private/tmp), so compare resolved paths
|
|
||||||
src_root = Path(CORE.relative_src_path()).resolve()
|
|
||||||
entry = next(
|
|
||||||
(
|
|
||||||
e
|
|
||||||
for e in entries
|
|
||||||
if isinstance(e, dict)
|
|
||||||
and isinstance(e.get("file"), str)
|
|
||||||
and e["file"].endswith(CXX_SOURCE_SUFFIXES)
|
|
||||||
and Path(e["file"]).resolve().is_relative_to(src_root)
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if entry is None:
|
|
||||||
_LOGGER.warning("No src C++ entry in the compile database, skipping pch")
|
|
||||||
return None
|
|
||||||
directory = entry.get("directory")
|
|
||||||
cmd_dir = Path(directory) if isinstance(directory, str) and directory else build_dir
|
|
||||||
command = entry.get("command")
|
|
||||||
tokens = expand_response_files(
|
|
||||||
split_command(command if isinstance(command, str) else ""), cmd_dir
|
|
||||||
)
|
|
||||||
# A DB recorded with ccache enabled prefixes the compiler with the
|
|
||||||
# launcher; the .gch must be compiled directly
|
|
||||||
if tokens and is_launcher(tokens[0]):
|
|
||||||
tokens = tokens[1:]
|
|
||||||
if not tokens:
|
|
||||||
# "arguments"-style or empty entries must skip, not spawn "-x ..."
|
|
||||||
_LOGGER.warning("Compile database entry has no usable command, skipping pch")
|
|
||||||
return None
|
|
||||||
args: list[str] = []
|
|
||||||
arg_it = iter(tokens)
|
|
||||||
for tok in arg_it:
|
|
||||||
if tok in _PCH_STRIP_FLAGS_WITH_ARG:
|
|
||||||
next(arg_it, None)
|
|
||||||
continue
|
|
||||||
if tok in _PCH_STRIP_FLAGS:
|
|
||||||
continue
|
|
||||||
if tok == "-include":
|
|
||||||
# Drop only the injected prefix; user force-includes must reach
|
|
||||||
# the .gch compile or GCC rejects it over the macro mismatch
|
|
||||||
inc = next(arg_it, "")
|
|
||||||
if not inc.endswith(PCH_HEADER_NAME):
|
|
||||||
args.extend(("-include", inc))
|
|
||||||
continue
|
|
||||||
args.append(tok)
|
|
||||||
return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)], cmd_dir
|
|
||||||
|
|
||||||
|
|
||||||
def _log_pch_in_use() -> None:
|
|
||||||
# The only place a user can discover the knob; emitted only once a
|
|
||||||
# .gch is actually fresh or being built
|
|
||||||
_LOGGER.info(
|
|
||||||
"Compiling with a precompiled header (set ESPHOME_PCH_ENABLE=0 to disable)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_stamp(path: Path) -> str:
|
|
||||||
"""A corrupt sidecar must read as stale, not kill the pch forever."""
|
|
||||||
try:
|
|
||||||
return path.read_text(encoding="utf-8").strip()
|
|
||||||
except (OSError, UnicodeDecodeError):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def discard_pch(build_dir: Path) -> None:
|
|
||||||
"""Remove the pch sidecars so a stale .gch is never consumed.
|
|
||||||
|
|
||||||
Bumps the header only when a .gch was actually removed: TUs compiled
|
|
||||||
against it have incomplete depfiles, while a repeat failure with no
|
|
||||||
.gch must not force a full rebuild every build.
|
|
||||||
"""
|
|
||||||
header = build_dir / PCH_HEADER_NAME
|
|
||||||
gch = Path(f"{header}.gch")
|
|
||||||
had_gch = gch.is_file()
|
|
||||||
gch.unlink(missing_ok=True)
|
|
||||||
Path(f"{gch}.sum").unlink(missing_ok=True)
|
|
||||||
if had_gch and header.is_file():
|
|
||||||
os.utime(header)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_pch(
|
|
||||||
build_dir: Path, include_headers: tuple[str, ...], extra: Iterable[str]
|
|
||||||
) -> None:
|
|
||||||
"""Compile ``build_dir``'s .gch from compile_commands.json flags and
|
|
||||||
write its ccache .sum.
|
|
||||||
|
|
||||||
The .sum doubles as the freshness stamp and folds in the compile
|
|
||||||
command, so a flag-only change rebuilds the .gch; ``extra`` carries
|
|
||||||
backend identity (framework version, sdkconfig, ...). A failed
|
|
||||||
compile falls back to the plain header include.
|
|
||||||
"""
|
|
||||||
from esphome.core import CORE
|
|
||||||
|
|
||||||
header = build_dir / PCH_HEADER_NAME
|
|
||||||
gch = Path(f"{header}.gch")
|
|
||||||
sum_path = Path(f"{gch}.sum")
|
|
||||||
cmd_and_dir = pch_compile_command(build_dir, header, gch)
|
|
||||||
if cmd_and_dir is None:
|
|
||||||
# Freshness cannot be validated; a leftover .gch must not be consumed
|
|
||||||
discard_pch(build_dir)
|
|
||||||
return
|
|
||||||
cmd, cmd_dir = cmd_and_dir
|
|
||||||
# Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw
|
|
||||||
# build path covers unresolved (symlinked) spellings
|
|
||||||
cmd_id = (
|
|
||||||
" ".join(cmd)
|
|
||||||
.replace(effective_ccache_basedir(), "")
|
|
||||||
.replace(str(CORE.build_path), "")
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
checksum = pch_checksum(
|
|
||||||
CORE.relative_src_path(),
|
|
||||||
include_headers,
|
|
||||||
(
|
|
||||||
# The closure is sorted, so root order only enters via the text
|
|
||||||
pch_header_text(include_headers),
|
|
||||||
*extra,
|
|
||||||
cmd_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except (OSError, UnicodeError) as err:
|
|
||||||
# Identity unknown: a stale cache entry must never be served
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Could not establish the pch identity; compiling without it: %s", err
|
|
||||||
)
|
|
||||||
discard_pch(build_dir)
|
|
||||||
return
|
|
||||||
if gch.is_file() and _read_stamp(sum_path) == checksum:
|
|
||||||
_log_pch_in_use()
|
|
||||||
return
|
|
||||||
failed_marker = Path(f"{gch}.failed")
|
|
||||||
if _read_stamp(failed_marker) == checksum:
|
|
||||||
_LOGGER.info(
|
|
||||||
"Precompiled header disabled after an earlier failure; delete %s to retry",
|
|
||||||
failed_marker,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
_log_pch_in_use()
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
cwd=cmd_dir,
|
|
||||||
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
|
|
||||||
env={**os.environ, "LC_ALL": "C"},
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
timeout=300,
|
|
||||||
)
|
|
||||||
error = None
|
|
||||||
if result.returncode < 0:
|
|
||||||
# Killed by a signal (OOM, ^C): environmental, do not latch
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Precompiled header compile was killed (signal %d); retrying "
|
|
||||||
"next build",
|
|
||||||
-result.returncode,
|
|
||||||
)
|
|
||||||
discard_pch(build_dir)
|
|
||||||
return
|
|
||||||
if result.returncode != 0:
|
|
||||||
error = result.stderr.strip() or f"exit code {result.returncode}"
|
|
||||||
elif not gch.is_file():
|
|
||||||
error = "compiler produced no .gch"
|
|
||||||
except (OSError, subprocess.SubprocessError) as err:
|
|
||||||
# Transient (timeout, spawn/IO): warn and retry next build, no marker
|
|
||||||
_LOGGER.warning("Precompiled header compile did not run: %s", err)
|
|
||||||
discard_pch(build_dir)
|
|
||||||
return
|
|
||||||
if error is not None:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Precompiled header failed; compiling without it: %s", error[:400]
|
|
||||||
)
|
|
||||||
# This path latches, so keep the full compiler output recoverable
|
|
||||||
_LOGGER.debug("Full pch compile output: %s", error)
|
|
||||||
discard_pch(build_dir)
|
|
||||||
if any(m in error for m in _TRANSIENT_ERRORS):
|
|
||||||
# Resource exhaustion clears on its own; retry next build
|
|
||||||
return
|
|
||||||
# Skip retries until a header/flag/backend-identity/command change
|
|
||||||
failed_marker.write_text(checksum + "\n", encoding="utf-8")
|
|
||||||
os.utime(header)
|
|
||||||
return
|
|
||||||
failed_marker.unlink(missing_ok=True)
|
|
||||||
sum_path.write_text(checksum + "\n", encoding="utf-8")
|
|
||||||
# Consumers depend on the header (depfiles cannot see through a .gch);
|
|
||||||
# bump it so users of the previous .gch recompile
|
|
||||||
os.utime(header)
|
|
||||||
@@ -14,6 +14,7 @@ from esphome.components.noise import ( # noqa: F401
|
|||||||
ENCRYPTION_SCHEMA,
|
ENCRYPTION_SCHEMA,
|
||||||
decode_encryption_key,
|
decode_encryption_key,
|
||||||
encryption_schema,
|
encryption_schema,
|
||||||
|
new_psk_progmem,
|
||||||
validate_encryption_key,
|
validate_encryption_key,
|
||||||
)
|
)
|
||||||
from esphome.config_helpers import filter_source_files_from_defines, get_logger_level
|
from esphome.config_helpers import filter_source_files_from_defines, get_logger_level
|
||||||
@@ -589,8 +590,7 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
|
|
||||||
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
|
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
|
||||||
if key := encryption_config.get(CONF_KEY):
|
if key := encryption_config.get(CONF_KEY):
|
||||||
decoded = decode_encryption_key(key)
|
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
|
||||||
cg.add(var.set_noise_psk(list(decoded)))
|
|
||||||
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
||||||
else:
|
else:
|
||||||
# No key provided, but encryption desired
|
# No key provided, but encryption desired
|
||||||
|
|||||||
@@ -2161,7 +2161,10 @@ void APIConnection::on_homeassistant_action_response(const HomeassistantActionRe
|
|||||||
bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg) {
|
bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg) {
|
||||||
NoiseEncryptionSetKeyResponse resp;
|
NoiseEncryptionSetKeyResponse resp;
|
||||||
resp.success = false;
|
resp.success = false;
|
||||||
|
#ifdef USE_API_NOISE_PSK_FROM_YAML
|
||||||
|
// A yaml key cannot be changed at runtime, so no decode or save path is built
|
||||||
|
ESP_LOGW(TAG, "Key set in YAML");
|
||||||
|
#else
|
||||||
#ifdef USE_PROVISIONING
|
#ifdef USE_PROVISIONING
|
||||||
// Refuse to set a key once the provisioning window has closed (defense in depth;
|
// Refuse to set a key once the provisioning window has closed (defense in depth;
|
||||||
// such connections are already rejected at hello).
|
// such connections are already rejected at hello).
|
||||||
@@ -2196,6 +2199,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#endif // USE_API_NOISE_PSK_FROM_YAML
|
||||||
|
|
||||||
return this->send_message(resp);
|
return this->send_message(resp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
|||||||
* @return 0 on success, -1 on error (check errno)
|
* @return 0 on success, -1 on error (check errno)
|
||||||
*/
|
*/
|
||||||
APIError APINoiseFrameHelper::init_handshake_() {
|
APIError APINoiseFrameHelper::init_handshake_() {
|
||||||
int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size());
|
int err = this->handshake_.init(this->ctx_, prologue_.data(), prologue_.size());
|
||||||
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||||
if (aerr != APIError::OK)
|
if (aerr != APIError::OK)
|
||||||
return aerr;
|
return aerr;
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ void APIServer::setup() {
|
|||||||
ControllerRegistry::register_controller(this);
|
ControllerRegistry::register_controller(this);
|
||||||
|
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
|
// Always reserve the slot: flash preferences are positional on esp8266, so
|
||||||
|
// a yaml key build must keep the layout of a runtime key build
|
||||||
uint32_t hash = 88491486UL;
|
uint32_t hash = 88491486UL;
|
||||||
|
|
||||||
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true);
|
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true);
|
||||||
|
|
||||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||||
// Only load saved PSK if not set from YAML
|
// A cleared record loads fine but holds no key
|
||||||
if (this->load_and_apply_noise_psk_()) {
|
if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) {
|
||||||
ESP_LOGD(TAG, "Loaded saved Noise PSK");
|
ESP_LOGD(TAG, "Loaded saved Noise PSK");
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -550,6 +550,7 @@ const std::vector<APIServer::HomeAssistantStateSubscription> &APIServer::get_sta
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
|
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||||
bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg,
|
bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg,
|
||||||
const LogString *fail_log_msg, bool make_active) {
|
const LogString *fail_log_msg, bool make_active) {
|
||||||
if (!this->noise_pref_.save(&new_psk)) {
|
if (!this->noise_pref_.save(&new_psk)) {
|
||||||
@@ -583,22 +584,19 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool APIServer::load_and_apply_noise_psk_() {
|
bool APIServer::load_and_apply_noise_psk_() {
|
||||||
SavedNoisePsk saved{};
|
// Load into a temp so a failed read cannot disturb the key in use
|
||||||
if (!this->noise_pref_.load(&saved))
|
SavedNoisePsk loaded{};
|
||||||
|
if (!this->noise_pref_.load(&loaded))
|
||||||
return false;
|
return false;
|
||||||
this->set_noise_psk(saved.psk);
|
this->saved_psk_ = loaded;
|
||||||
|
// An unprovisioned device stores the reserved all-zeros key, which is no key
|
||||||
|
const bool has_key = !noise::NoiseContext::is_all_zeros(this->saved_psk_.psk);
|
||||||
|
this->noise_ctx_.set_psk(has_key ? this->saved_psk_.psk.data() : nullptr);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
|
bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
|
||||||
#ifdef USE_API_NOISE_PSK_FROM_YAML
|
if (this->saved_psk_.psk == psk) {
|
||||||
// When PSK is set from YAML, this function should never be called
|
|
||||||
// but if it is, reject the change
|
|
||||||
ESP_LOGW(TAG, "Key set in YAML");
|
|
||||||
return false;
|
|
||||||
#else
|
|
||||||
auto &old_psk = this->noise_ctx_.get_psk();
|
|
||||||
if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) {
|
|
||||||
ESP_LOGW(TAG, "New PSK matches old");
|
ESP_LOGW(TAG, "New PSK matches old");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -614,15 +612,8 @@ bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
return result;
|
return result;
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
bool APIServer::clear_noise_psk(bool make_active) {
|
bool APIServer::clear_noise_psk(bool make_active) {
|
||||||
#ifdef USE_API_NOISE_PSK_FROM_YAML
|
|
||||||
// When PSK is set from YAML, this function should never be called
|
|
||||||
// but if it is, reject the change
|
|
||||||
ESP_LOGW(TAG, "Key set in YAML");
|
|
||||||
return false;
|
|
||||||
#else
|
|
||||||
SavedNoisePsk empty_psk{};
|
SavedNoisePsk empty_psk{};
|
||||||
bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
|
bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
|
||||||
make_active);
|
make_active);
|
||||||
@@ -634,8 +625,8 @@ bool APIServer::clear_noise_psk(bool make_active) {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
return result;
|
return result;
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
#endif // USE_API_NOISE_PSK_FROM_YAML
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef USE_HOMEASSISTANT_TIME
|
#ifdef USE_HOMEASSISTANT_TIME
|
||||||
|
|||||||
@@ -76,9 +76,14 @@ class APIServer final : public Component,
|
|||||||
APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; }
|
APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; }
|
||||||
|
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
|
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||||
|
// Runtime key changes exist for the provisioning path only (not lambdas);
|
||||||
|
// with a yaml key they compile out
|
||||||
bool save_noise_psk(noise::psk_t psk, bool make_active = true);
|
bool save_noise_psk(noise::psk_t psk, bool make_active = true);
|
||||||
bool clear_noise_psk(bool make_active = true);
|
bool clear_noise_psk(bool make_active = true);
|
||||||
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
|
#endif
|
||||||
|
/// psk points at 32 bytes that live in flash for the life of the program
|
||||||
|
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||||
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
|
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
|
||||||
#endif // USE_API_NOISE
|
#endif // USE_API_NOISE
|
||||||
|
|
||||||
@@ -275,10 +280,12 @@ class APIServer final : public Component,
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
|
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||||
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
|
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
|
||||||
bool make_active);
|
bool make_active);
|
||||||
// Load saved PSK from preferences and apply it. Returns true on success.
|
// Load saved PSK from preferences and apply it. Returns true on success.
|
||||||
bool load_and_apply_noise_psk_();
|
bool load_and_apply_noise_psk_();
|
||||||
|
#endif // USE_API_NOISE_PSK_FROM_YAML
|
||||||
#endif // USE_API_NOISE
|
#endif // USE_API_NOISE
|
||||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||||
// Helper methods to reduce code duplication
|
// Helper methods to reduce code duplication
|
||||||
@@ -358,6 +365,9 @@ class APIServer final : public Component,
|
|||||||
|
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
noise::NoiseContext noise_ctx_;
|
noise::NoiseContext noise_ctx_;
|
||||||
|
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||||
|
SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key
|
||||||
|
#endif
|
||||||
ESPPreferenceObject noise_pref_;
|
ESPPreferenceObject noise_pref_;
|
||||||
#endif // USE_API_NOISE
|
#endif // USE_API_NOISE
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,6 +125,19 @@ CLIMATE_SWING_MODES = {
|
|||||||
|
|
||||||
validate_climate_swing_mode = cv.enum(CLIMATE_SWING_MODES, upper=True)
|
validate_climate_swing_mode = cv.enum(CLIMATE_SWING_MODES, upper=True)
|
||||||
|
|
||||||
|
ClimateAction = climate_ns.enum("ClimateAction")
|
||||||
|
CLIMATE_ACTIONS = {
|
||||||
|
"OFF": ClimateAction.CLIMATE_ACTION_OFF,
|
||||||
|
"COOLING": ClimateAction.CLIMATE_ACTION_COOLING,
|
||||||
|
"HEATING": ClimateAction.CLIMATE_ACTION_HEATING,
|
||||||
|
"IDLE": ClimateAction.CLIMATE_ACTION_IDLE,
|
||||||
|
"DRYING": ClimateAction.CLIMATE_ACTION_DRYING,
|
||||||
|
"FAN": ClimateAction.CLIMATE_ACTION_FAN,
|
||||||
|
"DEFROSTING": ClimateAction.CLIMATE_ACTION_DEFROSTING,
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_climate_action = cv.enum(CLIMATE_ACTIONS, upper=True)
|
||||||
|
|
||||||
CONF_MIN_HUMIDITY = "min_humidity"
|
CONF_MIN_HUMIDITY = "min_humidity"
|
||||||
CONF_MAX_HUMIDITY = "max_humidity"
|
CONF_MAX_HUMIDITY = "max_humidity"
|
||||||
CONF_TARGET_HUMIDITY = "target_humidity"
|
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||||
|
|||||||
@@ -100,21 +100,38 @@ void ESP32BLE::disable() {
|
|||||||
#ifdef USE_ESP32_BLE_ADVERTISING
|
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||||
void ESP32BLE::advertising_start() {
|
void ESP32BLE::advertising_start() {
|
||||||
this->advertising_init_();
|
this->advertising_init_();
|
||||||
if (!this->is_active())
|
this->advertising_ref_count_++;
|
||||||
|
this->advertising_refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ESP32BLE::advertising_stop() {
|
||||||
|
if (this->advertising_ref_count_ == 0)
|
||||||
return;
|
return;
|
||||||
this->advertising_->start();
|
this->advertising_ref_count_--;
|
||||||
|
this->advertising_refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ESP32BLE::advertising_refresh() {
|
||||||
|
if (this->advertising_ == nullptr || !this->is_active())
|
||||||
|
return;
|
||||||
|
// Advertise while any component still needs it, otherwise stop
|
||||||
|
if (this->advertising_ref_count_ == 0) {
|
||||||
|
this->advertising_->stop();
|
||||||
|
} else {
|
||||||
|
this->advertising_->start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ESP32BLE::advertising_set_service_data(const std::vector<uint8_t> &data) {
|
void ESP32BLE::advertising_set_service_data(const std::vector<uint8_t> &data) {
|
||||||
this->advertising_init_();
|
this->advertising_init_();
|
||||||
this->advertising_->set_service_data(data);
|
this->advertising_->set_service_data(data);
|
||||||
this->advertising_start();
|
this->advertising_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ESP32BLE::advertising_set_manufacturer_data(const std::vector<uint8_t> &data) {
|
void ESP32BLE::advertising_set_manufacturer_data(const std::vector<uint8_t> &data) {
|
||||||
this->advertising_init_();
|
this->advertising_init_();
|
||||||
this->advertising_->set_manufacturer_data(data);
|
this->advertising_->set_manufacturer_data(data);
|
||||||
this->advertising_start();
|
this->advertising_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ESP32BLE::advertising_set_service_data_and_name(std::span<const uint8_t> data, bool include_name) {
|
void ESP32BLE::advertising_set_service_data_and_name(std::span<const uint8_t> data, bool include_name) {
|
||||||
@@ -136,7 +153,7 @@ void ESP32BLE::advertising_set_service_data_and_name(std::span<const uint8_t> da
|
|||||||
this->advertising_->set_service_data(data);
|
this->advertising_->set_service_data(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
this->advertising_start();
|
this->advertising_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ESP32BLE::advertising_register_raw_advertisement_callback(std::function<void(bool)> &&callback) {
|
void ESP32BLE::advertising_register_raw_advertisement_callback(std::function<void(bool)> &&callback) {
|
||||||
@@ -147,13 +164,13 @@ void ESP32BLE::advertising_register_raw_advertisement_callback(std::function<voi
|
|||||||
void ESP32BLE::advertising_add_service_uuid(ESPBTUUID uuid) {
|
void ESP32BLE::advertising_add_service_uuid(ESPBTUUID uuid) {
|
||||||
this->advertising_init_();
|
this->advertising_init_();
|
||||||
this->advertising_->add_service_uuid(uuid);
|
this->advertising_->add_service_uuid(uuid);
|
||||||
this->advertising_start();
|
this->advertising_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) {
|
void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) {
|
||||||
this->advertising_init_();
|
this->advertising_init_();
|
||||||
this->advertising_->remove_service_uuid(uuid);
|
this->advertising_->remove_service_uuid(uuid);
|
||||||
this->advertising_start();
|
this->advertising_refresh();
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -575,6 +592,10 @@ void ESP32BLE::loop_handle_state_transition_not_active_() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this->state_ = BLE_COMPONENT_STATE_ACTIVE;
|
this->state_ = BLE_COMPONENT_STATE_ACTIVE;
|
||||||
|
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||||
|
// Requests made before the stack was up (or before it was re-enabled) take effect now
|
||||||
|
this->advertising_refresh();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,17 @@ class ESP32BLE final : public Component {
|
|||||||
void set_name(const char *name) { this->name_ = name; }
|
void set_name(const char *name) { this->name_ = name; }
|
||||||
|
|
||||||
#ifdef USE_ESP32_BLE_ADVERTISING
|
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||||
|
/** Request advertising on behalf of a component.
|
||||||
|
*
|
||||||
|
* Requests are reference counted: advertising runs until every component that called
|
||||||
|
* advertising_start() has released it again with advertising_stop(). Each component must
|
||||||
|
* pair its calls, so nothing advertises until something actually asks for it.
|
||||||
|
*/
|
||||||
void advertising_start();
|
void advertising_start();
|
||||||
|
/// Release a request made with advertising_start(); advertising stops at the last release.
|
||||||
|
void advertising_stop();
|
||||||
|
/// Apply the current payload and request count: advertise while requested, otherwise stop.
|
||||||
|
void advertising_refresh();
|
||||||
void advertising_set_service_data(const std::vector<uint8_t> &data);
|
void advertising_set_service_data(const std::vector<uint8_t> &data);
|
||||||
void advertising_set_manufacturer_data(const std::vector<uint8_t> &data);
|
void advertising_set_manufacturer_data(const std::vector<uint8_t> &data);
|
||||||
void advertising_set_appearance(uint16_t appearance) { this->appearance_ = appearance; }
|
void advertising_set_appearance(uint16_t appearance) { this->appearance_ = appearance; }
|
||||||
@@ -226,6 +236,9 @@ class ESP32BLE final : public Component {
|
|||||||
// 1-byte aligned members (grouped together to minimize padding)
|
// 1-byte aligned members (grouped together to minimize padding)
|
||||||
BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum)
|
BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum)
|
||||||
bool enable_on_boot_{}; // 1 byte
|
bool enable_on_boot_{}; // 1 byte
|
||||||
|
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||||
|
uint8_t advertising_ref_count_{0}; // 1 byte, number of components requesting advertising
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS
|
#ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS
|
||||||
optional<esp_ble_auth_req_t> auth_req_mode_;
|
optional<esp_ble_auth_req_t> auth_req_mode_;
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ void ESP32BLEBeacon::setup() {
|
|||||||
this->on_advertise_();
|
this->on_advertise_();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// A beacon always needs the device to advertise, and never releases the request
|
||||||
|
global_ble->advertising_start();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ESP32BLEBeacon::on_advertise_() {
|
void ESP32BLEBeacon::on_advertise_() {
|
||||||
|
|||||||
@@ -596,6 +596,18 @@ async def to_code(config):
|
|||||||
cg.add(var.set_parent(parent))
|
cg.add(var.set_parent(parent))
|
||||||
cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE]))
|
cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE]))
|
||||||
cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS]))
|
cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS]))
|
||||||
|
# Only advertise for the server itself when the configuration gives clients something to
|
||||||
|
# find. A server that is auto-loaded purely to host a runtime service (esp32_improv) stays
|
||||||
|
# silent until that service asks for advertising.
|
||||||
|
cg.add(
|
||||||
|
var.set_advertising_required(
|
||||||
|
CONF_MANUFACTURER_DATA in config
|
||||||
|
or any(
|
||||||
|
not uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID)
|
||||||
|
for service_config in config[CONF_SERVICES]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
if CONF_MANUFACTURER_DATA in config:
|
if CONF_MANUFACTURER_DATA in config:
|
||||||
cg.add(var.set_manufacturer_data(config[CONF_MANUFACTURER_DATA]))
|
cg.add(var.set_manufacturer_data(config[CONF_MANUFACTURER_DATA]))
|
||||||
for service_config in config[CONF_SERVICES]:
|
for service_config in config[CONF_SERVICES]:
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ void BLEServer::loop() {
|
|||||||
if (this->device_information_service_->is_running()) {
|
if (this->device_information_service_->is_running()) {
|
||||||
this->state_ = RUNNING;
|
this->state_ = RUNNING;
|
||||||
this->restart_advertising_();
|
this->restart_advertising_();
|
||||||
|
this->request_advertising_();
|
||||||
ESP_LOGD(TAG, "BLE server setup successfully");
|
ESP_LOGD(TAG, "BLE server setup successfully");
|
||||||
} else if (this->device_information_service_->is_created()) {
|
} else if (this->device_information_service_->is_created()) {
|
||||||
this->device_information_service_->start();
|
this->device_information_service_->start();
|
||||||
@@ -98,6 +99,20 @@ void BLEServer::restart_advertising_() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void BLEServer::request_advertising_() {
|
||||||
|
if (!this->advertising_required_ || this->advertising_requested_)
|
||||||
|
return;
|
||||||
|
this->advertising_requested_ = true;
|
||||||
|
this->parent_->advertising_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEServer::release_advertising_() {
|
||||||
|
if (!this->advertising_requested_)
|
||||||
|
return;
|
||||||
|
this->advertising_requested_ = false;
|
||||||
|
this->parent_->advertising_stop();
|
||||||
|
}
|
||||||
|
|
||||||
BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t num_handles) {
|
BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t num_handles) {
|
||||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||||
char uuid_buf[esp32_ble::UUID_STR_LEN];
|
char uuid_buf[esp32_ble::UUID_STR_LEN];
|
||||||
@@ -170,7 +185,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga
|
|||||||
this->add_client_(param->connect.conn_id);
|
this->add_client_(param->connect.conn_id);
|
||||||
// Resume advertising so additional clients can discover and connect
|
// Resume advertising so additional clients can discover and connect
|
||||||
if (this->client_count_ < this->max_clients_) {
|
if (this->client_count_ < this->max_clients_) {
|
||||||
this->parent_->advertising_start();
|
this->parent_->advertising_refresh();
|
||||||
}
|
}
|
||||||
this->dispatch_callbacks_(CallbackType::ON_CONNECT, param->connect.conn_id);
|
this->dispatch_callbacks_(CallbackType::ON_CONNECT, param->connect.conn_id);
|
||||||
break;
|
break;
|
||||||
@@ -178,7 +193,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga
|
|||||||
case ESP_GATTS_DISCONNECT_EVT: {
|
case ESP_GATTS_DISCONNECT_EVT: {
|
||||||
ESP_LOGD(TAG, "BLE Client disconnected");
|
ESP_LOGD(TAG, "BLE Client disconnected");
|
||||||
this->remove_client_(param->disconnect.conn_id);
|
this->remove_client_(param->disconnect.conn_id);
|
||||||
this->parent_->advertising_start();
|
this->parent_->advertising_refresh();
|
||||||
this->dispatch_callbacks_(CallbackType::ON_DISCONNECT, param->disconnect.conn_id);
|
this->dispatch_callbacks_(CallbackType::ON_DISCONNECT, param->disconnect.conn_id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -226,6 +241,8 @@ void BLEServer::remove_client_(uint16_t conn_id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void BLEServer::ble_before_disabled_event_handler() {
|
void BLEServer::ble_before_disabled_event_handler() {
|
||||||
|
// Advertising is re-requested once the server is running again after BLE is re-enabled
|
||||||
|
this->release_advertising_();
|
||||||
// Delete all clients
|
// Delete all clients
|
||||||
this->client_count_ = 0;
|
this->client_count_ = 0;
|
||||||
// Delete all services
|
// Delete all services
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ class BLEServer final : public Component, public Parented<ESP32BLE> {
|
|||||||
this->restart_advertising_();
|
this->restart_advertising_();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this server needs the device to advertise so clients can find and connect to it.
|
||||||
|
*
|
||||||
|
* False for a server that only hosts services created at runtime (e.g. esp32_improv), which
|
||||||
|
* request advertising themselves for as long as they need it.
|
||||||
|
*/
|
||||||
|
void set_advertising_required(bool required) { this->advertising_required_ = required; }
|
||||||
|
|
||||||
void set_max_clients(uint8_t max_clients) { this->max_clients_ = max_clients; }
|
void set_max_clients(uint8_t max_clients) { this->max_clients_ = max_clients; }
|
||||||
uint8_t get_max_clients() const { return this->max_clients_; }
|
uint8_t get_max_clients() const { return this->max_clients_; }
|
||||||
|
|
||||||
@@ -82,6 +89,8 @@ class BLEServer final : public Component, public Parented<ESP32BLE> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void restart_advertising_();
|
void restart_advertising_();
|
||||||
|
void request_advertising_();
|
||||||
|
void release_advertising_();
|
||||||
|
|
||||||
int8_t find_client_index_(uint16_t conn_id) const;
|
int8_t find_client_index_(uint16_t conn_id) const;
|
||||||
void add_client_(uint16_t conn_id);
|
void add_client_(uint16_t conn_id);
|
||||||
@@ -93,6 +102,8 @@ class BLEServer final : public Component, public Parented<ESP32BLE> {
|
|||||||
std::vector<uint8_t> manufacturer_data_{};
|
std::vector<uint8_t> manufacturer_data_{};
|
||||||
esp_gatt_if_t gatts_if_{0};
|
esp_gatt_if_t gatts_if_{0};
|
||||||
bool registered_{false};
|
bool registered_{false};
|
||||||
|
bool advertising_required_{true};
|
||||||
|
bool advertising_requested_{false};
|
||||||
|
|
||||||
uint16_t clients_[USE_ESP32_BLE_MAX_CONNECTIONS]{};
|
uint16_t clients_[USE_ESP32_BLE_MAX_CONNECTIONS]{};
|
||||||
uint8_t client_count_{0};
|
uint8_t client_count_{0};
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ CONF_HANDSHAKE_PIN = "handshake_pin"
|
|||||||
CONF_SDIO_FREQUENCY = "sdio_frequency"
|
CONF_SDIO_FREQUENCY = "sdio_frequency"
|
||||||
CONF_SPI_MODE = "spi_mode"
|
CONF_SPI_MODE = "spi_mode"
|
||||||
|
|
||||||
|
# ESP-NOW-over-hosted shim (esp_now_hosted.cpp). esp-hosted proxies esp_wifi.h
|
||||||
|
# but not esp_now.h (espressif/esp-hosted-mcu#19), and esp_wifi_remote injects
|
||||||
|
# the esp_now.h header on the ESP32-P4 host with no implementation, leaving the
|
||||||
|
# esp_now_* symbols undefined at link. On a P4 host, esp_now_hosted.cpp DEFINES
|
||||||
|
# those symbols and forwards each call to the co-processor over esp-hosted's
|
||||||
|
# CustomRpc "peer data transfer" channel, so ESPHome's `espnow` component links
|
||||||
|
# and runs unchanged (proven on a Tab5, 2026-07-20). The .cpp is guarded to
|
||||||
|
# CONFIG_IDF_TARGET_ESP32P4 so it compiles to nothing on hosts with a native
|
||||||
|
# ESP-NOW stack. CustomRpc needs these two host-side Kconfig options. Host
|
||||||
|
# registers 3 handlers (RESP, RECV, SEND); the coprocessor registers 1 (REQ);
|
||||||
|
# we ask for 8 to leave room for other CustomRpc extensions alongside.
|
||||||
|
#
|
||||||
|
# The coprocessor must run the matching custom firmware (a parallel effort in
|
||||||
|
# esphome/esp-hosted-firmware). esp_now_hosted_rpc.h here is the canonical copy
|
||||||
|
# of the wire contract and MUST stay byte-identical to the copy that coprocessor
|
||||||
|
# firmware uses — the packed structs are the on-wire layout, so any divergence
|
||||||
|
# silently corrupts every ESP-NOW frame.
|
||||||
|
_MAX_CUSTOM_MSG_HANDLERS = 8
|
||||||
|
|
||||||
# Shared fields for both transport modes
|
# Shared fields for both transport modes
|
||||||
BASE_SCHEMA = cv.Schema(
|
BASE_SCHEMA = cv.Schema(
|
||||||
{
|
{
|
||||||
@@ -262,6 +281,23 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
else:
|
else:
|
||||||
_configure_spi(config)
|
_configure_spi(config)
|
||||||
|
|
||||||
|
# ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see
|
||||||
|
# the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not
|
||||||
|
# gated on the `espnow` component being present: the shim is tiny and the
|
||||||
|
# esp_now_* symbols/CustomRpc calls it defines require these Kconfig options
|
||||||
|
# to link whenever esp_now_hosted.cpp compiles (which is on any P4 host), so
|
||||||
|
# coupling the two keeps the build consistent. When `espnow` is absent the
|
||||||
|
# symbols are simply unused and never register a callback at runtime.
|
||||||
|
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
|
||||||
|
add_define("USE_ESP_NOW_HOSTED")
|
||||||
|
# esp-hosted's CustomRpc ("peer data transfer") path — off by default.
|
||||||
|
esp32.add_idf_sdkconfig_option(
|
||||||
|
"CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True
|
||||||
|
)
|
||||||
|
esp32.add_idf_sdkconfig_option(
|
||||||
|
"CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS
|
||||||
|
)
|
||||||
|
|
||||||
# Place the transport mempool in PSRAM. Required on memory-tight host
|
# Place the transport mempool in PSRAM. Required on memory-tight host
|
||||||
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
|
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
|
||||||
# mempool allocation fails at boot with `sdio_mempool_create` assert.
|
# mempool allocation fails at boot with `sdio_mempool_create` assert.
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
/*
|
||||||
|
* esp_now_hosted — host-side shim implementing <esp_now.h> over esp-hosted
|
||||||
|
* CustomRpc, so ESPHome's `espnow` component can run on a radio-less host
|
||||||
|
* (e.g. the ESP32-P4) whose radio lives on an esp-hosted co-processor.
|
||||||
|
*
|
||||||
|
* A radio-less host has no native ESP-NOW. esp_wifi_remote INJECTS the full
|
||||||
|
* esp_now.h header (types + declarations) but ships NO implementation, so every
|
||||||
|
* esp_now_* symbol is an undefined reference at link time. This translation
|
||||||
|
* unit provides those definitions; each forwards to the co-processor over
|
||||||
|
* CustomRpc (see esphome/esp-hosted-firmware for the matching coprocessor
|
||||||
|
* handlers). No esp-hosted or esp_wifi_remote source is patched, and there is no
|
||||||
|
* duplicate-symbol clash because nothing else defines these symbols here.
|
||||||
|
*
|
||||||
|
* See esp_now_hosted_rpc.h for the wire protocol.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "sdkconfig.h"
|
||||||
|
|
||||||
|
// Only build the shim on the radio-less host. On chips with a native ESP-NOW
|
||||||
|
// stack (S3, C6, …) the real symbols exist and this file must stay empty to
|
||||||
|
// avoid duplicate definitions.
|
||||||
|
#if defined(CONFIG_IDF_TARGET_ESP32P4)
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/semphr.h"
|
||||||
|
|
||||||
|
#include "esp_idf_version.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
|
||||||
|
#include <esp_now.h> // injected declarations we are now DEFINING
|
||||||
|
#include <esp_wifi_types.h> // wifi_pkt_rx_ctrl_t, wifi_tx_info_t
|
||||||
|
|
||||||
|
// esp_hosted_misc.h (host) ships WITHOUT an extern "C" guard, so including it
|
||||||
|
// from C++ would give its declarations C++ linkage and the real C symbols in
|
||||||
|
// libesp_hosted would go unresolved at link. Wrap it. (Verified vs
|
||||||
|
// esp_hosted 2.12.9.)
|
||||||
|
extern "C" {
|
||||||
|
#include "esp_hosted_misc.h" // esp_hosted_{send_custom_data,register_custom_callback}
|
||||||
|
}
|
||||||
|
|
||||||
|
#include "esp_now_hosted_rpc.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const char *const TAG = "esp_now_hosted";
|
||||||
|
|
||||||
|
// One outstanding request at a time. ESPHome drives esp_now_* from the main
|
||||||
|
// loop; the matching response and the async RECV/SEND events all arrive on the
|
||||||
|
// single esp-hosted RPC RX thread. Serializing requests keeps the shared
|
||||||
|
// response slot race-free; a sequence number stops a late/stale response from
|
||||||
|
// being mistaken for ours.
|
||||||
|
SemaphoreHandle_t g_req_mutex = nullptr;
|
||||||
|
SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands
|
||||||
|
bool g_setup_done = false; // set only after setup fully succeeds
|
||||||
|
uint8_t g_seq = 0;
|
||||||
|
volatile uint8_t g_expect_seq = 0;
|
||||||
|
volatile int32_t g_resp_status = 0;
|
||||||
|
uint8_t g_resp_ret[16];
|
||||||
|
volatile uint16_t g_resp_ret_len = 0;
|
||||||
|
|
||||||
|
// Written from the main loop (register/unregister/deinit), read from the
|
||||||
|
// esp-hosted RX thread (on_recv/on_send). volatile for the same reason the
|
||||||
|
// g_resp_* globals are: force the RX thread to observe an updated pointer
|
||||||
|
// (e.g. a nulling by esp_now_deinit) rather than a cached one.
|
||||||
|
volatile esp_now_recv_cb_t g_recv_cb = nullptr;
|
||||||
|
volatile esp_now_send_cb_t g_send_cb = nullptr;
|
||||||
|
|
||||||
|
// Local mirror of the co-processor's peer table. ESPHome's espnow component
|
||||||
|
// calls esp_now_is_peer_exist() on the main loop for every received frame
|
||||||
|
// (twice) and every send; forwarding each as a blocking RPC round-trip stalls
|
||||||
|
// the loop. The shim is the only path that mutates the co-processor peer table
|
||||||
|
// (add/del/deinit all go through here), so this mirror is authoritative and
|
||||||
|
// esp_now_is_peer_exist() can answer from it with no round-trip.
|
||||||
|
//
|
||||||
|
// esp_now_* are public C symbols: any component or user lambda may call them,
|
||||||
|
// and although ESPHome's espnow touches peers only from the main loop today
|
||||||
|
// (its RX/TX callbacks merely enqueue), the shim cannot rely on that. A short
|
||||||
|
// spinlock keeps the mirror consistent from any task/core, matching native
|
||||||
|
// esp_now_*'s own internal thread-safety. The critical sections are a bounded
|
||||||
|
// (<=20-entry) scan, so they stay tiny. ESP_NOW_MAX_TOTAL_PEER_NUM is 20.
|
||||||
|
constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20;
|
||||||
|
uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6];
|
||||||
|
size_t g_peer_count = 0;
|
||||||
|
portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
|
|
||||||
|
// Caller must hold g_peer_lock.
|
||||||
|
int peer_cache_find_locked(const uint8_t *mac) {
|
||||||
|
for (size_t i = 0; i < g_peer_count; i++) {
|
||||||
|
if (memcmp(g_peer_cache[i], mac, 6) == 0)
|
||||||
|
return static_cast<int>(i);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool peer_cache_contains(const uint8_t *mac) {
|
||||||
|
portENTER_CRITICAL(&g_peer_lock);
|
||||||
|
const bool found = peer_cache_find_locked(mac) >= 0;
|
||||||
|
portEXIT_CRITICAL(&g_peer_lock);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
void peer_cache_add(const uint8_t *mac) {
|
||||||
|
portENTER_CRITICAL(&g_peer_lock);
|
||||||
|
if (peer_cache_find_locked(mac) < 0 && g_peer_count < ESP_NOW_HOSTED_MAX_PEERS)
|
||||||
|
memcpy(g_peer_cache[g_peer_count++], mac, 6);
|
||||||
|
portEXIT_CRITICAL(&g_peer_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
void peer_cache_remove(const uint8_t *mac) {
|
||||||
|
portENTER_CRITICAL(&g_peer_lock);
|
||||||
|
const int idx = peer_cache_find_locked(mac);
|
||||||
|
if (idx >= 0) {
|
||||||
|
g_peer_count--;
|
||||||
|
if (static_cast<size_t>(idx) != g_peer_count) // move the last entry into the gap
|
||||||
|
memcpy(g_peer_cache[idx], g_peer_cache[g_peer_count], 6);
|
||||||
|
}
|
||||||
|
portEXIT_CRITICAL(&g_peer_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
void peer_cache_clear() {
|
||||||
|
portENTER_CRITICAL(&g_peer_lock);
|
||||||
|
g_peer_count = 0;
|
||||||
|
portEXIT_CRITICAL(&g_peer_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CustomRpc event handlers (run on the esp-hosted RPC RX thread) ──────────
|
||||||
|
// Keep them short and non-blocking. In particular they MUST NOT call back into
|
||||||
|
// any esp_now_* shim function: that would try to take g_req_mutex / wait on the
|
||||||
|
// RX thread that delivers the response, and deadlock.
|
||||||
|
|
||||||
|
void on_resp(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) {
|
||||||
|
if (len < sizeof(esp_now_hosted_resp_t)) {
|
||||||
|
ESP_LOGW(TAG, "RESP too short: %u bytes", static_cast<unsigned>(len));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto *r = reinterpret_cast<const esp_now_hosted_resp_t *>(data);
|
||||||
|
if (r->seq != g_expect_seq) { // late response from a timed-out request (expected)
|
||||||
|
ESP_LOGV(TAG, "dropping stale RESP seq %u (want %u)", r->seq, g_expect_seq);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_resp_status = r->status;
|
||||||
|
uint16_t rl = r->ret_len;
|
||||||
|
if (rl > sizeof(g_resp_ret)) {
|
||||||
|
// Larger than any real opcode return — a likely wire-format drift signal.
|
||||||
|
ESP_LOGW(TAG, "RESP ret_len %u exceeds buffer, clamping (wire drift?)", rl);
|
||||||
|
rl = sizeof(g_resp_ret);
|
||||||
|
}
|
||||||
|
if (len >= sizeof(esp_now_hosted_resp_t) + rl) {
|
||||||
|
memcpy(g_resp_ret, r->ret, rl);
|
||||||
|
} else {
|
||||||
|
// Truncated frame: fail closed. Never hand the caller stale bytes left in
|
||||||
|
// g_resp_ret by a previous response, and don't let request() report a
|
||||||
|
// zeroed payload as success — override the status to an error.
|
||||||
|
ESP_LOGW(TAG, "RESP truncated: claims %u ret bytes, frame too short", rl);
|
||||||
|
rl = 0;
|
||||||
|
g_resp_status = ESP_ERR_INVALID_RESPONSE;
|
||||||
|
}
|
||||||
|
g_resp_ret_len = rl;
|
||||||
|
xSemaphoreGive(g_resp_sem);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_recv(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) {
|
||||||
|
// Read the volatile pointer once: esp_now_unregister_recv_cb()/deinit() (via
|
||||||
|
// the espnow component's disable()) can null it on the main loop between the
|
||||||
|
// guard and the call, which would otherwise turn the call into a null-deref.
|
||||||
|
const esp_now_recv_cb_t cb = g_recv_cb;
|
||||||
|
if (cb == nullptr)
|
||||||
|
return;
|
||||||
|
if (len < sizeof(esp_now_hosted_recv_evt_t)) {
|
||||||
|
ESP_LOGW(TAG, "RECV too short: %u bytes", static_cast<unsigned>(len));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto *e = reinterpret_cast<const esp_now_hosted_recv_evt_t *>(data);
|
||||||
|
if (len < sizeof(esp_now_hosted_recv_evt_t) + e->data_len) {
|
||||||
|
ESP_LOGW(TAG, "RECV data_len %u exceeds frame", e->data_len);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESPHome dereferences info->rx_ctrl->{rssi,timestamp}; give it a real one.
|
||||||
|
wifi_pkt_rx_ctrl_t rx_ctrl;
|
||||||
|
memset(&rx_ctrl, 0, sizeof(rx_ctrl));
|
||||||
|
rx_ctrl.rssi = e->rssi;
|
||||||
|
rx_ctrl.channel = e->channel;
|
||||||
|
rx_ctrl.timestamp = static_cast<uint32_t>(esp_timer_get_time());
|
||||||
|
|
||||||
|
esp_now_recv_info_t info;
|
||||||
|
info.src_addr = const_cast<uint8_t *>(e->src_addr);
|
||||||
|
info.des_addr = const_cast<uint8_t *>(e->des_addr);
|
||||||
|
info.rx_ctrl = &rx_ctrl;
|
||||||
|
cb(&info, e->data, static_cast<int>(e->data_len));
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_send(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) {
|
||||||
|
// Read the volatile pointer once (see on_recv): disable()/deinit() can null it
|
||||||
|
// on the main loop concurrently with this RX-thread callback.
|
||||||
|
const esp_now_send_cb_t cb = g_send_cb;
|
||||||
|
if (cb == nullptr)
|
||||||
|
return;
|
||||||
|
if (len < sizeof(esp_now_hosted_send_evt_t)) {
|
||||||
|
ESP_LOGW(TAG, "SEND evt too short: %u bytes", static_cast<unsigned>(len));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto *e = reinterpret_cast<const esp_now_hosted_send_evt_t *>(data);
|
||||||
|
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
|
||||||
|
// IDF >= 5.5: esp_now_send_cb_t takes esp_now_send_info_t (== wifi_tx_info_t),
|
||||||
|
// whose des_addr is a POINTER (not an inline array). Point it at the event's
|
||||||
|
// MAC (valid for this callback) — do NOT memcpy into it (that writes NULL and
|
||||||
|
// faults). ESPHome reads only info->des_addr.
|
||||||
|
esp_now_send_info_t si;
|
||||||
|
memset(&si, 0, sizeof(si));
|
||||||
|
si.des_addr = const_cast<uint8_t *>(e->des_addr);
|
||||||
|
cb(&si, static_cast<esp_now_send_status_t>(e->status));
|
||||||
|
#else
|
||||||
|
cb(e->des_addr, static_cast<esp_now_send_status_t>(e->status));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ensure_setup() {
|
||||||
|
// Gate on g_setup_done, not on g_req_mutex: a failure part-way through (a
|
||||||
|
// semaphore that did not allocate, a callback that did not register) must not
|
||||||
|
// leave a later call thinking setup completed. Semaphore creation is guarded
|
||||||
|
// so a retry after a partial failure does not leak the earlier handles.
|
||||||
|
if (g_setup_done)
|
||||||
|
return ESP_OK;
|
||||||
|
if (g_req_mutex == nullptr)
|
||||||
|
g_req_mutex = xSemaphoreCreateMutex();
|
||||||
|
if (g_resp_sem == nullptr)
|
||||||
|
g_resp_sem = xSemaphoreCreateBinary();
|
||||||
|
if (g_req_mutex == nullptr || g_resp_sem == nullptr)
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
esp_err_t err;
|
||||||
|
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK)
|
||||||
|
return err;
|
||||||
|
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK)
|
||||||
|
return err;
|
||||||
|
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK)
|
||||||
|
return err;
|
||||||
|
g_setup_done = true;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send one request envelope. With wait=true (default) block until the matching
|
||||||
|
// response (or timeout); with wait=false return as soon as the frame is handed
|
||||||
|
// to the transport (fire-and-forget, used by esp_now_send).
|
||||||
|
//
|
||||||
|
// `tail` is an optional second chunk written straight after `payload`. Callers
|
||||||
|
// with a fixed header plus a bulk body (esp_now_send) pass the two separately
|
||||||
|
// so they never need a build buffer of their own: both chunks are laid into the
|
||||||
|
// request buffer here, under g_req_mutex, which keeps concurrent callers from
|
||||||
|
// racing and saves a full copy of the body on every transmit.
|
||||||
|
esp_err_t request(uint8_t opcode, const void *payload, uint16_t plen, void *ret, uint16_t ret_cap, uint16_t *ret_len,
|
||||||
|
bool wait = true, const void *tail = nullptr, uint16_t tail_len = 0) {
|
||||||
|
esp_err_t err = ensure_setup();
|
||||||
|
if (err != ESP_OK)
|
||||||
|
return err;
|
||||||
|
if (plen > ESP_NOW_HOSTED_MAX_PAYLOAD || tail_len > ESP_NOW_HOSTED_MAX_PAYLOAD - plen)
|
||||||
|
return ESP_ERR_INVALID_SIZE;
|
||||||
|
const uint16_t total_len = static_cast<uint16_t>(plen + tail_len);
|
||||||
|
|
||||||
|
if (xSemaphoreTake(g_req_mutex, portMAX_DELAY) != pdTRUE)
|
||||||
|
return ESP_FAIL;
|
||||||
|
|
||||||
|
static uint8_t buf[sizeof(esp_now_hosted_req_t) + ESP_NOW_HOSTED_MAX_PAYLOAD]; // guarded by g_req_mutex
|
||||||
|
auto *req = reinterpret_cast<esp_now_hosted_req_t *>(buf);
|
||||||
|
req->opcode = opcode;
|
||||||
|
req->seq = ++g_seq;
|
||||||
|
req->payload_len = total_len;
|
||||||
|
if (plen != 0)
|
||||||
|
memcpy(req->payload, payload, plen);
|
||||||
|
if (tail_len != 0)
|
||||||
|
memcpy(req->payload + plen, tail, tail_len);
|
||||||
|
g_expect_seq = req->seq;
|
||||||
|
|
||||||
|
xSemaphoreTake(g_resp_sem, 0); // drain any stale signal before sending
|
||||||
|
err = esp_hosted_send_custom_data(ESP_NOW_HOSTED_MSG_REQ, buf, sizeof(esp_now_hosted_req_t) + total_len);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
xSemaphoreGive(g_req_mutex);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
if (!wait) {
|
||||||
|
// Fire-and-forget (esp_now_send): the co-processor enqueues the frame and
|
||||||
|
// reports the real TX result later via the async SEND event, exactly like
|
||||||
|
// native esp_now_send. Returning here keeps the main loop off the ~100 ms+
|
||||||
|
// RPC round-trip. The matching RESP is ignored (seq won't match the next
|
||||||
|
// waited request, so on_resp drops it).
|
||||||
|
xSemaphoreGive(g_req_mutex);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
if (xSemaphoreTake(g_resp_sem, pdMS_TO_TICKS(ESP_NOW_HOSTED_TIMEOUT_MS)) != pdTRUE) {
|
||||||
|
ESP_LOGW(TAG, "opcode %u timed out", opcode);
|
||||||
|
xSemaphoreGive(g_req_mutex);
|
||||||
|
return ESP_ERR_TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32_t status = g_resp_status;
|
||||||
|
if (ret != nullptr && ret_cap != 0) {
|
||||||
|
uint16_t n = g_resp_ret_len < ret_cap ? g_resp_ret_len : ret_cap;
|
||||||
|
memcpy(ret, const_cast<const uint8_t *>(g_resp_ret), n);
|
||||||
|
if (ret_len != nullptr)
|
||||||
|
*ret_len = n;
|
||||||
|
}
|
||||||
|
xSemaphoreGive(g_req_mutex);
|
||||||
|
return static_cast<esp_err_t>(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ── The <esp_now.h> surface, defined for the radio-less host ────────────────
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
esp_err_t esp_now_init(void) { return request(ESP_NOW_HOSTED_OP_INIT, nullptr, 0, nullptr, 0, nullptr); }
|
||||||
|
|
||||||
|
esp_err_t esp_now_deinit(void) {
|
||||||
|
g_recv_cb = nullptr;
|
||||||
|
g_send_cb = nullptr;
|
||||||
|
peer_cache_clear(); // the co-processor drops all peers on deinit
|
||||||
|
return request(ESP_NOW_HOSTED_OP_DEINIT, nullptr, 0, nullptr, 0, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t esp_now_get_version(uint32_t *version) {
|
||||||
|
uint32_t v = 0;
|
||||||
|
uint16_t rl = 0;
|
||||||
|
esp_err_t err = request(ESP_NOW_HOSTED_OP_GET_VERSION, nullptr, 0, &v, sizeof(v), &rl);
|
||||||
|
if (version != nullptr)
|
||||||
|
*version = v;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t esp_now_register_recv_cb(esp_now_recv_cb_t cb) {
|
||||||
|
// Only arm the callback once the CustomRpc handlers are actually registered,
|
||||||
|
// so a failed setup leaves g_recv_cb null rather than falsely "registered".
|
||||||
|
esp_err_t err = ensure_setup();
|
||||||
|
if (err != ESP_OK)
|
||||||
|
return err;
|
||||||
|
g_recv_cb = cb;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t esp_now_unregister_recv_cb(void) {
|
||||||
|
g_recv_cb = nullptr;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t esp_now_register_send_cb(esp_now_send_cb_t cb) {
|
||||||
|
esp_err_t err = ensure_setup();
|
||||||
|
if (err != ESP_OK)
|
||||||
|
return err;
|
||||||
|
g_send_cb = cb;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t esp_now_unregister_send_cb(void) {
|
||||||
|
g_send_cb = nullptr;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t add_or_mod_peer(uint8_t opcode, const esp_now_peer_info_t *peer, bool wait) {
|
||||||
|
if (peer == nullptr)
|
||||||
|
return ESP_ERR_ESPNOW_ARG;
|
||||||
|
esp_now_hosted_peer_t p;
|
||||||
|
memset(&p, 0, sizeof(p));
|
||||||
|
memcpy(p.peer_addr, peer->peer_addr, 6);
|
||||||
|
memcpy(p.lmk, peer->lmk, 16);
|
||||||
|
p.channel = peer->channel;
|
||||||
|
p.ifidx = static_cast<uint8_t>(peer->ifidx);
|
||||||
|
p.encrypt = peer->encrypt ? 1 : 0;
|
||||||
|
return request(opcode, &p, sizeof(p), nullptr, 0, nullptr, wait);
|
||||||
|
}
|
||||||
|
esp_err_t esp_now_add_peer(const esp_now_peer_info_t *peer) {
|
||||||
|
// Fire-and-forget (wait=false): adding a peer is a blocking RPC round-trip,
|
||||||
|
// and ESPHome's espnow calls it on the main loop when a device joins the mesh
|
||||||
|
// — under co-processor load that stalls the UI (peer-churn stutter). Issue it
|
||||||
|
// without waiting and mirror it locally. Safe against a following
|
||||||
|
// esp_now_send to the same peer: both ride the same in-order CustomRpc
|
||||||
|
// channel (mutex-serialized on the host) and the co-processor processes REQs
|
||||||
|
// FIFO, so ADD_PEER is applied before the SEND. Trade-off: a co-processor-side
|
||||||
|
// failure (e.g. peer table full) is no longer reported synchronously — the
|
||||||
|
// same limitation as esp_now_send — but ESPHome only adds peers it validated.
|
||||||
|
esp_err_t err = add_or_mod_peer(ESP_NOW_HOSTED_OP_ADD_PEER, peer, /*wait=*/false);
|
||||||
|
if (err == ESP_OK)
|
||||||
|
peer_cache_add(peer->peer_addr); // keep the local mirror in sync
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
esp_err_t esp_now_mod_peer(const esp_now_peer_info_t *peer) {
|
||||||
|
// mod_peer changes a peer's parameters, not its existence, so the cache is
|
||||||
|
// unaffected. Kept synchronous — it is not on any hot path (espnow never
|
||||||
|
// calls it), so the extra round-trip does not matter and the status is useful.
|
||||||
|
return add_or_mod_peer(ESP_NOW_HOSTED_OP_MOD_PEER, peer, /*wait=*/true);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t esp_now_del_peer(const uint8_t *peer_addr) {
|
||||||
|
if (peer_addr == nullptr)
|
||||||
|
return ESP_ERR_ESPNOW_ARG;
|
||||||
|
// Fire-and-forget for the same reason as add_peer (peer churn on the main
|
||||||
|
// loop). Removal is order-independent, so this is strictly safe.
|
||||||
|
esp_err_t err = request(ESP_NOW_HOSTED_OP_DEL_PEER, peer_addr, 6, nullptr, 0, nullptr, /*wait=*/false);
|
||||||
|
if (err == ESP_OK)
|
||||||
|
peer_cache_remove(peer_addr); // keep the local mirror in sync
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool esp_now_is_peer_exist(const uint8_t *peer_addr) {
|
||||||
|
if (peer_addr == nullptr)
|
||||||
|
return false;
|
||||||
|
// Answered from the local mirror — no RPC round-trip. ESPHome's espnow calls
|
||||||
|
// this on the main loop for every received frame and every send, so a
|
||||||
|
// blocking round-trip here would stall rendering under mesh traffic.
|
||||||
|
return peer_cache_contains(peer_addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len) {
|
||||||
|
if (len > ESP_NOW_HOSTED_MAX_FRAME)
|
||||||
|
return ESP_ERR_ESPNOW_ARG;
|
||||||
|
if (data == nullptr && len != 0) // native esp_now_send treats this as an arg error
|
||||||
|
return ESP_ERR_ESPNOW_ARG;
|
||||||
|
// Only the small fixed header is built here; the caller's frame goes over as
|
||||||
|
// the request tail, so request() lays both into its own buffer under
|
||||||
|
// g_req_mutex. esp_now_send is a public C symbol and may be called from any
|
||||||
|
// task, and a shared build buffer here would let two callers corrupt each
|
||||||
|
// other's frame. Passing the body through also drops a full-frame copy per
|
||||||
|
// transmit, on the path this shim exists to keep quick.
|
||||||
|
uint8_t hdr[sizeof(esp_now_hosted_send_req_t)];
|
||||||
|
auto *s = reinterpret_cast<esp_now_hosted_send_req_t *>(hdr);
|
||||||
|
s->has_addr = peer_addr != nullptr ? 1 : 0;
|
||||||
|
if (peer_addr != nullptr)
|
||||||
|
memcpy(s->peer_addr, peer_addr, 6);
|
||||||
|
else
|
||||||
|
memset(s->peer_addr, 0, 6);
|
||||||
|
s->data_len = static_cast<uint16_t>(len);
|
||||||
|
// Fire-and-forget (wait=false): native esp_now_send returns once the frame is
|
||||||
|
// queued, with the real TX result delivered later through the send callback.
|
||||||
|
// The co-processor mirrors that — it acks enqueue immediately and reports the
|
||||||
|
// outcome via the async SEND event (on_send -> on_send_report). Waiting for
|
||||||
|
// the RPC RESP here would block the main loop for the full round-trip on
|
||||||
|
// every transmit.
|
||||||
|
return request(ESP_NOW_HOSTED_OP_SEND, hdr, sizeof(hdr), nullptr, 0, nullptr, /*wait=*/false, data,
|
||||||
|
static_cast<uint16_t>(len));
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t esp_now_set_pmk(const uint8_t *pmk) {
|
||||||
|
if (pmk == nullptr)
|
||||||
|
return ESP_ERR_ESPNOW_ARG;
|
||||||
|
return request(ESP_NOW_HOSTED_OP_SET_PMK, pmk, 16, nullptr, 0, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remainder of the <esp_now.h> surface. Not used by ESPHome's espnow component
|
||||||
|
// today; provided so the whole header links and future callers get a defined
|
||||||
|
// (if unimplemented) symbol rather than a link error. Wire them through
|
||||||
|
// CustomRpc if a use case appears.
|
||||||
|
esp_err_t esp_now_get_peer(const uint8_t * /*peer_addr*/, esp_now_peer_info_t * /*peer*/) {
|
||||||
|
return ESP_ERR_NOT_SUPPORTED;
|
||||||
|
}
|
||||||
|
esp_err_t esp_now_fetch_peer(bool /*from_head*/, esp_now_peer_info_t * /*peer*/) { return ESP_ERR_NOT_SUPPORTED; }
|
||||||
|
esp_err_t esp_now_get_peer_num(esp_now_peer_num_t * /*num*/) { return ESP_ERR_NOT_SUPPORTED; }
|
||||||
|
esp_err_t esp_now_set_wake_window(uint16_t /*window*/) {
|
||||||
|
return ESP_ERR_NOT_SUPPORTED; // power-save wake window is not forwarded; don't claim success
|
||||||
|
}
|
||||||
|
esp_err_t esp_now_set_peer_rate_config(const uint8_t * /*peer_addr*/, esp_now_rate_config_t * /*cfg*/) {
|
||||||
|
return ESP_ERR_NOT_SUPPORTED;
|
||||||
|
}
|
||||||
|
esp_err_t esp_wifi_config_espnow_rate(wifi_interface_t /*ifx*/, wifi_phy_rate_t /*rate*/) {
|
||||||
|
return ESP_ERR_NOT_SUPPORTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // extern "C"
|
||||||
|
|
||||||
|
#endif // CONFIG_IDF_TARGET_ESP32P4
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
* esp_now_hosted — ESP-NOW-over-CustomRpc wire protocol.
|
||||||
|
*
|
||||||
|
* Shared, byte-for-byte-identical contract between:
|
||||||
|
* - the host shim (esphome/components/esp32_hosted/esp_now_hosted.cpp)
|
||||||
|
* - the coprocessor firmware (esphome/esp-hosted-firmware)
|
||||||
|
*
|
||||||
|
* It rides esp-hosted's CustomRpc channel (RPC ID 388, "peer data transfer",
|
||||||
|
* available since esp-hosted v2.8.1), teaching the radio-less host <-> radio
|
||||||
|
* co-processor link to carry esp_now.h, which esp-hosted itself does not proxy
|
||||||
|
* (Espressif issue espressif/esp-hosted-mcu#19).
|
||||||
|
*
|
||||||
|
* KEEP THE TWO COPIES IN SYNC. The canonical copy lives here; the coprocessor
|
||||||
|
* firmware uses a verbatim copy. Both sides are little-endian, so these packed
|
||||||
|
* structs are wire-compatible with no byte-swapping.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef ESP_NOW_HOSTED_RPC_H
|
||||||
|
#define ESP_NOW_HOSTED_RPC_H
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
#include <cstdint>
|
||||||
|
#else
|
||||||
|
#include <stdint.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ── CustomRpc message IDs (any uint32_t except 0xFFFFFFFF) ──────────────────
|
||||||
|
* One REQ handler slot on the device; three event handler slots on the host.
|
||||||
|
* The bytes spell "now" + index, a private range unlikely to clash with other
|
||||||
|
* CustomRpc users (e.g. the stock peer_data_transfer example's 1..6). */
|
||||||
|
#define ESP_NOW_HOSTED_MSG_REQ 0x6E6F7701u /* host -> device : request envelope */
|
||||||
|
#define ESP_NOW_HOSTED_MSG_RESP 0x6E6F7702u /* device -> host : reply to a REQ */
|
||||||
|
#define ESP_NOW_HOSTED_MSG_RECV 0x6E6F7703u /* device -> host : async RX frame */
|
||||||
|
#define ESP_NOW_HOSTED_MSG_SEND 0x6E6F7704u /* device -> host : async TX status */
|
||||||
|
|
||||||
|
/* ── Request opcodes ────────────────────────────────────────────────────── */
|
||||||
|
enum {
|
||||||
|
ESP_NOW_HOSTED_OP_INIT = 1, /* esp_now_init + register device recv/send cbs */
|
||||||
|
ESP_NOW_HOSTED_OP_DEINIT = 2, /* unregister cbs + esp_now_deinit */
|
||||||
|
ESP_NOW_HOSTED_OP_ADD_PEER = 3, /* payload: esp_now_hosted_peer_t */
|
||||||
|
ESP_NOW_HOSTED_OP_DEL_PEER = 4, /* payload: 6-byte peer MAC */
|
||||||
|
ESP_NOW_HOSTED_OP_IS_PEER_EXIST = 5, /* payload: 6-byte MAC; ret: 1 byte bool */
|
||||||
|
ESP_NOW_HOSTED_OP_SEND = 6, /* payload: esp_now_hosted_send_req_t */
|
||||||
|
ESP_NOW_HOSTED_OP_GET_VERSION = 7, /* ret: uint32 version */
|
||||||
|
ESP_NOW_HOSTED_OP_SET_PMK = 8, /* payload: 16-byte PMK */
|
||||||
|
ESP_NOW_HOSTED_OP_MOD_PEER = 9, /* payload: esp_now_hosted_peer_t */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Largest ESP-NOW payload we forward. ESP-NOW v2 (IDF >= 5.4) is 1470 B; well
|
||||||
|
* under esp-hosted's 8166 B CustomRpc cap, so the shim never truncates. */
|
||||||
|
#define ESP_NOW_HOSTED_MAX_FRAME 1470u
|
||||||
|
/* Envelope slack for the largest opcode payload (a SEND req wrapping a frame). */
|
||||||
|
#define ESP_NOW_HOSTED_MAX_PAYLOAD (ESP_NOW_HOSTED_MAX_FRAME + 16u)
|
||||||
|
/* Host request/response round-trip timeout over the transport. Generous:
|
||||||
|
* normal RTT is sub-millisecond, but Wi-Fi/BLE contention on the co-processor
|
||||||
|
* can stall the RX thread. */
|
||||||
|
#define ESP_NOW_HOSTED_TIMEOUT_MS 2000
|
||||||
|
|
||||||
|
/* ── Envelopes ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* These payloads are shared verbatim with the C co-processor firmware, so they
|
||||||
|
* use C's `typedef struct {...} name;` idiom rather than C++ `using` aliases,
|
||||||
|
* which would not compile there. Silence clang-tidy's modernize-use-using for
|
||||||
|
* the shared struct block. */
|
||||||
|
// NOLINTBEGIN(modernize-use-using)
|
||||||
|
typedef struct {
|
||||||
|
uint8_t opcode; /* one of ESP_NOW_HOSTED_OP_* */
|
||||||
|
uint8_t seq; /* wraps 0..255; echoed in the response for matching */
|
||||||
|
uint16_t payload_len; /* bytes of opcode-specific payload that follow */
|
||||||
|
uint8_t payload[]; /* flexible */
|
||||||
|
} __attribute__((packed)) esp_now_hosted_req_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t opcode; /* echoes the request opcode */
|
||||||
|
uint8_t seq; /* echoes the request seq */
|
||||||
|
int32_t status; /* esp_err_t from the native call on the co-processor */
|
||||||
|
uint16_t ret_len; /* bytes of return payload that follow */
|
||||||
|
uint8_t ret[]; /* flexible (e.g. version u32, is_peer_exist bool) */
|
||||||
|
} __attribute__((packed)) esp_now_hosted_resp_t;
|
||||||
|
|
||||||
|
/* ── Opcode payloads ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* esp_now_peer_info_t minus the host-only `priv` pointer, which is meaningless
|
||||||
|
* across the transport and never set by ESPHome's espnow component. */
|
||||||
|
typedef struct {
|
||||||
|
uint8_t peer_addr[6];
|
||||||
|
uint8_t lmk[16];
|
||||||
|
uint8_t channel; /* 0 = current channel */
|
||||||
|
uint8_t ifidx; /* wifi_interface_t (0=STA, 1=AP) */
|
||||||
|
uint8_t encrypt; /* bool */
|
||||||
|
} __attribute__((packed)) esp_now_hosted_peer_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t has_addr; /* 0 => peer_addr is NULL (broadcast to all peers) */
|
||||||
|
uint8_t peer_addr[6];
|
||||||
|
uint16_t data_len;
|
||||||
|
uint8_t data[]; /* flexible, up to ESP_NOW_HOSTED_MAX_FRAME */
|
||||||
|
} __attribute__((packed)) esp_now_hosted_send_req_t;
|
||||||
|
|
||||||
|
/* ── Async events (device -> host) ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Reconstructed on the host into an esp_now_recv_info_t + a minimal
|
||||||
|
* wifi_pkt_rx_ctrl_t. ESPHome's espnow reads info->src_addr, info->des_addr,
|
||||||
|
* info->rx_ctrl->rssi and info->rx_ctrl->timestamp. */
|
||||||
|
typedef struct {
|
||||||
|
uint8_t src_addr[6];
|
||||||
|
uint8_t des_addr[6];
|
||||||
|
int8_t rssi;
|
||||||
|
uint8_t channel;
|
||||||
|
uint16_t data_len;
|
||||||
|
uint8_t data[]; /* flexible */
|
||||||
|
} __attribute__((packed)) esp_now_hosted_recv_evt_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t des_addr[6];
|
||||||
|
uint8_t status; /* esp_now_send_status_t (0 = success) */
|
||||||
|
} __attribute__((packed)) esp_now_hosted_send_evt_t;
|
||||||
|
// NOLINTEND(modernize-use-using)
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* ESP_NOW_HOSTED_RPC_H */
|
||||||
@@ -112,6 +112,7 @@ void ESP32ImprovComponent::loop() {
|
|||||||
this->state_callback_.call(this->state_, this->error_state_);
|
this->state_callback_.call(this->state_, this->error_state_);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
this->release_advertising_();
|
||||||
this->incoming_data_.clear();
|
this->incoming_data_.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,8 +144,9 @@ void ESP32ImprovComponent::loop() {
|
|||||||
ESP_LOGV(TAG, "Starting with device name advertising");
|
ESP_LOGV(TAG, "Starting with device name advertising");
|
||||||
this->advertising_device_name_ = true;
|
this->advertising_device_name_ = true;
|
||||||
this->last_name_adv_time_ = App.get_loop_component_start_time();
|
this->last_name_adv_time_ = App.get_loop_component_start_time();
|
||||||
|
// Set the payload before requesting, so advertising starts exactly once
|
||||||
esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>{}, true);
|
esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>{}, true);
|
||||||
esp32_ble::global_ble->advertising_start();
|
this->request_advertising_();
|
||||||
|
|
||||||
// Set initial state based on whether we have an authorizer
|
// Set initial state based on whether we have an authorizer
|
||||||
this->set_state_(this->get_initial_state_(), false);
|
this->set_state_(this->get_initial_state_(), false);
|
||||||
@@ -326,6 +328,8 @@ void ESP32ImprovComponent::stop() {
|
|||||||
this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] {
|
this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] {
|
||||||
if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr)
|
if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr)
|
||||||
return;
|
return;
|
||||||
|
// Release first so removing the service UUID does not restart advertising on the way out
|
||||||
|
this->release_advertising_();
|
||||||
this->service_->stop();
|
this->service_->stop();
|
||||||
this->set_state_(improv::STATE_STOPPED);
|
this->set_state_(improv::STATE_STOPPED);
|
||||||
});
|
});
|
||||||
@@ -520,6 +524,20 @@ void ESP32ImprovComponent::update_advertising_type_() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ESP32ImprovComponent::request_advertising_() {
|
||||||
|
if (this->advertising_requested_)
|
||||||
|
return;
|
||||||
|
this->advertising_requested_ = true;
|
||||||
|
esp32_ble::global_ble->advertising_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ESP32ImprovComponent::release_advertising_() {
|
||||||
|
if (!this->advertising_requested_)
|
||||||
|
return;
|
||||||
|
this->advertising_requested_ = false;
|
||||||
|
esp32_ble::global_ble->advertising_stop();
|
||||||
|
}
|
||||||
|
|
||||||
improv::State ESP32ImprovComponent::get_initial_state_() const {
|
improv::State ESP32ImprovComponent::get_initial_state_() const {
|
||||||
#ifdef USE_BINARY_SENSOR
|
#ifdef USE_BINARY_SENSOR
|
||||||
// If we have an authorizer, start in awaiting authorization state
|
// If we have an authorizer, start in awaiting authorization state
|
||||||
|
|||||||
@@ -104,8 +104,11 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB
|
|||||||
bool status_indicator_state_{false};
|
bool status_indicator_state_{false};
|
||||||
uint32_t last_name_adv_time_{0};
|
uint32_t last_name_adv_time_{0};
|
||||||
bool advertising_device_name_{false};
|
bool advertising_device_name_{false};
|
||||||
|
bool advertising_requested_{false};
|
||||||
void set_status_indicator_state_(bool state);
|
void set_status_indicator_state_(bool state);
|
||||||
void update_advertising_type_();
|
void update_advertising_type_();
|
||||||
|
void request_advertising_();
|
||||||
|
void release_advertising_();
|
||||||
|
|
||||||
void set_state_(improv::State state, bool update_advertising = true);
|
void set_state_(improv::State state, bool update_advertising = true);
|
||||||
void set_error_(improv::Error error);
|
void set_error_(improv::Error error);
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ from pathlib import Path
|
|||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from esphome.build_helpers.pch import pch_extra_scripts
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -16,7 +14,6 @@ from esphome.const import (
|
|||||||
CONF_FRAMEWORK,
|
CONF_FRAMEWORK,
|
||||||
CONF_PLATFORM_VERSION,
|
CONF_PLATFORM_VERSION,
|
||||||
CONF_SOURCE,
|
CONF_SOURCE,
|
||||||
CONF_TOOLCHAIN,
|
|
||||||
CONF_VERSION,
|
CONF_VERSION,
|
||||||
KEY_CORE,
|
KEY_CORE,
|
||||||
KEY_FRAMEWORK_VERSION,
|
KEY_FRAMEWORK_VERSION,
|
||||||
@@ -24,7 +21,6 @@ from esphome.const import (
|
|||||||
KEY_TARGET_PLATFORM,
|
KEY_TARGET_PLATFORM,
|
||||||
PLATFORM_ESP8266,
|
PLATFORM_ESP8266,
|
||||||
ThreadModel,
|
ThreadModel,
|
||||||
Toolchain,
|
|
||||||
)
|
)
|
||||||
from esphome.core import (
|
from esphome.core import (
|
||||||
CORE,
|
CORE,
|
||||||
@@ -35,13 +31,12 @@ from esphome.core import (
|
|||||||
)
|
)
|
||||||
from esphome.core.config import BOARD_MAX_LENGTH
|
from esphome.core.config import BOARD_MAX_LENGTH
|
||||||
from esphome.helpers import IS_MACOS, copy_file_if_changed
|
from esphome.helpers import IS_MACOS, copy_file_if_changed
|
||||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
from esphome.platformio.toolchain import copy_ccache_script
|
||||||
from esphome.storage_json import StorageJSON
|
from esphome.storage_json import StorageJSON
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .boards import BOARDS, ESP8266_BOARD_BUILD, board_ld_script
|
from .boards import BOARDS, board_ld_script
|
||||||
from .const import (
|
from .const import (
|
||||||
BUILD_FLASH_MODES,
|
|
||||||
CONF_EARLY_PIN_INIT,
|
CONF_EARLY_PIN_INIT,
|
||||||
CONF_ENABLE_SERIAL,
|
CONF_ENABLE_SERIAL,
|
||||||
CONF_ENABLE_SERIAL1,
|
CONF_ENABLE_SERIAL1,
|
||||||
@@ -49,7 +44,6 @@ from .const import (
|
|||||||
KEY_BOARD,
|
KEY_BOARD,
|
||||||
KEY_ESP8266,
|
KEY_ESP8266,
|
||||||
KEY_PIN_INITIAL_STATES,
|
KEY_PIN_INITIAL_STATES,
|
||||||
KEY_SCANF_FLOAT,
|
|
||||||
KEY_SERIAL1_REQUIRED,
|
KEY_SERIAL1_REQUIRED,
|
||||||
KEY_SERIAL_REQUIRED,
|
KEY_SERIAL_REQUIRED,
|
||||||
KEY_WAVEFORM_REQUIRED,
|
KEY_WAVEFORM_REQUIRED,
|
||||||
@@ -109,53 +103,6 @@ def set_core_data(config: ConfigType) -> ConfigType:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO)
|
|
||||||
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
|
||||||
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.PLATFORMIO)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_native_toolchain(config: ConfigType) -> ConfigType:
|
|
||||||
"""Constraints of the native (non-PlatformIO) Arduino toolchain."""
|
|
||||||
if not CORE.using_toolchain_arduino:
|
|
||||||
return config
|
|
||||||
from esphome.arduino8266.framework import MIN_FRAMEWORK_VERSION
|
|
||||||
|
|
||||||
conf = config[CONF_FRAMEWORK]
|
|
||||||
version = cv.Version.parse(conf[CONF_VERSION])
|
|
||||||
if version < MIN_FRAMEWORK_VERSION:
|
|
||||||
raise cv.Invalid(
|
|
||||||
"'toolchain: arduino' requires framework version "
|
|
||||||
f"{MIN_FRAMEWORK_VERSION} or newer"
|
|
||||||
)
|
|
||||||
# platform_version is a PlatformIO concept; drop it (as esp32's native
|
|
||||||
# toolchain does), warning when a custom pin is discarded. The floor
|
|
||||||
# above guarantees the schema-derived default is the ARDUINO_4 spec.
|
|
||||||
if (
|
|
||||||
conf.pop(CONF_PLATFORM_VERSION, _ARDUINO_4_PLATFORM_SPEC)
|
|
||||||
!= _ARDUINO_4_PLATFORM_SPEC
|
|
||||||
):
|
|
||||||
_LOGGER.warning(
|
|
||||||
"'platform_version' is ignored by 'toolchain: arduino'; the native "
|
|
||||||
"toolchain downloads the framework and compiler directly"
|
|
||||||
)
|
|
||||||
if conf[CONF_SOURCE] != _format_framework_arduino_version(version):
|
|
||||||
raise cv.Invalid(
|
|
||||||
"'toolchain: arduino' does not support a custom framework source; "
|
|
||||||
"use 'toolchain: platformio'"
|
|
||||||
)
|
|
||||||
# BOARDS is a subset of ESP8266_BOARD_BUILD today; the second clause is
|
|
||||||
# a drift guard for the independently regenerated tables
|
|
||||||
if (
|
|
||||||
config[CONF_BOARD] not in BOARDS
|
|
||||||
or config[CONF_BOARD] not in ESP8266_BOARD_BUILD
|
|
||||||
):
|
|
||||||
raise cv.Invalid(
|
|
||||||
f"Board '{config[CONF_BOARD]}' is not supported by "
|
|
||||||
"'toolchain: arduino'; use 'toolchain: platformio'"
|
|
||||||
)
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
|
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
|
||||||
"""Binary-download entries for a built ESP8266 firmware.
|
"""Binary-download entries for a built ESP8266 firmware.
|
||||||
|
|
||||||
@@ -185,7 +132,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
|
|||||||
# a PIO platformio/framework-arduinoespressif8266 value
|
# a PIO platformio/framework-arduinoespressif8266 value
|
||||||
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
|
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
|
||||||
# Same encoding the native toolchain uses for its package download, so a
|
# Same encoding the native toolchain uses for its package download, so a
|
||||||
# custom-source check against this value cannot drift from what it fetches.
|
# version bump cannot drift between the two paths.
|
||||||
from esphome.arduino8266.framework import framework_package_version
|
from esphome.arduino8266.framework import framework_package_version
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -247,7 +194,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType:
|
|||||||
platform_version = value.get(CONF_PLATFORM_VERSION)
|
platform_version = value.get(CONF_PLATFORM_VERSION)
|
||||||
if platform_version is None:
|
if platform_version is None:
|
||||||
if version >= cv.Version(3, 1, 0):
|
if version >= cv.Version(3, 1, 0):
|
||||||
platform_version = _ARDUINO_4_PLATFORM_SPEC
|
platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
|
||||||
else:
|
else:
|
||||||
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
|
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
|
||||||
value[CONF_PLATFORM_VERSION] = platform_version
|
value[CONF_PLATFORM_VERSION] = platform_version
|
||||||
@@ -270,10 +217,6 @@ def _parse_platform_version(value: Any) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
# The platform_version derived for every core >= 3.1.0 config
|
|
||||||
_ARDUINO_4_PLATFORM_SPEC = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
|
|
||||||
|
|
||||||
|
|
||||||
ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
@@ -290,6 +233,7 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"]
|
||||||
CONFIG_SCHEMA = cv.All(
|
CONFIG_SCHEMA = cv.All(
|
||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
@@ -306,30 +250,15 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
|
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
|
||||||
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
|
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
|
||||||
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
|
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
|
||||||
cv.Optional(
|
|
||||||
CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED
|
|
||||||
): _validate_toolchain,
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
_resolve_toolchain,
|
# Until the native toolchain lands, PlatformIO is the only backend;
|
||||||
_validate_native_toolchain,
|
# reject a --toolchain this platform cannot serve yet.
|
||||||
|
cv.require_platformio_toolchain("ESP8266"),
|
||||||
set_core_data,
|
set_core_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def native_toolchain_module():
|
|
||||||
"""The native build backend for the resolved toolchain, if any.
|
|
||||||
|
|
||||||
``__main__`` dispatches from its own toolchain-keyed table; this helper
|
|
||||||
serves the component's internal callers.
|
|
||||||
"""
|
|
||||||
if not CORE.using_toolchain_arduino:
|
|
||||||
return None
|
|
||||||
from esphome.arduino8266 import toolchain
|
|
||||||
|
|
||||||
return toolchain
|
|
||||||
|
|
||||||
|
|
||||||
def check_rosetta() -> None:
|
def check_rosetta() -> None:
|
||||||
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
|
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
|
||||||
|
|
||||||
@@ -365,13 +294,12 @@ def _choose_ld_script(board: str) -> str:
|
|||||||
|
|
||||||
@coroutine_with_priority(CoroPriority.PLATFORM)
|
@coroutine_with_priority(CoroPriority.PLATFORM)
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
use_platformio = CORE.using_toolchain_platformio
|
|
||||||
cg.add(esp8266_ns.setup_preferences())
|
cg.add(esp8266_ns.setup_preferences())
|
||||||
|
|
||||||
if use_platformio:
|
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
|
||||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||||
cg.add_build_flag("-DUSE_ESP8266")
|
cg.add_build_flag("-DUSE_ESP8266")
|
||||||
cg.set_cpp_standard("gnu++20")
|
cg.set_cpp_standard("gnu++20")
|
||||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
||||||
@@ -387,33 +315,28 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
"enabling scanf float support (~8KB flash)"
|
"enabling scanf float support (~8KB flash)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# The native generator reads the same decision (KEY_SCANF_FLOAT)
|
extra_scripts = [
|
||||||
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float)
|
"pre:ccache.py",
|
||||||
if use_platformio:
|
"pre:testing_mode.py",
|
||||||
extra_scripts = [
|
"pre:exclude_updater.py",
|
||||||
"pre:ccache.py",
|
"pre:exclude_waveform.py",
|
||||||
"pre:testing_mode.py",
|
"pre:relocate_ratetable.py",
|
||||||
"pre:exclude_updater.py",
|
]
|
||||||
"pre:exclude_waveform.py",
|
if not enable_scanf_float:
|
||||||
"pre:relocate_ratetable.py",
|
extra_scripts.append("pre:remove_float_scanf.py")
|
||||||
]
|
extra_scripts.append("post:post_build.py")
|
||||||
if not enable_scanf_float:
|
cg.add_platformio_option("extra_scripts", extra_scripts)
|
||||||
extra_scripts.append("pre:remove_float_scanf.py")
|
|
||||||
extra_scripts.extend(pch_extra_scripts())
|
|
||||||
extra_scripts.append("post:post_build.py")
|
|
||||||
cg.add_platformio_option("extra_scripts", extra_scripts)
|
|
||||||
|
|
||||||
conf = config[CONF_FRAMEWORK]
|
conf = config[CONF_FRAMEWORK]
|
||||||
|
cg.add_platformio_option("framework", "arduino")
|
||||||
cg.add_build_flag("-DUSE_ARDUINO")
|
cg.add_build_flag("-DUSE_ARDUINO")
|
||||||
cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO")
|
cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO")
|
||||||
cg.add_build_flag("-Wno-nonnull-compare")
|
cg.add_build_flag("-Wno-nonnull-compare")
|
||||||
if use_platformio:
|
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||||
cg.add_platformio_option("framework", "arduino")
|
cg.add_platformio_option(
|
||||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
"platform_packages",
|
||||||
cg.add_platformio_option(
|
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
|
||||||
"platform_packages",
|
)
|
||||||
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Default for platformio is LWIP2_LOW_MEMORY with:
|
# Default for platformio is LWIP2_LOW_MEMORY with:
|
||||||
# - MSS=536
|
# - MSS=536
|
||||||
@@ -451,8 +374,7 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
|
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
|
||||||
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
|
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
|
||||||
# See throw_stubs.h for details. Must be prepended before <string>, so this
|
# See throw_stubs.h for details. Must be prepended before <string>, so this
|
||||||
# uses build_src_flags with -include. Unconditional: the native build
|
# uses build_src_flags with -include.
|
||||||
# generator reads the same option, keeping one source of truth.
|
|
||||||
cg.add_platformio_option(
|
cg.add_platformio_option(
|
||||||
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
|
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
|
||||||
)
|
)
|
||||||
@@ -482,8 +404,6 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
# implementation in the Arduino ESP8266 core.
|
# implementation in the Arduino ESP8266 core.
|
||||||
cg.add_build_flag("-Wl,--wrap=millis")
|
cg.add_build_flag("-Wl,--wrap=millis")
|
||||||
|
|
||||||
# Unconditional: the native build generator reads the same option,
|
|
||||||
# keeping one source of truth
|
|
||||||
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
|
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
|
||||||
|
|
||||||
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||||
@@ -492,7 +412,7 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"),
|
cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if use_platformio and config[CONF_BOARD] in BOARDS:
|
if config[CONF_BOARD] in BOARDS:
|
||||||
cg.add_platformio_option(
|
cg.add_platformio_option(
|
||||||
"board_build.ldscript", _choose_ld_script(config[CONF_BOARD])
|
"board_build.ldscript", _choose_ld_script(config[CONF_BOARD])
|
||||||
)
|
)
|
||||||
@@ -533,24 +453,8 @@ async def finalize_serial_config() -> None:
|
|||||||
cg.add_build_flag("-DNO_GLOBAL_SERIAL1")
|
cg.add_build_flag("-DNO_GLOBAL_SERIAL1")
|
||||||
|
|
||||||
|
|
||||||
# Called by __main__.compile_program; returning False falls through to the
|
|
||||||
# PlatformIO toolchain.
|
|
||||||
def run_compile(args, config: ConfigType) -> bool:
|
|
||||||
# Positive check: the native backend only runs when explicitly resolved
|
|
||||||
toolchain = native_toolchain_module()
|
|
||||||
if toolchain is None:
|
|
||||||
return False
|
|
||||||
if toolchain.run_compile(config, CORE.verbose) != 0:
|
|
||||||
raise EsphomeError("ESP8266 native build failed")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# Called by writer.py
|
# Called by writer.py
|
||||||
def copy_files() -> None:
|
def copy_files() -> None:
|
||||||
# Native builds skip the PlatformIO extra scripts; the build generator
|
|
||||||
# carries their logic
|
|
||||||
if CORE.using_toolchain_arduino:
|
|
||||||
return
|
|
||||||
dir = Path(__file__).parent
|
dir = Path(__file__).parent
|
||||||
for script in (
|
for script in (
|
||||||
"post_build",
|
"post_build",
|
||||||
@@ -565,7 +469,6 @@ def copy_files() -> None:
|
|||||||
CORE.relative_build_path(f"{script}.py"),
|
CORE.relative_build_path(f"{script}.py"),
|
||||||
)
|
)
|
||||||
copy_ccache_script()
|
copy_ccache_script()
|
||||||
copy_pch_script()
|
|
||||||
|
|
||||||
|
|
||||||
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
|
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
|
||||||
@@ -608,75 +511,22 @@ ESP8266_EXCEPTION_CODES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_DECODE_WARNED_AT: dict[str, float] = {}
|
def _decode_pc(config: ConfigType, addr: str) -> None:
|
||||||
|
from esphome.platformio import toolchain
|
||||||
|
|
||||||
|
idedata = toolchain.get_idedata(config)
|
||||||
def _warn_decode_problem(key: str, message: str, *args) -> bool:
|
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||||
"""Warn, deduplicated briefly so a burst of stack-dump addresses warns
|
_LOGGER.debug("decode_pc no addr2line")
|
||||||
once but a later dump warns again; returns whether it warned so the
|
return
|
||||||
caller can mark suppressed addresses individually."""
|
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||||
now = time.monotonic()
|
|
||||||
last = _DECODE_WARNED_AT.get(key)
|
|
||||||
if last is not None and now - last < 30:
|
|
||||||
return False
|
|
||||||
_DECODE_WARNED_AT[key] = now
|
|
||||||
_LOGGER.warning(message, *args)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_pc(config: ConfigType, addr: str, *, bulk: bool = False) -> None:
|
|
||||||
"""Decode one crash address. ``bulk``: the caller is scanning every
|
|
||||||
8-hex stack word, most of which are not code addresses -- unmappable
|
|
||||||
ones log at debug so real frames are not buried."""
|
|
||||||
if (native_toolchain := native_toolchain_module()) is not None:
|
|
||||||
addr2line = native_toolchain.get_addr2line_path()
|
|
||||||
elf = native_toolchain.get_elf_path()
|
|
||||||
for path in (addr2line, elf):
|
|
||||||
if not path.is_file():
|
|
||||||
_warn_decode_problem(
|
|
||||||
str(path), "Cannot decode crash addresses: %s missing", path
|
|
||||||
)
|
|
||||||
# The detailed warning names no address; mark named
|
|
||||||
# registers, but bulk stack words at debug (~150 per dump)
|
|
||||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
|
||||||
log("Not decoded %s (toolchain file missing)", addr)
|
|
||||||
return
|
|
||||||
addr2line, elf = str(addr2line), str(elf)
|
|
||||||
else:
|
|
||||||
from esphome.platformio import toolchain
|
|
||||||
|
|
||||||
idedata = toolchain.get_idedata(config)
|
|
||||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
|
||||||
_warn_decode_problem(
|
|
||||||
"no-addr2line",
|
|
||||||
"Cannot decode crash addresses: no addr2line or ELF in idedata",
|
|
||||||
)
|
|
||||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
|
||||||
log("Not decoded %s (no addr2line or ELF)", addr)
|
|
||||||
return
|
|
||||||
addr2line, elf = idedata.addr2line_path, idedata.firmware_elf_path
|
|
||||||
command = [addr2line, "-pfiaC", "-e", elf, addr]
|
|
||||||
try:
|
try:
|
||||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||||
# Warn, not debug: a failing addr2line must be visible. The warning
|
|
||||||
# is rate-limited across a dump, so mark every undecoded address
|
|
||||||
# inline or the rest read as merely unmappable
|
|
||||||
if not _warn_decode_problem(
|
|
||||||
"addr2line-failed", "Could not decode crash address %s (%s)", addr, err
|
|
||||||
):
|
|
||||||
# The detailed warning already named this address; mark only
|
|
||||||
# the rate-limited ones, and bulk stack words at debug
|
|
||||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
|
||||||
log("Not decoded %s (addr2line failed)", addr)
|
|
||||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||||
return
|
return
|
||||||
|
|
||||||
if "?? ??:0" in translation:
|
if "?? ??:0" in translation:
|
||||||
# A named register that fails to decode is confusing silence; a
|
# Nothing useful
|
||||||
# bulk stack word failing is the expected common case
|
|
||||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
|
||||||
log("Not decoded %s (address not in %s)", addr, elf)
|
|
||||||
return
|
return
|
||||||
translation = translation.replace(" at ??:?", "").replace(":?", "")
|
translation = translation.replace(" at ??:?", "").replace(":?", "")
|
||||||
_LOGGER.warning("Decoded %s", translation)
|
_LOGGER.warning("Decoded %s", translation)
|
||||||
@@ -746,6 +596,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) ->
|
|||||||
|
|
||||||
if backtrace_state:
|
if backtrace_state:
|
||||||
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
|
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
|
||||||
_decode_pc(config, addr.group(), bulk=True)
|
_decode_pc(config, addr.group())
|
||||||
|
|
||||||
return backtrace_state
|
return backtrace_state
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ KEY_WAVEFORM_REQUIRED = "waveform_required"
|
|||||||
KEY_SERIAL_REQUIRED = "serial_required"
|
KEY_SERIAL_REQUIRED = "serial_required"
|
||||||
KEY_SERIAL1_REQUIRED = "serial1_required"
|
KEY_SERIAL1_REQUIRED = "serial1_required"
|
||||||
# Set for the native (non-PlatformIO) toolchain's build generator
|
# Set for the native (non-PlatformIO) toolchain's build generator
|
||||||
|
KEY_FLASH_MODE = "flash_mode"
|
||||||
KEY_SCANF_FLOAT = "scanf_float"
|
KEY_SCANF_FLOAT = "scanf_float"
|
||||||
# Per-board flash-layout override consumed by board_ld_script()
|
# Per-board flash-layout override consumed by board_ld_script()
|
||||||
KEY_LDSCRIPT = "ldscript"
|
KEY_LDSCRIPT = "ldscript"
|
||||||
@@ -72,6 +73,3 @@ def enable_serial1() -> None:
|
|||||||
enable_serial1()
|
enable_serial1()
|
||||||
"""
|
"""
|
||||||
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
|
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
|
||||||
|
|
||||||
|
|
||||||
BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout")
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import logging
|
|||||||
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components.noise import (
|
from esphome.components.noise import (
|
||||||
decode_encryption_key,
|
|
||||||
encryption_schema,
|
encryption_schema,
|
||||||
is_reserved_key,
|
new_psk_progmem,
|
||||||
|
static_encryption_key,
|
||||||
)
|
)
|
||||||
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
|
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
|
||||||
from esphome.config_helpers import merge_config
|
from esphome.config_helpers import filter_source_files_from_defines, merge_config
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
CONF_API,
|
CONF_API,
|
||||||
@@ -31,7 +31,6 @@ import esphome.final_validate as fv
|
|||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access"
|
CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access"
|
||||||
CONF_CAPTIVE_PORTAL = "captive_portal"
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,11 +40,10 @@ DEPENDENCIES = ["network"]
|
|||||||
|
|
||||||
|
|
||||||
def AUTO_LOAD(config: ConfigType) -> list[str]:
|
def AUTO_LOAD(config: ConfigType) -> list[str]:
|
||||||
"""Auto-load noise only when encryption is configured."""
|
"""Auto-load noise only when encryption is configured; the api key offer
|
||||||
|
inherits it from the api component."""
|
||||||
base = ["sha256", "socket"]
|
base = ["sha256", "socket"]
|
||||||
# A falsy config is a tooling probe for the maximal set (None from
|
# A falsy config is a tooling probe for the maximal set
|
||||||
# dependency resolution, {} from the components-graph platform probe);
|
|
||||||
# a validated config always carries defaults, never empty
|
|
||||||
if not config or CONF_ENCRYPTION in config:
|
if not config or CONF_ENCRYPTION in config:
|
||||||
return base + ["noise"]
|
return base + ["noise"]
|
||||||
return base
|
return base
|
||||||
@@ -132,12 +130,56 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
|||||||
_validate_no_password_with_encryption(ota_conf)
|
_validate_no_password_with_encryption(ota_conf)
|
||||||
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||||
_resolve_encryption_key(encryption_conf, api_conf)
|
_resolve_encryption_key(encryption_conf, api_conf)
|
||||||
if any(
|
elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None:
|
||||||
conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf
|
_LOGGER.warning(
|
||||||
) and any(
|
"'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 "
|
||||||
CONF_ENCRYPTION in conf for conf in merged_ota_esphome_configs_by_port.values()
|
"bytes plus the password on the heap): the device already offers "
|
||||||
|
"encryption with the '%s' %s %s, which authenticates any uploader "
|
||||||
|
"that takes it, and a password only matters for uploaders without "
|
||||||
|
"encryption support; remove '%s' and add '%s' under '%s' so "
|
||||||
|
"uploads use the key and encryption is required",
|
||||||
|
CONF_OTA,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_API,
|
||||||
|
CONF_ENCRYPTION,
|
||||||
|
CONF_KEY,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_ENCRYPTION,
|
||||||
|
CONF_OTA,
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
CONF_PASSWORD in ota_conf
|
||||||
|
and CONF_ENCRYPTION in api_conf
|
||||||
|
and not api_conf[CONF_ENCRYPTION].get(CONF_KEY)
|
||||||
|
):
|
||||||
|
# The CLI still needs the password; whoever provisions the key skips it
|
||||||
|
_LOGGER.warning(
|
||||||
|
"The '%s' %s %s provisioned at runtime also authenticates OTA "
|
||||||
|
"uploads once provisioned; '%s' %s then only guards plaintext "
|
||||||
|
"uploads. Whoever provisions the key can upload firmware "
|
||||||
|
"without the password, so add a 'provisioning:' block to limit "
|
||||||
|
"when that is possible",
|
||||||
|
CONF_API,
|
||||||
|
CONF_ENCRYPTION,
|
||||||
|
CONF_KEY,
|
||||||
|
CONF_OTA,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
)
|
||||||
|
# web_server and prometheus keep the shared listener up; the captive
|
||||||
|
# portal's copy only exists on the fallback AP and is the recovery path
|
||||||
|
if (
|
||||||
|
(CONF_WEB_SERVER in full_conf or "prometheus" in full_conf)
|
||||||
|
and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf)
|
||||||
|
and any(
|
||||||
|
CONF_ENCRYPTION in conf
|
||||||
|
for conf in merged_ota_esphome_configs_by_port.values()
|
||||||
|
)
|
||||||
):
|
):
|
||||||
_warn_web_server_ota(full_conf)
|
_LOGGER.warning(
|
||||||
|
"OTA encryption does not cover the %s OTA platform; its "
|
||||||
|
"plaintext /update endpoint accepts the same image",
|
||||||
|
CONF_WEB_SERVER,
|
||||||
|
)
|
||||||
|
|
||||||
full_conf[CONF_OTA] = new_ota_conf
|
full_conf[CONF_OTA] = new_ota_conf
|
||||||
fv.full_config.set(full_conf)
|
fv.full_config.set(full_conf)
|
||||||
@@ -152,33 +194,11 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _warn_web_server_ota(full_conf: ConfigType) -> None:
|
|
||||||
"""The web_server ota platform accepts the same image over plaintext HTTP
|
|
||||||
with basic auth, bypassing the encryption; warn rather than fail so the
|
|
||||||
operator keeps the recovery path."""
|
|
||||||
if CONF_CAPTIVE_PORTAL in full_conf and CONF_WEB_SERVER not in full_conf:
|
|
||||||
# The captive_portal auto-load: the endpoint only exists while the
|
|
||||||
# fallback AP is active
|
|
||||||
_LOGGER.warning(
|
|
||||||
"OTA encryption does not cover the %s OTA platform (auto-loaded "
|
|
||||||
"by captive_portal); the plaintext /update endpoint stays "
|
|
||||||
"reachable while the fallback AP is active",
|
|
||||||
CONF_WEB_SERVER,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"OTA encryption does not cover the %s OTA platform; its "
|
|
||||||
"plaintext /update endpoint accepts the same image",
|
|
||||||
CONF_WEB_SERVER,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -> None:
|
def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -> None:
|
||||||
"""Resolve the one encryption key per device into the ota block.
|
"""Resolve the one encryption key per device into the ota block.
|
||||||
|
|
||||||
An explicit ota key must match the api key, a bare block inherits it,
|
An explicit ota key must match the api key, a bare block inherits it,
|
||||||
a runtime provisioned api key cannot be inherited, and the all-zeros
|
a runtime provisioned api key cannot be inherited.
|
||||||
provisioning sentinel is rejected (the device treats it as no key).
|
|
||||||
"""
|
"""
|
||||||
api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
|
api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
|
||||||
if ota_key := encryption_conf.get(CONF_KEY):
|
if ota_key := encryption_conf.get(CONF_KEY):
|
||||||
@@ -201,11 +221,6 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
encryption_conf[CONF_KEY] = api_key
|
encryption_conf[CONF_KEY] = api_key
|
||||||
if is_reserved_key(encryption_conf[CONF_KEY]):
|
|
||||||
raise cv.Invalid(
|
|
||||||
f"The all-zeros {CONF_KEY} is reserved and provides no protection; "
|
|
||||||
f"generate a real key with: openssl rand -base64 32"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Also called on merged same-port configs in final validate, where schemas
|
# Also called on merged same-port configs in final validate, where schemas
|
||||||
@@ -267,15 +282,9 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
|
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
|
||||||
|
|
||||||
|
|
||||||
def FILTER_SOURCE_FILES() -> list[str]:
|
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||||
"""Filter out the noise transport when no ota entry configures encryption."""
|
{"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"}
|
||||||
for ota_conf in CORE.config.get(CONF_OTA, []):
|
)
|
||||||
if (
|
|
||||||
ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME
|
|
||||||
and ota_conf.get(CONF_ENCRYPTION) is not None
|
|
||||||
):
|
|
||||||
return []
|
|
||||||
return ["ota_esphome_noise.cpp"]
|
|
||||||
|
|
||||||
|
|
||||||
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
||||||
@@ -296,11 +305,24 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
if config.get(CONF_ALLOW_PARTITION_ACCESS):
|
if config.get(CONF_ALLOW_PARTITION_ACCESS):
|
||||||
cg.add_define("USE_OTA_PARTITIONS")
|
cg.add_define("USE_OTA_PARTITIONS")
|
||||||
|
|
||||||
if (encryption_conf := config.get(CONF_ENCRYPTION)) is not None:
|
# One key per device: an api encryption block supplies it (static or
|
||||||
# A missing key was resolved from the api component in final validate.
|
# runtime) and offers; the ota block only adds the requirement
|
||||||
key = encryption_conf[CONF_KEY]
|
api_conf = CORE.config.get(CONF_API) or {}
|
||||||
|
encryption_conf = config.get(CONF_ENCRYPTION)
|
||||||
|
own_key = None
|
||||||
|
if encryption_conf is not None and static_encryption_key(api_conf) is None:
|
||||||
|
own_key = encryption_conf[CONF_KEY]
|
||||||
|
if own_key is not None:
|
||||||
cg.add_define("USE_OTA_ENCRYPTION")
|
cg.add_define("USE_OTA_ENCRYPTION")
|
||||||
cg.add(var.set_noise_psk(list(decode_encryption_key(key))))
|
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key)))
|
||||||
|
elif CONF_ENCRYPTION in api_conf:
|
||||||
|
cg.add_define("USE_OTA_ENCRYPTION")
|
||||||
|
cg.add_define("USE_OTA_ENCRYPTION_FROM_API")
|
||||||
|
if static_encryption_key(api_conf) is None:
|
||||||
|
# The key arrives at runtime, so the offer has to look for it
|
||||||
|
cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED")
|
||||||
|
if encryption_conf is not None:
|
||||||
|
cg.add_define("USE_OTA_ENCRYPTION_REQUIRED")
|
||||||
|
|
||||||
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
||||||
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
|
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#include "ota_esphome.h"
|
#include "ota_esphome.h"
|
||||||
|
#ifdef USE_OTA_ENCRYPTION_FROM_API
|
||||||
|
#include "esphome/components/api/api_server.h"
|
||||||
|
#endif
|
||||||
#ifdef USE_OTA
|
#ifdef USE_OTA
|
||||||
#ifdef USE_OTA_PASSWORD
|
#ifdef USE_OTA_PASSWORD
|
||||||
#include "esphome/components/sha256/sha256.h"
|
#include "esphome/components/sha256/sha256.h"
|
||||||
@@ -26,6 +29,16 @@
|
|||||||
namespace esphome {
|
namespace esphome {
|
||||||
|
|
||||||
static const char *const TAG = "esphome.ota";
|
static const char *const TAG = "esphome.ota";
|
||||||
|
|
||||||
|
#ifdef USE_OTA_ENCRYPTION
|
||||||
|
const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
|
||||||
|
#ifdef USE_OTA_ENCRYPTION_FROM_API
|
||||||
|
return api::global_api_server->get_noise_ctx();
|
||||||
|
#else
|
||||||
|
return this->noise_ctx_;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#endif
|
||||||
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
|
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
|
||||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
|
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
|
||||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
|
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
|
||||||
@@ -97,18 +110,30 @@ void ESPHomeOTAComponent::dump_config() {
|
|||||||
ESP_LOGCONFIG(TAG,
|
ESP_LOGCONFIG(TAG,
|
||||||
"Over-The-Air updates:\n"
|
"Over-The-Air updates:\n"
|
||||||
" Address: %s:%u\n"
|
" Address: %s:%u\n"
|
||||||
" Version: %d",
|
" Version: %d"
|
||||||
network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION);
|
#ifdef USE_OTA_ENCRYPTION
|
||||||
|
"\n Encryption: %s"
|
||||||
|
#endif
|
||||||
|
,
|
||||||
|
network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION
|
||||||
|
#ifdef USE_OTA_ENCRYPTION_REQUIRED
|
||||||
|
,
|
||||||
|
LOG_STR_LITERAL("required")
|
||||||
|
#elif defined(USE_OTA_ENCRYPTION_PROVISIONED)
|
||||||
|
// A runtime provisioned key may not exist yet
|
||||||
|
,
|
||||||
|
this->noise_context_().has_psk() ? LOG_STR_LITERAL("offered, plaintext accepted")
|
||||||
|
: LOG_STR_LITERAL("offered once the api key is provisioned")
|
||||||
|
#elif defined(USE_OTA_ENCRYPTION)
|
||||||
|
,
|
||||||
|
LOG_STR_LITERAL("offered, plaintext accepted")
|
||||||
|
#endif
|
||||||
|
);
|
||||||
#ifdef USE_OTA_PASSWORD
|
#ifdef USE_OTA_PASSWORD
|
||||||
if (!this->password_.empty()) {
|
if (!this->password_.empty()) {
|
||||||
ESP_LOGCONFIG(TAG, " Password configured");
|
ESP_LOGCONFIG(TAG, " Password configured");
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_OTA_ENCRYPTION
|
|
||||||
if (this->noise_ctx_.has_psk()) {
|
|
||||||
ESP_LOGCONFIG(TAG, " Encryption configured");
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#ifdef USE_OTA_PARTITIONS
|
#ifdef USE_OTA_PARTITIONS
|
||||||
ESP_LOGCONFIG(TAG,
|
ESP_LOGCONFIG(TAG,
|
||||||
" Partition access allowed\n"
|
" Partition access allowed\n"
|
||||||
@@ -154,10 +179,22 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
|||||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
|
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
|
||||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
|
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
|
||||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
|
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
|
||||||
|
// Noise needs the extended protocol: the prologue binds the 2-byte feature ack
|
||||||
|
static constexpr uint8_t CLIENT_NOISE_FEATURES =
|
||||||
|
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
|
||||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
|
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
|
||||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
|
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
|
||||||
|
|
||||||
|
inline bool ESPHomeOTAComponent::extended_proto_() const {
|
||||||
|
#ifdef USE_OTA_ENCRYPTION_REQUIRED
|
||||||
|
// FEATURE_READ already refused every client without the extended protocol
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
return (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
void ESPHomeOTAComponent::handle_handshake_() {
|
void ESPHomeOTAComponent::handle_handshake_() {
|
||||||
/// Handle the OTA handshake and authentication.
|
/// Handle the OTA handshake and authentication.
|
||||||
///
|
///
|
||||||
@@ -241,12 +278,9 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
|||||||
this->ota_features_ = this->handshake_buf_[0];
|
this->ota_features_ = this->handshake_buf_[0];
|
||||||
ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_);
|
ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_);
|
||||||
|
|
||||||
#ifdef USE_OTA_ENCRYPTION
|
#ifdef USE_OTA_ENCRYPTION_REQUIRED
|
||||||
// Fail closed: with a PSK configured the client must negotiate encryption
|
// `ota: encryption:` requires the client to negotiate encryption
|
||||||
// (which requires the extended protocol); refuse plaintext uploads.
|
if ((this->ota_features_ & CLIENT_NOISE_FEATURES) != CLIENT_NOISE_FEATURES) {
|
||||||
static constexpr uint8_t NOISE_REQUIRED_FEATURES =
|
|
||||||
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
|
|
||||||
if (this->noise_ctx_.has_psk() && (this->ota_features_ & NOISE_REQUIRED_FEATURES) != NOISE_REQUIRED_FEATURES) {
|
|
||||||
ESP_LOGW(TAG, "Client does not support encryption");
|
ESP_LOGW(TAG, "Client does not support encryption");
|
||||||
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED);
|
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED);
|
||||||
return;
|
return;
|
||||||
@@ -261,18 +295,21 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
|||||||
// Compose the feature-ack response. When the client negotiates the extended protocol we emit
|
// Compose the feature-ack response. When the client negotiates the extended protocol we emit
|
||||||
// a 2-byte response (marker + server feature flags); otherwise we emit the single-byte
|
// a 2-byte response (marker + server feature flags); otherwise we emit the single-byte
|
||||||
// legacy response.
|
// legacy response.
|
||||||
this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0;
|
if (this->extended_proto_()) {
|
||||||
if (this->extended_proto_) {
|
|
||||||
static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack");
|
static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack");
|
||||||
this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS;
|
this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS;
|
||||||
this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0);
|
this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0);
|
||||||
#ifdef USE_OTA_PARTITIONS
|
#ifdef USE_OTA_PARTITIONS
|
||||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS;
|
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS;
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_OTA_ENCRYPTION
|
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
|
||||||
if (this->noise_ctx_.has_psk()) {
|
// A runtime provisioned key may not exist yet
|
||||||
|
if (this->noise_context_().has_psk()) {
|
||||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
|
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
|
||||||
}
|
}
|
||||||
|
#elif defined(USE_OTA_ENCRYPTION)
|
||||||
|
// A yaml key always exists: validation rejects the all-zeros key
|
||||||
|
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
|
||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
this->handshake_buf_[0] =
|
this->handshake_buf_[0] =
|
||||||
@@ -284,15 +321,15 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
|||||||
case OTAState::FEATURE_ACK: {
|
case OTAState::FEATURE_ACK: {
|
||||||
static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1;
|
static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1;
|
||||||
static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2;
|
static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2;
|
||||||
const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE;
|
const size_t ack_size = this->extended_proto_() ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE;
|
||||||
if (!this->try_write_(ack_size, LOG_STR("ack feature"))) {
|
if (!this->try_write_(ack_size, LOG_STR("ack feature"))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
#ifdef USE_OTA_ENCRYPTION
|
#ifdef USE_OTA_ENCRYPTION
|
||||||
// With a PSK configured the rest of the session runs inside the noise
|
// Latch the offer actually sent: a key activating between the two
|
||||||
// transport; the client sends the first handshake frame next, so there
|
// states must not start a session the client never expects
|
||||||
// is nothing to do until data arrives.
|
if ((this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 &&
|
||||||
if (this->noise_ctx_.has_psk()) {
|
(this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) {
|
||||||
// handshake_buf_ still holds the feature ack composed above; a
|
// handshake_buf_ still holds the feature ack composed above; a
|
||||||
// would-block re-entry lands here without rebuilding it
|
// would-block re-entry lands here without rebuilding it
|
||||||
if (!this->noise_start_session_(this->handshake_buf_[1])) {
|
if (!this->noise_start_session_(this->handshake_buf_[1])) {
|
||||||
@@ -412,7 +449,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
|||||||
// Acknowledge auth OK - 1 byte
|
// Acknowledge auth OK - 1 byte
|
||||||
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
|
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
|
||||||
|
|
||||||
if (this->extended_proto_) {
|
if (this->extended_proto_()) {
|
||||||
// Read ota type, 1 byte
|
// Read ota type, 1 byte
|
||||||
if (!this->data_readall_(buf, 1)) {
|
if (!this->data_readall_(buf, 1)) {
|
||||||
this->log_read_error_(LOG_STR("OTA type"));
|
this->log_read_error_(LOG_STR("OTA type"));
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
|||||||
}
|
}
|
||||||
#endif // USE_OTA_PASSWORD
|
#endif // USE_OTA_PASSWORD
|
||||||
|
|
||||||
#ifdef USE_OTA_ENCRYPTION
|
#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API)
|
||||||
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
|
/// psk points at 32 bytes that live in flash for the life of the program
|
||||||
|
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Manually set the port OTA should listen on
|
/// Manually set the port OTA should listen on
|
||||||
@@ -85,9 +86,12 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
|||||||
bool writing{false}; // a produced handshake frame is still being flushed
|
bool writing{false}; // a produced handshake frame is still being flushed
|
||||||
uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE];
|
uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE];
|
||||||
};
|
};
|
||||||
|
// The api server's live context when the api has encryption, else our own
|
||||||
|
const noise::NoiseContext &noise_context_() const;
|
||||||
bool noise_start_session_(uint8_t server_feature_flags);
|
bool noise_start_session_(uint8_t server_feature_flags);
|
||||||
bool handle_noise_handshake_();
|
bool handle_noise_handshake_();
|
||||||
bool noise_try_read_frame_();
|
bool noise_try_read_frame_();
|
||||||
|
size_t noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len);
|
||||||
bool noise_try_write_frame_();
|
bool noise_try_write_frame_();
|
||||||
void noise_send_reject_(const LogString *reason);
|
void noise_send_reject_(const LogString *reason);
|
||||||
ssize_t noise_decrypt_(uint8_t *buf, size_t len);
|
ssize_t noise_decrypt_(uint8_t *buf, size_t len);
|
||||||
@@ -144,7 +148,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
|||||||
std::unique_ptr<uint8_t[]> auth_buf_;
|
std::unique_ptr<uint8_t[]> auth_buf_;
|
||||||
#endif // USE_OTA_PASSWORD
|
#endif // USE_OTA_PASSWORD
|
||||||
#ifdef USE_OTA_ENCRYPTION
|
#ifdef USE_OTA_ENCRYPTION
|
||||||
|
#ifndef USE_OTA_ENCRYPTION_FROM_API
|
||||||
noise::NoiseContext noise_ctx_;
|
noise::NoiseContext noise_ctx_;
|
||||||
|
#endif
|
||||||
std::unique_ptr<NoiseSession> noise_;
|
std::unique_ptr<NoiseSession> noise_;
|
||||||
#endif // USE_OTA_ENCRYPTION
|
#endif // USE_OTA_ENCRYPTION
|
||||||
|
|
||||||
@@ -166,6 +172,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
|||||||
"OTA_BUFFER_SIZE must fit a full encrypted data frame");
|
"OTA_BUFFER_SIZE must fit a full encrypted data frame");
|
||||||
#endif
|
#endif
|
||||||
static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
|
static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
|
||||||
|
// Derived from the feature byte; storing it would pad the trailing bytes
|
||||||
|
bool extended_proto_() const;
|
||||||
#ifdef USE_OTA_PARTITIONS
|
#ifdef USE_OTA_PARTITIONS
|
||||||
uint32_t running_app_offset_{0};
|
uint32_t running_app_offset_{0};
|
||||||
size_t running_app_size_{0};
|
size_t running_app_size_{0};
|
||||||
@@ -179,7 +187,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
|||||||
uint8_t auth_buf_pos_{0};
|
uint8_t auth_buf_pos_{0};
|
||||||
uint8_t auth_type_{0}; // Store auth type to know which hasher to use
|
uint8_t auth_type_{0}; // Store auth type to know which hasher to use
|
||||||
#endif // USE_OTA_PASSWORD
|
#endif // USE_OTA_PASSWORD
|
||||||
bool extended_proto_{false};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace esphome
|
} // namespace esphome
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#ifdef USE_OTA_ENCRYPTION
|
#ifdef USE_OTA_ENCRYPTION
|
||||||
#include "esphome/components/noise/noise.h"
|
#include "esphome/components/noise/noise.h"
|
||||||
#include "esphome/components/ota/ota_backend.h"
|
#include "esphome/components/ota/ota_backend.h"
|
||||||
|
#include "esphome/core/hal.h"
|
||||||
#include "esphome/core/log.h"
|
#include "esphome/core/log.h"
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -40,24 +41,17 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
|
|||||||
* "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags
|
* "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags
|
||||||
*/
|
*/
|
||||||
bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
|
bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
|
||||||
|
// A provisioned key cleared between the offer and here is not guarded: the
|
||||||
|
// session runs on the zero key load_psk fills in and fails the client's MAC.
|
||||||
|
// Default-init: the frame buffer is written before it is read
|
||||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
|
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||||
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession());
|
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession);
|
||||||
if (this->noise_ == nullptr) {
|
|
||||||
ESP_LOGW(TAG, "Session allocation failed");
|
|
||||||
this->cleanup_connection_();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version
|
static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version
|
||||||
static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1;
|
static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1;
|
||||||
static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags
|
static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags
|
||||||
uint8_t prologue[OTA_NOISE_PROLOGUE_INIT_LEN + sizeof(MAGIC_BYTES) + PROLOGUE_ACK_LEN + PROLOGUE_CLIENT_FEATURES_LEN +
|
uint8_t prologue[OTA_NOISE_PROLOGUE_INIT_LEN + sizeof(MAGIC_BYTES) + PROLOGUE_ACK_LEN + PROLOGUE_CLIENT_FEATURES_LEN +
|
||||||
PROLOGUE_FEATURE_ACK_LEN];
|
PROLOGUE_FEATURE_ACK_LEN];
|
||||||
#ifdef USE_ESP8266
|
progmem_memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
|
||||||
memcpy_P(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
|
|
||||||
#else
|
|
||||||
std::memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
|
|
||||||
#endif
|
|
||||||
uint8_t *p = prologue + OTA_NOISE_PROLOGUE_INIT_LEN;
|
uint8_t *p = prologue + OTA_NOISE_PROLOGUE_INIT_LEN;
|
||||||
// Magic bytes, already validated in MAGIC_READ
|
// Magic bytes, already validated in MAGIC_READ
|
||||||
std::memcpy(p, MAGIC_BYTES, sizeof(MAGIC_BYTES));
|
std::memcpy(p, MAGIC_BYTES, sizeof(MAGIC_BYTES));
|
||||||
@@ -71,9 +65,13 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
|
|||||||
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
|
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
|
||||||
*p++ = server_feature_flags;
|
*p++ = server_feature_flags;
|
||||||
|
|
||||||
int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue));
|
// The caller only starts a session when the context holds a key
|
||||||
|
int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY
|
||||||
|
: this->noise_->handshake.init(this->noise_context_(), prologue, sizeof(prologue));
|
||||||
if (err != 0) {
|
if (err != 0) {
|
||||||
ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
// Raw noise codes throughout: the name table would cost flash in builds
|
||||||
|
// where only the OTA uses noise
|
||||||
|
ESP_LOGW(TAG, "Session init: %d", err);
|
||||||
this->cleanup_connection_();
|
this->cleanup_connection_();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -105,14 +103,16 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
|
|||||||
s.frame_pos = 0;
|
s.frame_pos = 0;
|
||||||
s.frame_len = 0;
|
s.frame_len = 0;
|
||||||
if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) {
|
if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) {
|
||||||
ESP_LOGW(TAG, "Bad handshake error byte: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
|
ESP_LOGW(TAG, "Client rejected the handshake: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
|
||||||
this->cleanup_connection_();
|
this->cleanup_connection_();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1);
|
int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1);
|
||||||
if (err != 0) {
|
if (err != 0) {
|
||||||
ESP_LOGW(TAG, "Handshake read: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
// A MAC failure here almost always means the uploader has a different key
|
||||||
this->noise_send_reject_(noise::reject_reason_for(err));
|
const LogString *reason = noise::reject_reason_for(err);
|
||||||
|
ESP_LOGW(TAG, "Handshake read: %s (%d)", LOG_STR_ARG(reason), err);
|
||||||
|
this->noise_send_reject_(reason);
|
||||||
this->cleanup_connection_();
|
this->cleanup_connection_();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
|
|||||||
int err =
|
int err =
|
||||||
s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len);
|
s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len);
|
||||||
if (err != 0) {
|
if (err != 0) {
|
||||||
ESP_LOGW(TAG, "Handshake write: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
ESP_LOGW(TAG, "Handshake write: %d", err);
|
||||||
this->cleanup_connection_();
|
this->cleanup_connection_();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -138,7 +138,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
|
|||||||
case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: {
|
case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: {
|
||||||
int err = s.handshake.split(s.send_cipher, s.recv_cipher);
|
int err = s.handshake.split(s.send_cipher, s.recv_cipher);
|
||||||
if (err != 0) {
|
if (err != 0) {
|
||||||
ESP_LOGW(TAG, "Handshake split: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
ESP_LOGW(TAG, "Handshake split: %d", err);
|
||||||
this->cleanup_connection_();
|
this->cleanup_connection_();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -154,33 +154,41 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Payload length from a frame header, or 0 (logged) when the indicator or
|
||||||
|
/// the length is out of range. Callers pass min_len >= 1 so 0 is never valid.
|
||||||
|
size_t ESPHomeOTAComponent::noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len) {
|
||||||
|
const size_t payload_len = encode_uint16(header[1], header[2]);
|
||||||
|
if (header[0] != noise::FRAME_INDICATOR || payload_len < min_len || payload_len > max_len) {
|
||||||
|
ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], payload_len);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return payload_len;
|
||||||
|
}
|
||||||
|
|
||||||
/// Non-blocking read of one handshake frame into the session buffer.
|
/// Non-blocking read of one handshake frame into the session buffer.
|
||||||
bool ESPHomeOTAComponent::noise_try_read_frame_() {
|
bool ESPHomeOTAComponent::noise_try_read_frame_() {
|
||||||
NoiseSession &s = *this->noise_;
|
NoiseSession &s = *this->noise_;
|
||||||
while (s.frame_pos < noise::FRAME_HEADER_SIZE) {
|
while (true) {
|
||||||
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, noise::FRAME_HEADER_SIZE - s.frame_pos);
|
// The header first, then the body once the header says how long it is
|
||||||
if (!this->handle_read_error_(read, LOG_STR("read noise header"))) {
|
const uint16_t want = s.frame_len == 0 ? noise::FRAME_HEADER_SIZE : s.frame_len;
|
||||||
return false;
|
if (s.frame_pos < want) {
|
||||||
|
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, want - s.frame_pos);
|
||||||
|
if (!this->handle_read_error_(read, LOG_STR("read noise"))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s.frame_pos += read;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
s.frame_pos += read;
|
if (s.frame_len != 0) {
|
||||||
}
|
return true;
|
||||||
if (s.frame_len == 0) {
|
}
|
||||||
const uint16_t payload_len = encode_uint16(s.frame_buf[1], s.frame_buf[2]);
|
const size_t payload_len = this->noise_frame_payload_len_(s.frame_buf, 1, 1 + noise::MAX_HANDSHAKE_SIZE);
|
||||||
if (s.frame_buf[0] != noise::FRAME_INDICATOR || payload_len < 1 || payload_len > 1 + noise::MAX_HANDSHAKE_SIZE) {
|
if (payload_len == 0) {
|
||||||
ESP_LOGW(TAG, "Bad handshake frame: 0x%02X, %u bytes", s.frame_buf[0], payload_len);
|
|
||||||
this->cleanup_connection_();
|
this->cleanup_connection_();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
s.frame_len = noise::FRAME_HEADER_SIZE + payload_len;
|
s.frame_len = noise::FRAME_HEADER_SIZE + payload_len;
|
||||||
}
|
}
|
||||||
while (s.frame_pos < s.frame_len) {
|
|
||||||
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos);
|
|
||||||
if (!this->handle_read_error_(read, LOG_STR("read noise frame"))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
s.frame_pos += read;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-blocking write of the pending session-buffer frame.
|
/// Non-blocking write of the pending session-buffer frame.
|
||||||
@@ -214,7 +222,7 @@ ssize_t ESPHomeOTAComponent::noise_decrypt_(uint8_t *buf, size_t len) {
|
|||||||
noise_buffer_set_inout(mbuf, buf, len, len);
|
noise_buffer_set_inout(mbuf, buf, len, len);
|
||||||
int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf);
|
int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf);
|
||||||
if (err != 0) {
|
if (err != 0) {
|
||||||
ESP_LOGW(TAG, "Decrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
ESP_LOGW(TAG, "Decrypt: %d", err);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
return mbuf.size;
|
return mbuf.size;
|
||||||
@@ -229,9 +237,8 @@ ssize_t ESPHomeOTAComponent::noise_read_frame_blocking_(uint8_t *buf, size_t min
|
|||||||
if (!this->readall_(header, sizeof(header))) {
|
if (!this->readall_(header, sizeof(header))) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
const size_t ciphertext_len = encode_uint16(header[1], header[2]);
|
const size_t ciphertext_len = this->noise_frame_payload_len_(header, min_ciphertext, max_ciphertext);
|
||||||
if (header[0] != noise::FRAME_INDICATOR || ciphertext_len < min_ciphertext || ciphertext_len > max_ciphertext) {
|
if (ciphertext_len == 0) {
|
||||||
ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], ciphertext_len);
|
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (!this->readall_(buf, ciphertext_len)) {
|
if (!this->readall_(buf, ciphertext_len)) {
|
||||||
@@ -267,7 +274,7 @@ bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) {
|
|||||||
noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE);
|
noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE);
|
||||||
int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf);
|
int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf);
|
||||||
if (err != 0) {
|
if (err != 0) {
|
||||||
ESP_LOGW(TAG, "Encrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
ESP_LOGW(TAG, "Encrypt: %d", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
noise::write_frame_header(frame, mbuf.size);
|
noise::write_frame_header(frame, mbuf.size);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Any
|
|||||||
from esphome import automation, core
|
from esphome import automation, core
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components import wifi
|
from esphome.components import wifi
|
||||||
|
from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant
|
||||||
from esphome.components.udp import CONF_ON_RECEIVE
|
from esphome.components.udp import CONF_ON_RECEIVE
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -17,6 +18,7 @@ from esphome.const import (
|
|||||||
)
|
)
|
||||||
from esphome.core import CORE, HexInt
|
from esphome.core import CORE, HexInt
|
||||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||||
|
import esphome.final_validate as fv
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@jesserockz"]
|
CODEOWNERS = ["@jesserockz"]
|
||||||
@@ -132,6 +134,24 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_variant(config: ConfigType) -> ConfigType:
|
||||||
|
# ESP-NOW rides the Wi-Fi PHY. Radio-less esp32 variants have no native
|
||||||
|
# ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that
|
||||||
|
# supplies the esp_now_* symbols. Fail here with a clear message instead of
|
||||||
|
# letting the build reach an "undefined reference to esp_now_*" link error.
|
||||||
|
variant = get_esp32_variant()
|
||||||
|
if wifi.variant_has_wifi(variant):
|
||||||
|
return config
|
||||||
|
if variant != VARIANT_ESP32P4:
|
||||||
|
raise cv.Invalid(f"ESP-NOW is not supported on {variant} (no Wi-Fi radio)")
|
||||||
|
if "esp32_hosted" not in fv.full_config.get():
|
||||||
|
raise cv.Invalid(f"ESP-NOW on {variant} requires the esp32_hosted component")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
FINAL_VALIDATE_SCHEMA = _validate_variant
|
||||||
|
|
||||||
|
|
||||||
async def _trigger_to_code(config: ConfigType) -> MockObj:
|
async def _trigger_to_code(config: ConfigType) -> MockObj:
|
||||||
if address := config.get(CONF_ADDRESS):
|
if address := config.get(CONF_ADDRESS):
|
||||||
address = address.parts
|
address = address.parts
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from esphome.build_helpers.pch import pch_extra_scripts
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -27,7 +26,7 @@ from esphome.const import (
|
|||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
from esphome.core.config import BOARD_MAX_LENGTH
|
from esphome.core.config import BOARD_MAX_LENGTH
|
||||||
from esphome.helpers import copy_file_if_changed
|
from esphome.helpers import copy_file_if_changed
|
||||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
from esphome.platformio.toolchain import copy_ccache_script
|
||||||
from esphome.storage_json import StorageJSON
|
from esphome.storage_json import StorageJSON
|
||||||
|
|
||||||
from . import gpio # noqa: F401
|
from . import gpio # noqa: F401
|
||||||
@@ -514,7 +513,7 @@ async def component_to_code(config):
|
|||||||
# it for project source files only. GCC uses the last -O flag.
|
# it for project source files only. GCC uses the last -O flag.
|
||||||
build_src_flags += " -Os"
|
build_src_flags += " -Os"
|
||||||
cg.add_platformio_option("build_src_flags", build_src_flags)
|
cg.add_platformio_option("build_src_flags", build_src_flags)
|
||||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
|
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||||
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
|
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
|
||||||
# On other families, patch_linker.py routes .sram.text into the right
|
# On other families, patch_linker.py routes .sram.text into the right
|
||||||
# RAM-executable output section and prints a post-link placement summary.
|
# RAM-executable output section and prints a post-link placement summary.
|
||||||
@@ -620,4 +619,3 @@ def copy_files() -> None:
|
|||||||
CORE.relative_build_path("patch_linker.py"),
|
CORE.relative_build_path("patch_linker.py"),
|
||||||
)
|
)
|
||||||
copy_ccache_script()
|
copy_ccache_script()
|
||||||
copy_pch_script()
|
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
if CORE.using_arduino:
|
if CORE.using_arduino:
|
||||||
if CORE.is_esp8266:
|
if CORE.is_esp8266:
|
||||||
cg.add_library("ESP8266mDNS", None)
|
cg.add_library("ESP8266mDNS", None)
|
||||||
|
# No MDNS global in the build; mdns_esp8266.cpp owns a guarded MDNSResponder
|
||||||
|
cg.add_build_flag("-DNO_GLOBAL_MDNS")
|
||||||
elif CORE.is_rp2:
|
elif CORE.is_rp2:
|
||||||
cg.add_library("LEAmDNS", None)
|
cg.add_library("LEAmDNS", None)
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,47 @@
|
|||||||
|
|
||||||
namespace esphome::mdns {
|
namespace esphome::mdns {
|
||||||
|
|
||||||
|
// Main-loop calls into LEAmDNS that send (update() and close(); begin(), addService() and
|
||||||
|
// the scheduled restart never reach a send) can yield inside UdpContext::sendTimeout(); a
|
||||||
|
// packet arriving then re-enters LEAmDNS from lwIP on the same UdpContext and both sides
|
||||||
|
// free the same tx pbufs (#18760). Received packets stay queued during such a call and are
|
||||||
|
// processed from the main loop afterwards.
|
||||||
|
class GuardedMDNSResponder : public ::esp8266::MDNSImplementation::MDNSResponder {
|
||||||
|
public:
|
||||||
|
void update_guarded() { this->run_guarded_(&GuardedMDNSResponder::update); }
|
||||||
|
void close_guarded() { this->run_guarded_(&GuardedMDNSResponder::close); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void run_guarded_(bool (GuardedMDNSResponder::*fn)()) {
|
||||||
|
UdpContext *ctx = this->m_pUDPContext;
|
||||||
|
if (ctx == nullptr) {
|
||||||
|
(this->*fn)();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Set every time: a restart replaces the context together with its stock handler. Only
|
||||||
|
// begin() and the scheduled netif callback restart, never update() or close(), so the
|
||||||
|
// context cannot change underneath this call.
|
||||||
|
ctx->onRx([this]() {
|
||||||
|
if (!this->in_loop_call_) {
|
||||||
|
this->_callProcess();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this->in_loop_call_ = true;
|
||||||
|
(this->*fn)();
|
||||||
|
// close() releases the context; a yield in here queues further packets for this loop too
|
||||||
|
while (this->m_pUDPContext != nullptr && this->m_pUDPContext->next()) {
|
||||||
|
this->_parseMessage();
|
||||||
|
}
|
||||||
|
this->in_loop_call_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
volatile bool in_loop_call_{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
static GuardedMDNSResponder mdns_responder; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||||
|
|
||||||
static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services) {
|
static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services) {
|
||||||
MDNS.begin(App.get_name().c_str());
|
mdns_responder.begin(App.get_name().c_str());
|
||||||
|
|
||||||
for (const auto &service : services) {
|
for (const auto &service : services) {
|
||||||
// Strip the leading underscore from the proto and service_type. While it is
|
// Strip the leading underscore from the proto and service_type. While it is
|
||||||
@@ -30,10 +69,10 @@ static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SER
|
|||||||
service_type++;
|
service_type++;
|
||||||
}
|
}
|
||||||
uint16_t port = service.port.value();
|
uint16_t port = service.port.value();
|
||||||
MDNS.addService(FPSTR(service_type), FPSTR(proto), port);
|
mdns_responder.addService(FPSTR(service_type), FPSTR(proto), port);
|
||||||
for (const auto &record : service.txt_records) {
|
for (const auto &record : service.txt_records) {
|
||||||
MDNS.addServiceTxt(FPSTR(service_type), FPSTR(proto), FPSTR(MDNS_STR_ARG(record.key)),
|
mdns_responder.addServiceTxt(FPSTR(service_type), FPSTR(proto), FPSTR(MDNS_STR_ARG(record.key)),
|
||||||
FPSTR(MDNS_STR_ARG(record.value)));
|
FPSTR(MDNS_STR_ARG(record.value)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +91,7 @@ void MDNSComponent::start_polling_window_() {
|
|||||||
if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active()))
|
if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active()))
|
||||||
return;
|
return;
|
||||||
#endif
|
#endif
|
||||||
MDNS.update();
|
mdns_responder.update_guarded();
|
||||||
});
|
});
|
||||||
this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); });
|
this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); });
|
||||||
}
|
}
|
||||||
@@ -81,7 +120,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
void MDNSComponent::on_shutdown() {
|
void MDNSComponent::on_shutdown() {
|
||||||
MDNS.close();
|
mdns_responder.close_guarded();
|
||||||
delay(10);
|
delay(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ void MipiDsi::setup() {
|
|||||||
.bus_id = 0, // index from 0, specify the DSI host to use
|
.bus_id = 0, // index from 0, specify the DSI host to use
|
||||||
.num_data_lanes =
|
.num_data_lanes =
|
||||||
this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability
|
this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability
|
||||||
.phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, // Clock source for the DPHY
|
// phy_clk_src left at 0 to enable runtime auto-select.
|
||||||
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
|
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
|
||||||
};
|
};
|
||||||
auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_);
|
auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_);
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from typing import Any
|
|||||||
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_KEY
|
from esphome.const import CONF_ENCRYPTION, CONF_KEY
|
||||||
|
from esphome.core import ID
|
||||||
|
from esphome.cpp_generator import MockObj
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@esphome/core"]
|
CODEOWNERS = ["@esphome/core"]
|
||||||
@@ -23,6 +25,14 @@ def validate_encryption_key(value: Any) -> str:
|
|||||||
|
|
||||||
if len(decoded) != 32:
|
if len(decoded) != 32:
|
||||||
raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
|
raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
|
||||||
|
if not any(decoded):
|
||||||
|
# The device treats the all-zeros key as no key at all (it is the
|
||||||
|
# provisioning sentinel), so it must never reach a build
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"The all-zeros {CONF_KEY} is reserved and provides no protection; "
|
||||||
|
f"omit the {CONF_KEY} to provision it at runtime, or generate a real "
|
||||||
|
"key with: openssl rand -base64 32"
|
||||||
|
)
|
||||||
|
|
||||||
# Return original data for roundtrip conversion
|
# Return original data for roundtrip conversion
|
||||||
return value
|
return value
|
||||||
@@ -45,15 +55,6 @@ def decode_encryption_key(value: str) -> bytes:
|
|||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
def is_reserved_key(value: str) -> bool:
|
|
||||||
"""Whether the key is the reserved all-zeros provisioning sentinel.
|
|
||||||
|
|
||||||
The device treats it as no key configured, so consumers that require a
|
|
||||||
real key must reject it.
|
|
||||||
"""
|
|
||||||
return not any(decode_encryption_key(value))
|
|
||||||
|
|
||||||
|
|
||||||
ENCRYPTION_SCHEMA = cv.Schema(
|
ENCRYPTION_SCHEMA = cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
|
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
|
||||||
@@ -61,6 +62,21 @@ ENCRYPTION_SCHEMA = cv.Schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def static_encryption_key(conf: ConfigType) -> str | None:
|
||||||
|
"""The build time key of a component config; None without one or when
|
||||||
|
the key is provisioned at runtime."""
|
||||||
|
return (conf.get(CONF_ENCRYPTION) or {}).get(CONF_KEY) or None
|
||||||
|
|
||||||
|
|
||||||
|
def new_psk_progmem(parent_id: ID, key: str) -> MockObj:
|
||||||
|
"""Emit the decoded key as a PROGMEM array; the component keeps a pointer
|
||||||
|
so the key never occupies RAM."""
|
||||||
|
return cg.progmem_array(
|
||||||
|
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
|
||||||
|
list(decode_encryption_key(key)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def encryption_schema(config: ConfigType | None) -> ConfigType:
|
def encryption_schema(config: ConfigType | None) -> ConfigType:
|
||||||
# A bare `encryption:` block is valid; a missing key means the consumer
|
# A bare `encryption:` block is valid; a missing key means the consumer
|
||||||
# falls back to its keyless behavior (api provisioning, ota inheriting
|
# falls back to its keyless behavior (api provisioning, ota inheriting
|
||||||
@@ -72,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
|
|||||||
|
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
cg.add_define("USE_NOISE")
|
cg.add_define("USE_NOISE")
|
||||||
cg.add_library("esphome/noise-c", "0.1.21")
|
cg.add_library("esphome/noise-c", "0.1.24")
|
||||||
# noise-c depends on libsodium, but declaring it here too lets the
|
# noise-c depends on libsodium, but declaring it here too lets the
|
||||||
# library manager see the full set up front instead of discovering
|
# library manager see the full set up front instead of discovering
|
||||||
# libsodium only after noise-c has downloaded, so the two can download
|
# libsodium only after noise-c has downloaded, so the two can download
|
||||||
# in parallel. The version must match noise-c's library.json.
|
# in parallel. The version must match noise-c's library.json.
|
||||||
cg.add_library("esphome/libsodium", "1.10021.4")
|
cg.add_library("esphome/libsodium", "1.10021.6")
|
||||||
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
||||||
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
||||||
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "noise.h"
|
#include "noise.h"
|
||||||
#ifdef USE_NOISE
|
#ifdef USE_NOISE
|
||||||
|
#include "esphome/core/hal.h"
|
||||||
#include "esphome/core/log.h"
|
#include "esphome/core/log.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -15,6 +16,14 @@ namespace esphome::noise {
|
|||||||
|
|
||||||
static const char *const TAG = "noise";
|
static const char *const TAG = "noise";
|
||||||
|
|
||||||
|
void NoiseContext::load_psk(psk_t &out) const {
|
||||||
|
if (this->psk_ == nullptr) {
|
||||||
|
out.fill(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
progmem_memcpy(out.data(), this->psk_, out.size());
|
||||||
|
}
|
||||||
|
|
||||||
const LogString *noise_err_to_logstr(int err) {
|
const LogString *noise_err_to_logstr(int err) {
|
||||||
if (err == NOISE_ERROR_NO_MEMORY)
|
if (err == NOISE_ERROR_NO_MEMORY)
|
||||||
return LOG_STR("NO_MEMORY");
|
return LOG_STR("NO_MEMORY");
|
||||||
|
|||||||
@@ -23,16 +23,16 @@ class NoiseContext {
|
|||||||
}
|
}
|
||||||
return acc == 0;
|
return acc == 0;
|
||||||
}
|
}
|
||||||
void set_psk(psk_t psk) {
|
/// psk points at 32 bytes that outlive the context (PROGMEM or caller owned
|
||||||
this->psk_ = psk;
|
/// RAM); nullptr means no key. Runtime callers map the all-zeros key to
|
||||||
this->has_psk_ = !is_all_zeros(psk);
|
/// nullptr themselves; validation keeps it out of yaml.
|
||||||
}
|
void set_psk(const uint8_t *psk) { this->psk_ = psk; }
|
||||||
const psk_t &get_psk() const { return this->psk_; }
|
/// Copy the key out (flash-aware on ESP8266); all zeros when none is set.
|
||||||
bool has_psk() const { return this->has_psk_; }
|
void load_psk(psk_t &out) const;
|
||||||
|
bool has_psk() const { return this->psk_ != nullptr; }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
psk_t psk_{};
|
const uint8_t *psk_{nullptr};
|
||||||
bool has_psk_{false};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Convert a noise error code to a readable error
|
/// Convert a noise error code to a readable error
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ NoiseResponderHandshake::~NoiseResponderHandshake() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
|
int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len) {
|
||||||
if (this->handshake_ != nullptr) {
|
if (this->handshake_ != nullptr) {
|
||||||
noise_handshakestate_free(this->handshake_);
|
noise_handshakestate_free(this->handshake_);
|
||||||
this->handshake_ = nullptr;
|
this->handshake_ = nullptr;
|
||||||
@@ -44,6 +44,9 @@ int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, siz
|
|||||||
HANDSHAKE_STEP_LOG("noise_handshakestate_new_by_id", err);
|
HANDSHAKE_STEP_LOG("noise_handshakestate_new_by_id", err);
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
// noise-c keeps its own copy, so the key only passes through the stack here
|
||||||
|
psk_t psk;
|
||||||
|
ctx.load_psk(psk);
|
||||||
err = noise_handshakestate_set_pre_shared_key(this->handshake_, psk.data(), psk.size());
|
err = noise_handshakestate_set_pre_shared_key(this->handshake_, psk.data(), psk.size());
|
||||||
if (err != 0) {
|
if (err != 0) {
|
||||||
HANDSHAKE_STEP_LOG("noise_handshakestate_set_pre_shared_key", err);
|
HANDSHAKE_STEP_LOG("noise_handshakestate_set_pre_shared_key", err);
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ class NoiseResponderHandshake {
|
|||||||
NoiseResponderHandshake(const NoiseResponderHandshake &) = delete;
|
NoiseResponderHandshake(const NoiseResponderHandshake &) = delete;
|
||||||
NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete;
|
NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete;
|
||||||
|
|
||||||
/// Create and start the handshake with the given PSK and prologue. A
|
/// Create and start the handshake with the context's PSK and the prologue.
|
||||||
/// repeated call frees the previous handshake state and starts over.
|
/// A repeated call frees the previous handshake state and starts over.
|
||||||
[[nodiscard]] int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len);
|
[[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len);
|
||||||
/// ACTION_FAILED is the catch-all: returned before init(), after split()
|
/// ACTION_FAILED is the catch-all: returned before init(), after split()
|
||||||
/// has released the state, and when noise-c reports a failed handshake.
|
/// has released the state, and when noise-c reports a failed handshake.
|
||||||
[[nodiscard]] Action action() const;
|
[[nodiscard]] Action action() const;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from string import ascii_letters, digits
|
|||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from esphome.build_helpers.pch import pch_extra_scripts
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -34,7 +33,7 @@ from esphome.core import (
|
|||||||
)
|
)
|
||||||
from esphome.core.config import BOARD_MAX_LENGTH
|
from esphome.core.config import BOARD_MAX_LENGTH
|
||||||
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
|
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
|
||||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
from esphome.platformio.toolchain import copy_ccache_script
|
||||||
from esphome.storage_json import StorageJSON
|
from esphome.storage_json import StorageJSON
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
@@ -341,10 +340,7 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
|
cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
|
||||||
cg.add_define(ThreadModel.SINGLE)
|
cg.add_define(ThreadModel.SINGLE)
|
||||||
|
|
||||||
cg.add_platformio_option(
|
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"])
|
||||||
"extra_scripts",
|
|
||||||
["pre:ccache.py", *pch_extra_scripts(), "post:post_build.py"],
|
|
||||||
)
|
|
||||||
|
|
||||||
conf = config[CONF_FRAMEWORK]
|
conf = config[CONF_FRAMEWORK]
|
||||||
cg.add_platformio_option("framework", "arduino")
|
cg.add_platformio_option("framework", "arduino")
|
||||||
@@ -648,7 +644,6 @@ def copy_files() -> None:
|
|||||||
CORE.relative_build_path("inject_lwip_include.py"),
|
CORE.relative_build_path("inject_lwip_include.py"),
|
||||||
)
|
)
|
||||||
copy_ccache_script()
|
copy_ccache_script()
|
||||||
copy_pch_script()
|
|
||||||
_generate_lwipopts_h()
|
_generate_lwipopts_h()
|
||||||
if generate_pio_files():
|
if generate_pio_files():
|
||||||
path = CORE.relative_src_path("esphome.h")
|
path = CORE.relative_src_path("esphome.h")
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
from esphome import automation
|
||||||
|
import esphome.codegen as cg
|
||||||
|
from esphome.components import climate, sensor
|
||||||
|
from esphome.components.climate import climate_ns
|
||||||
|
import esphome.config_validation as cv
|
||||||
|
from esphome.const import (
|
||||||
|
CONF_ACTION,
|
||||||
|
CONF_CURRENT_TEMPERATURE,
|
||||||
|
CONF_CUSTOM_FAN_MODE,
|
||||||
|
CONF_CUSTOM_FAN_MODES,
|
||||||
|
CONF_CUSTOM_PRESET,
|
||||||
|
CONF_CUSTOM_PRESETS,
|
||||||
|
CONF_FAN_MODE,
|
||||||
|
CONF_HUMIDITY_SENSOR,
|
||||||
|
CONF_ID,
|
||||||
|
CONF_INITIAL_STATE,
|
||||||
|
CONF_MODE,
|
||||||
|
CONF_OPTIMISTIC,
|
||||||
|
CONF_PRESET,
|
||||||
|
CONF_RESTORE_MODE,
|
||||||
|
CONF_SENSOR,
|
||||||
|
CONF_SUPPORTED_FAN_MODES,
|
||||||
|
CONF_SUPPORTED_MODES,
|
||||||
|
CONF_SUPPORTED_PRESETS,
|
||||||
|
CONF_SUPPORTED_SWING_MODES,
|
||||||
|
CONF_SWING_MODE,
|
||||||
|
CONF_TARGET_TEMPERATURE,
|
||||||
|
CONF_TARGET_TEMPERATURE_HIGH,
|
||||||
|
CONF_TARGET_TEMPERATURE_LOW,
|
||||||
|
)
|
||||||
|
from esphome.core import ID
|
||||||
|
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
|
from .. import template_ns
|
||||||
|
|
||||||
|
CONF_CURRENT_HUMIDITY = "current_humidity"
|
||||||
|
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||||
|
CONF_SUPPORTS_ACTION = "supports_action"
|
||||||
|
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = "supports_two_point_target_temperature"
|
||||||
|
CONF_SUPPORTS_TARGET_HUMIDITY = "supports_target_humidity"
|
||||||
|
CONF_SUPPORTS_CURRENT_TEMPERATURE = "supports_current_temperature"
|
||||||
|
CONF_SUPPORTS_CURRENT_HUMIDITY = "supports_current_humidity"
|
||||||
|
CONF_SET_MODE_ACTION = "set_mode_action"
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_ACTION = "set_target_temperature_action"
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION = "set_target_temperature_low_action"
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION = "set_target_temperature_high_action"
|
||||||
|
CONF_SET_TARGET_HUMIDITY_ACTION = "set_target_humidity_action"
|
||||||
|
CONF_SET_FAN_MODE_ACTION = "set_fan_mode_action"
|
||||||
|
CONF_SET_CUSTOM_FAN_MODE_ACTION = "set_custom_fan_mode_action"
|
||||||
|
CONF_SET_SWING_MODE_ACTION = "set_swing_mode_action"
|
||||||
|
CONF_SET_PRESET_ACTION = "set_preset_action"
|
||||||
|
CONF_SET_CUSTOM_PRESET_ACTION = "set_custom_preset_action"
|
||||||
|
|
||||||
|
TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Component)
|
||||||
|
TemplateClimatePublishAction = template_ns.class_(
|
||||||
|
"TemplateClimatePublishAction",
|
||||||
|
automation.Action,
|
||||||
|
cg.Parented.template(TemplateClimate),
|
||||||
|
)
|
||||||
|
|
||||||
|
TemplateClimateRestoreMode = template_ns.enum(
|
||||||
|
"TemplateClimateRestoreMode", is_class=True
|
||||||
|
)
|
||||||
|
CLIMATE_RESTORE_MODES = {
|
||||||
|
"NO_RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE,
|
||||||
|
"RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-field actions that forward a requested value on. The third item is the type of `x`.
|
||||||
|
SET_ACTIONS = (
|
||||||
|
(CONF_SET_MODE_ACTION, "get_set_mode_trigger", climate.ClimateMode),
|
||||||
|
(
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_ACTION,
|
||||||
|
"get_set_target_temperature_trigger",
|
||||||
|
cg.float_,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
|
||||||
|
"get_set_target_temperature_low_trigger",
|
||||||
|
cg.float_,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
|
||||||
|
"get_set_target_temperature_high_trigger",
|
||||||
|
cg.float_,
|
||||||
|
),
|
||||||
|
(CONF_SET_TARGET_HUMIDITY_ACTION, "get_set_target_humidity_trigger", cg.float_),
|
||||||
|
(CONF_SET_FAN_MODE_ACTION, "get_set_fan_mode_trigger", climate.ClimateFanMode),
|
||||||
|
(
|
||||||
|
CONF_SET_CUSTOM_FAN_MODE_ACTION,
|
||||||
|
"get_set_custom_fan_mode_trigger",
|
||||||
|
cg.StringRef,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CONF_SET_SWING_MODE_ACTION,
|
||||||
|
"get_set_swing_mode_trigger",
|
||||||
|
climate.ClimateSwingMode,
|
||||||
|
),
|
||||||
|
(CONF_SET_PRESET_ACTION, "get_set_preset_trigger", climate.ClimatePreset),
|
||||||
|
(CONF_SET_CUSTOM_PRESET_ACTION, "get_set_custom_preset_trigger", cg.StringRef),
|
||||||
|
)
|
||||||
|
|
||||||
|
# supports_* keys have no default so that an omitted key can mean "derive it from the sensor or
|
||||||
|
# set action that makes the trait useful", which is not expressible once a default fills it in.
|
||||||
|
DERIVED_SUPPORTS = (
|
||||||
|
(CONF_SUPPORTS_CURRENT_TEMPERATURE, (CONF_SENSOR,)),
|
||||||
|
(CONF_SUPPORTS_CURRENT_HUMIDITY, (CONF_HUMIDITY_SENSOR,)),
|
||||||
|
(
|
||||||
|
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
|
||||||
|
(
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(CONF_SUPPORTS_TARGET_HUMIDITY, (CONF_SET_TARGET_HUMIDITY_ACTION,)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Custom fan modes/presets are opaque user-defined strings with no build-time correctness check
|
||||||
|
# elsewhere (Climate::set_supported_custom_fan_modes()/set_supported_custom_presets() don't block
|
||||||
|
# empty entries), so reject empty ones here -- they could never be selected at runtime anyway.
|
||||||
|
validate_custom_climate_string = cv.All(cv.string_strict, cv.Length(min=1))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_two_point(config: ConfigType) -> ConfigType:
|
||||||
|
has_low = CONF_TARGET_TEMPERATURE_LOW in config
|
||||||
|
has_high = CONF_TARGET_TEMPERATURE_HIGH in config
|
||||||
|
if has_low != has_high:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"'{CONF_TARGET_TEMPERATURE_LOW}' and '{CONF_TARGET_TEMPERATURE_HIGH}' must be used together"
|
||||||
|
)
|
||||||
|
if (has_low or has_high) and CONF_TARGET_TEMPERATURE in config:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"'{CONF_TARGET_TEMPERATURE}' cannot be used together with "
|
||||||
|
f"'{CONF_TARGET_TEMPERATURE_LOW}'/'{CONF_TARGET_TEMPERATURE_HIGH}'"
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_set_actions(config: ConfigType) -> ConfigType:
|
||||||
|
has_low = CONF_SET_TARGET_TEMPERATURE_LOW_ACTION in config
|
||||||
|
has_high = CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION in config
|
||||||
|
if has_low != has_high:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"'{CONF_SET_TARGET_TEMPERATURE_LOW_ACTION}' and "
|
||||||
|
f"'{CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION}' must be used together"
|
||||||
|
)
|
||||||
|
if (has_low or has_high) and CONF_SET_TARGET_TEMPERATURE_ACTION in config:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"'{CONF_SET_TARGET_TEMPERATURE_ACTION}' cannot be used together with "
|
||||||
|
f"'{CONF_SET_TARGET_TEMPERATURE_LOW_ACTION}'/'{CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION}'"
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_supports(config: ConfigType) -> ConfigType:
|
||||||
|
# An explicit true stays valid without either, since climate.template.publish can report the
|
||||||
|
# value; an explicit false that contradicts the configuration is an error, not a silent override.
|
||||||
|
for key, sources in DERIVED_SUPPORTS:
|
||||||
|
configured = [source for source in sources if source in config]
|
||||||
|
if key not in config:
|
||||||
|
config[key] = bool(configured)
|
||||||
|
elif not config[key] and configured:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"'{key}' cannot be false while '{configured[0]}' is configured",
|
||||||
|
path=[key],
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_initial_state(config: ConfigType) -> ConfigType:
|
||||||
|
# Climate keeps target_temperature and target_temperature_low in a union, so writing the wrong
|
||||||
|
# one of the pair corrupts the setpoint with no runtime complaint.
|
||||||
|
if (initial_state := config.get(CONF_INITIAL_STATE)) is None:
|
||||||
|
return config
|
||||||
|
|
||||||
|
two_point = config[CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE]
|
||||||
|
if two_point and CONF_TARGET_TEMPERATURE in initial_state:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"'{CONF_TARGET_TEMPERATURE}' is not available while "
|
||||||
|
f"'{CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE}' is enabled; use "
|
||||||
|
f"'{CONF_TARGET_TEMPERATURE_LOW}'/'{CONF_TARGET_TEMPERATURE_HIGH}' instead",
|
||||||
|
path=[CONF_INITIAL_STATE, CONF_TARGET_TEMPERATURE],
|
||||||
|
)
|
||||||
|
if not two_point:
|
||||||
|
for key in (CONF_TARGET_TEMPERATURE_LOW, CONF_TARGET_TEMPERATURE_HIGH):
|
||||||
|
if key in initial_state:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"'{key}' requires '{CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE}' to be enabled",
|
||||||
|
path=[CONF_INITIAL_STATE, key],
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
CONF_TARGET_HUMIDITY in initial_state
|
||||||
|
and not config[CONF_SUPPORTS_TARGET_HUMIDITY]
|
||||||
|
):
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled",
|
||||||
|
path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY],
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
# Same settable fields as climate.template.publish, minus current_temperature/current_humidity/
|
||||||
|
# action: those are reported values (from a sensor or the device), not meaningful static defaults.
|
||||||
|
INITIAL_STATE_SCHEMA = cv.All(
|
||||||
|
cv.Schema(
|
||||||
|
{
|
||||||
|
cv.Optional(CONF_MODE): climate.validate_climate_mode,
|
||||||
|
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature,
|
||||||
|
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature,
|
||||||
|
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature,
|
||||||
|
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
|
||||||
|
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
|
||||||
|
cv.Exclusive(
|
||||||
|
CONF_CUSTOM_FAN_MODE, "fan_mode"
|
||||||
|
): validate_custom_climate_string,
|
||||||
|
cv.Optional(CONF_SWING_MODE): climate.validate_climate_swing_mode,
|
||||||
|
cv.Exclusive(CONF_PRESET, "preset"): climate.validate_climate_preset,
|
||||||
|
cv.Exclusive(CONF_CUSTOM_PRESET, "preset"): validate_custom_climate_string,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
_validate_two_point,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.All(
|
||||||
|
climate.climate_schema(TemplateClimate)
|
||||||
|
.extend(
|
||||||
|
{
|
||||||
|
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
||||||
|
cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor),
|
||||||
|
# action only ever arrives through climate.template.publish, so unlike the other
|
||||||
|
# supports_* keys there is no set action to derive it from.
|
||||||
|
cv.Optional(CONF_SUPPORTS_ACTION, default=False): cv.boolean,
|
||||||
|
cv.Optional(CONF_SUPPORTS_CURRENT_TEMPERATURE): cv.boolean,
|
||||||
|
cv.Optional(CONF_SUPPORTS_CURRENT_HUMIDITY): cv.boolean,
|
||||||
|
cv.Optional(CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE): cv.boolean,
|
||||||
|
cv.Optional(CONF_SUPPORTS_TARGET_HUMIDITY): cv.boolean,
|
||||||
|
cv.Required(CONF_SUPPORTED_MODES): cv.All(
|
||||||
|
cv.ensure_list(climate.validate_climate_mode), cv.Unique()
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_SUPPORTED_FAN_MODES): cv.All(
|
||||||
|
cv.ensure_list(climate.validate_climate_fan_mode), cv.Unique()
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_CUSTOM_FAN_MODES): cv.All(
|
||||||
|
cv.ensure_list(validate_custom_climate_string), cv.Unique()
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.All(
|
||||||
|
cv.ensure_list(climate.validate_climate_swing_mode), cv.Unique()
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_SUPPORTED_PRESETS): cv.All(
|
||||||
|
cv.ensure_list(climate.validate_climate_preset), cv.Unique()
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_CUSTOM_PRESETS): cv.All(
|
||||||
|
cv.ensure_list(validate_custom_climate_string), cv.Unique()
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
|
||||||
|
cv.Optional(CONF_RESTORE_MODE, default="RESTORE"): cv.enum(
|
||||||
|
CLIMATE_RESTORE_MODES, upper=True
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_INITIAL_STATE): INITIAL_STATE_SCHEMA,
|
||||||
|
cv.Optional(CONF_SET_MODE_ACTION): automation.validate_automation(
|
||||||
|
single=True
|
||||||
|
),
|
||||||
|
cv.Optional(
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_ACTION
|
||||||
|
): automation.validate_automation(single=True),
|
||||||
|
cv.Optional(
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION
|
||||||
|
): automation.validate_automation(single=True),
|
||||||
|
cv.Optional(
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION
|
||||||
|
): automation.validate_automation(single=True),
|
||||||
|
cv.Optional(
|
||||||
|
CONF_SET_TARGET_HUMIDITY_ACTION
|
||||||
|
): automation.validate_automation(single=True),
|
||||||
|
cv.Optional(CONF_SET_FAN_MODE_ACTION): automation.validate_automation(
|
||||||
|
single=True
|
||||||
|
),
|
||||||
|
cv.Optional(
|
||||||
|
CONF_SET_CUSTOM_FAN_MODE_ACTION
|
||||||
|
): automation.validate_automation(single=True),
|
||||||
|
cv.Optional(CONF_SET_SWING_MODE_ACTION): automation.validate_automation(
|
||||||
|
single=True
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_SET_PRESET_ACTION): automation.validate_automation(
|
||||||
|
single=True
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_SET_CUSTOM_PRESET_ACTION): automation.validate_automation(
|
||||||
|
single=True
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.extend(cv.COMPONENT_SCHEMA),
|
||||||
|
_validate_set_actions,
|
||||||
|
_resolve_supports,
|
||||||
|
_validate_initial_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def to_code(config: ConfigType) -> None:
|
||||||
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
|
await cg.register_component(var, config)
|
||||||
|
await climate.register_climate(var, config)
|
||||||
|
|
||||||
|
if (sens := config.get(CONF_SENSOR)) is not None:
|
||||||
|
cg.add(var.set_sensor(await cg.get_variable(sens)))
|
||||||
|
|
||||||
|
if (sens := config.get(CONF_HUMIDITY_SENSOR)) is not None:
|
||||||
|
cg.add(var.set_humidity_sensor(await cg.get_variable(sens)))
|
||||||
|
|
||||||
|
for key, flag in (
|
||||||
|
(CONF_SUPPORTS_ACTION, climate_ns.CLIMATE_SUPPORTS_ACTION),
|
||||||
|
(
|
||||||
|
CONF_SUPPORTS_CURRENT_TEMPERATURE,
|
||||||
|
climate_ns.CLIMATE_SUPPORTS_CURRENT_TEMPERATURE,
|
||||||
|
),
|
||||||
|
(CONF_SUPPORTS_CURRENT_HUMIDITY, climate_ns.CLIMATE_SUPPORTS_CURRENT_HUMIDITY),
|
||||||
|
(
|
||||||
|
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
|
||||||
|
climate_ns.CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
|
||||||
|
),
|
||||||
|
(CONF_SUPPORTS_TARGET_HUMIDITY, climate_ns.CLIMATE_SUPPORTS_TARGET_HUMIDITY),
|
||||||
|
):
|
||||||
|
if config[key]:
|
||||||
|
cg.add(var.add_feature_flags(flag))
|
||||||
|
|
||||||
|
for mode in config[CONF_SUPPORTED_MODES]:
|
||||||
|
cg.add(var.add_supported_mode(mode))
|
||||||
|
|
||||||
|
for mode in config.get(CONF_SUPPORTED_FAN_MODES, []):
|
||||||
|
cg.add(var.add_supported_fan_mode(mode))
|
||||||
|
|
||||||
|
if CONF_CUSTOM_FAN_MODES in config:
|
||||||
|
cg.add(
|
||||||
|
var.set_supported_custom_fan_modes(
|
||||||
|
cg.ArrayInitializer(*config[CONF_CUSTOM_FAN_MODES])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for mode in config.get(CONF_SUPPORTED_SWING_MODES, []):
|
||||||
|
cg.add(var.add_supported_swing_mode(mode))
|
||||||
|
|
||||||
|
for preset in config.get(CONF_SUPPORTED_PRESETS, []):
|
||||||
|
cg.add(var.add_supported_preset(preset))
|
||||||
|
|
||||||
|
if CONF_CUSTOM_PRESETS in config:
|
||||||
|
cg.add(
|
||||||
|
var.set_supported_custom_presets(
|
||||||
|
cg.ArrayInitializer(*config[CONF_CUSTOM_PRESETS])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, trigger_getter, arg_type in SET_ACTIONS:
|
||||||
|
if (conf := config.get(key)) is not None:
|
||||||
|
await automation.build_automation(
|
||||||
|
getattr(var, trigger_getter)(), [(arg_type, "x")], conf
|
||||||
|
)
|
||||||
|
|
||||||
|
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
|
||||||
|
cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE]))
|
||||||
|
|
||||||
|
if (initial_state := config.get(CONF_INITIAL_STATE)) is not None:
|
||||||
|
if (v := initial_state.get(CONF_MODE)) is not None:
|
||||||
|
cg.add(var.set_mode(v))
|
||||||
|
if (v := initial_state.get(CONF_TARGET_TEMPERATURE)) is not None:
|
||||||
|
cg.add(var.set_target_temperature(v))
|
||||||
|
if (v := initial_state.get(CONF_TARGET_TEMPERATURE_LOW)) is not None:
|
||||||
|
cg.add(var.set_target_temperature_low(v))
|
||||||
|
if (v := initial_state.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None:
|
||||||
|
cg.add(var.set_target_temperature_high(v))
|
||||||
|
if (v := initial_state.get(CONF_TARGET_HUMIDITY)) is not None:
|
||||||
|
cg.add(var.set_target_humidity(v))
|
||||||
|
if (v := initial_state.get(CONF_FAN_MODE)) is not None:
|
||||||
|
cg.add(var.set_fan_mode(v))
|
||||||
|
if (v := initial_state.get(CONF_CUSTOM_FAN_MODE)) is not None:
|
||||||
|
cg.add(var.set_custom_fan_mode(v))
|
||||||
|
if (v := initial_state.get(CONF_SWING_MODE)) is not None:
|
||||||
|
cg.add(var.set_swing_mode(v))
|
||||||
|
if (v := initial_state.get(CONF_PRESET)) is not None:
|
||||||
|
cg.add(var.set_preset(v))
|
||||||
|
if (v := initial_state.get(CONF_CUSTOM_PRESET)) is not None:
|
||||||
|
cg.add(var.set_custom_preset(v))
|
||||||
|
|
||||||
|
|
||||||
|
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
|
||||||
|
cv.Schema(
|
||||||
|
{
|
||||||
|
cv.GenerateID(): cv.use_id(TemplateClimate),
|
||||||
|
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
|
||||||
|
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||||
|
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
||||||
|
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
|
||||||
|
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
|
||||||
|
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||||
|
cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode),
|
||||||
|
cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action),
|
||||||
|
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
|
||||||
|
climate.validate_climate_fan_mode
|
||||||
|
),
|
||||||
|
cv.Exclusive(CONF_CUSTOM_FAN_MODE, "fan_mode"): cv.templatable(
|
||||||
|
validate_custom_climate_string
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_SWING_MODE): cv.templatable(
|
||||||
|
climate.validate_climate_swing_mode
|
||||||
|
),
|
||||||
|
cv.Exclusive(CONF_PRESET, "preset"): cv.templatable(
|
||||||
|
climate.validate_climate_preset
|
||||||
|
),
|
||||||
|
cv.Exclusive(CONF_CUSTOM_PRESET, "preset"): cv.templatable(
|
||||||
|
validate_custom_climate_string
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
_validate_two_point,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@automation.register_action(
|
||||||
|
"climate.template.publish",
|
||||||
|
TemplateClimatePublishAction,
|
||||||
|
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
|
||||||
|
synchronous=True,
|
||||||
|
)
|
||||||
|
async def climate_template_publish_to_code(
|
||||||
|
config: ConfigType,
|
||||||
|
action_id: ID,
|
||||||
|
template_arg: cg.TemplateArguments,
|
||||||
|
args: TemplateArgsType,
|
||||||
|
) -> MockObj:
|
||||||
|
var = cg.new_Pvariable(action_id, template_arg)
|
||||||
|
await cg.register_parented(var, config[CONF_ID])
|
||||||
|
|
||||||
|
if (v := config.get(CONF_CURRENT_TEMPERATURE)) is not None:
|
||||||
|
cg.add(var.set_current_temperature(await cg.templatable(v, args, cg.float_)))
|
||||||
|
if (v := config.get(CONF_CURRENT_HUMIDITY)) is not None:
|
||||||
|
cg.add(var.set_current_humidity(await cg.templatable(v, args, cg.float_)))
|
||||||
|
if (v := config.get(CONF_TARGET_TEMPERATURE)) is not None:
|
||||||
|
cg.add(var.set_target_temperature(await cg.templatable(v, args, cg.float_)))
|
||||||
|
if (v := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None:
|
||||||
|
cg.add(var.set_target_temperature_low(await cg.templatable(v, args, cg.float_)))
|
||||||
|
if (v := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None:
|
||||||
|
cg.add(
|
||||||
|
var.set_target_temperature_high(await cg.templatable(v, args, cg.float_))
|
||||||
|
)
|
||||||
|
if (v := config.get(CONF_TARGET_HUMIDITY)) is not None:
|
||||||
|
cg.add(var.set_target_humidity(await cg.templatable(v, args, cg.float_)))
|
||||||
|
if (v := config.get(CONF_MODE)) is not None:
|
||||||
|
cg.add(var.set_mode(await cg.templatable(v, args, climate.ClimateMode)))
|
||||||
|
if (v := config.get(CONF_ACTION)) is not None:
|
||||||
|
cg.add(var.set_action(await cg.templatable(v, args, climate.ClimateAction)))
|
||||||
|
if (v := config.get(CONF_FAN_MODE)) is not None:
|
||||||
|
cg.add(var.set_fan_mode(await cg.templatable(v, args, climate.ClimateFanMode)))
|
||||||
|
if (v := config.get(CONF_CUSTOM_FAN_MODE)) is not None:
|
||||||
|
cg.add(var.set_custom_fan_mode(await cg.templatable(v, args, cg.std_string)))
|
||||||
|
if (v := config.get(CONF_SWING_MODE)) is not None:
|
||||||
|
cg.add(
|
||||||
|
var.set_swing_mode(await cg.templatable(v, args, climate.ClimateSwingMode))
|
||||||
|
)
|
||||||
|
if (v := config.get(CONF_PRESET)) is not None:
|
||||||
|
cg.add(var.set_preset(await cg.templatable(v, args, climate.ClimatePreset)))
|
||||||
|
if (v := config.get(CONF_CUSTOM_PRESET)) is not None:
|
||||||
|
cg.add(var.set_custom_preset(await cg.templatable(v, args, cg.std_string)))
|
||||||
|
|
||||||
|
return var
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "template_climate.h"
|
||||||
|
#include "esphome/core/automation.h"
|
||||||
|
|
||||||
|
namespace esphome::template_ {
|
||||||
|
|
||||||
|
template<typename... Ts>
|
||||||
|
class TemplateClimatePublishAction final : public Action<Ts...>, public Parented<TemplateClimate> {
|
||||||
|
public:
|
||||||
|
TEMPLATABLE_VALUE(float, current_temperature)
|
||||||
|
TEMPLATABLE_VALUE(float, current_humidity)
|
||||||
|
TEMPLATABLE_VALUE(float, target_temperature)
|
||||||
|
TEMPLATABLE_VALUE(float, target_temperature_low)
|
||||||
|
TEMPLATABLE_VALUE(float, target_temperature_high)
|
||||||
|
TEMPLATABLE_VALUE(float, target_humidity)
|
||||||
|
TEMPLATABLE_VALUE(climate::ClimateMode, mode)
|
||||||
|
TEMPLATABLE_VALUE(climate::ClimateAction, action)
|
||||||
|
TEMPLATABLE_VALUE(climate::ClimateFanMode, fan_mode)
|
||||||
|
TEMPLATABLE_VALUE(std::string, custom_fan_mode)
|
||||||
|
TEMPLATABLE_VALUE(climate::ClimateSwingMode, swing_mode)
|
||||||
|
TEMPLATABLE_VALUE(climate::ClimatePreset, preset)
|
||||||
|
TEMPLATABLE_VALUE(std::string, custom_preset)
|
||||||
|
|
||||||
|
void play(const Ts &...x) override {
|
||||||
|
if (this->current_temperature_.has_value())
|
||||||
|
this->parent_->current_temperature = this->current_temperature_.value(x...);
|
||||||
|
if (this->current_humidity_.has_value())
|
||||||
|
this->parent_->current_humidity = this->current_humidity_.value(x...);
|
||||||
|
if (this->target_temperature_.has_value())
|
||||||
|
this->parent_->set_target_temperature(this->target_temperature_.value(x...));
|
||||||
|
if (this->target_temperature_low_.has_value())
|
||||||
|
this->parent_->set_target_temperature_low(this->target_temperature_low_.value(x...));
|
||||||
|
if (this->target_temperature_high_.has_value())
|
||||||
|
this->parent_->set_target_temperature_high(this->target_temperature_high_.value(x...));
|
||||||
|
if (this->target_humidity_.has_value())
|
||||||
|
this->parent_->set_target_humidity(this->target_humidity_.value(x...));
|
||||||
|
if (this->mode_.has_value())
|
||||||
|
this->parent_->set_mode(this->mode_.value(x...));
|
||||||
|
if (this->action_.has_value())
|
||||||
|
this->parent_->action = this->action_.value(x...);
|
||||||
|
if (this->fan_mode_.has_value())
|
||||||
|
this->parent_->set_fan_mode(this->fan_mode_.value(x...));
|
||||||
|
if (this->custom_fan_mode_.has_value())
|
||||||
|
this->parent_->set_custom_fan_mode(StringRef(this->custom_fan_mode_.value(x...)));
|
||||||
|
if (this->swing_mode_.has_value())
|
||||||
|
this->parent_->set_swing_mode(this->swing_mode_.value(x...));
|
||||||
|
if (this->preset_.has_value())
|
||||||
|
this->parent_->set_preset(this->preset_.value(x...));
|
||||||
|
if (this->custom_preset_.has_value())
|
||||||
|
this->parent_->set_custom_preset(StringRef(this->custom_preset_.value(x...)));
|
||||||
|
|
||||||
|
this->parent_->publish_state();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace esphome::template_
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#include "template_climate.h"
|
||||||
|
#include "esphome/core/log.h"
|
||||||
|
|
||||||
|
namespace esphome::template_ {
|
||||||
|
|
||||||
|
static const char *const TAG = "template.climate";
|
||||||
|
|
||||||
|
void TemplateClimate::setup() {
|
||||||
|
if (this->restore_mode_ == TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE) {
|
||||||
|
auto restore = this->restore_state_();
|
||||||
|
if (restore.has_value()) {
|
||||||
|
restore->apply(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sensors publish every reading, not just changes, so only re-publish when the value moved.
|
||||||
|
// NAN means the sensor went unavailable and is passed through rather than dropped; the second
|
||||||
|
// check stops an unavailable sensor re-publishing forever, since NAN never equals NAN.
|
||||||
|
#ifdef USE_SENSOR
|
||||||
|
if (this->sensor_ != nullptr) {
|
||||||
|
this->current_temperature = this->sensor_->state;
|
||||||
|
this->sensor_->add_on_state_callback([this](float state) {
|
||||||
|
if (state != this->current_temperature && !(std::isnan(state) && std::isnan(this->current_temperature))) {
|
||||||
|
this->current_temperature = state;
|
||||||
|
this->publish_state();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this->humidity_sensor_ != nullptr) {
|
||||||
|
this->current_humidity = this->humidity_sensor_->state;
|
||||||
|
this->humidity_sensor_->add_on_state_callback([this](float state) {
|
||||||
|
if (state != this->current_humidity && !(std::isnan(state) && std::isnan(this->current_humidity))) {
|
||||||
|
this->current_humidity = state;
|
||||||
|
this->publish_state();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void TemplateClimate::dump_config() {
|
||||||
|
LOG_CLIMATE("", "Template Climate", this);
|
||||||
|
ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TemplateClimate::control(const climate::ClimateCall &call) {
|
||||||
|
// Each field present fires its set_*_action; on_control sees the whole call. optimistic: true
|
||||||
|
// also applies the values right away, false waits for a climate.template.publish report.
|
||||||
|
if (auto mode = call.get_mode()) {
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->mode = *mode;
|
||||||
|
this->set_mode_trigger_.trigger(*mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto target_temp = call.get_target_temperature()) {
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->target_temperature = *target_temp;
|
||||||
|
this->set_target_temperature_trigger_.trigger(*target_temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto target_temp_low = call.get_target_temperature_low()) {
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->target_temperature_low = *target_temp_low;
|
||||||
|
this->set_target_temperature_low_trigger_.trigger(*target_temp_low);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto target_temp_high = call.get_target_temperature_high()) {
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->target_temperature_high = *target_temp_high;
|
||||||
|
this->set_target_temperature_high_trigger_.trigger(*target_temp_high);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto target_humidity = call.get_target_humidity()) {
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->target_humidity = *target_humidity;
|
||||||
|
this->set_target_humidity_trigger_.trigger(*target_humidity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto fan_mode = call.get_fan_mode()) {
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->set_fan_mode_(*fan_mode);
|
||||||
|
this->set_fan_mode_trigger_.trigger(*fan_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (call.has_custom_fan_mode()) {
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->set_custom_fan_mode_(call.get_custom_fan_mode());
|
||||||
|
this->set_custom_fan_mode_trigger_.trigger(call.get_custom_fan_mode());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto swing_mode = call.get_swing_mode()) {
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->swing_mode = *swing_mode;
|
||||||
|
this->set_swing_mode_trigger_.trigger(*swing_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto preset = call.get_preset()) {
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->set_preset_(*preset);
|
||||||
|
this->set_preset_trigger_.trigger(*preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (call.has_custom_preset()) {
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->set_custom_preset_(call.get_custom_preset());
|
||||||
|
this->set_custom_preset_trigger_.trigger(call.get_custom_preset());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this->optimistic_)
|
||||||
|
this->publish_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A climate.template.publish report (and initial_state:) never goes through ClimateCall::validate_(),
|
||||||
|
// so check here instead -- otherwise a typo is published as state the receiving end will reject.
|
||||||
|
void TemplateClimate::set_mode(climate::ClimateMode mode) {
|
||||||
|
if (!this->traits_.supports_mode(mode)) {
|
||||||
|
ESP_LOGW(TAG, "'%s' - Unsupported mode %u", this->get_name().c_str(), static_cast<unsigned>(mode));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TemplateClimate::set_swing_mode(climate::ClimateSwingMode swing_mode) {
|
||||||
|
if (!this->traits_.supports_swing_mode(swing_mode)) {
|
||||||
|
ESP_LOGW(TAG, "'%s' - Unsupported swing mode %u", this->get_name().c_str(), static_cast<unsigned>(swing_mode));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->swing_mode = swing_mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TemplateClimate::set_fan_mode(climate::ClimateFanMode fan_mode) {
|
||||||
|
if (!this->traits_.supports_fan_mode(fan_mode)) {
|
||||||
|
ESP_LOGW(TAG, "'%s' - Unsupported fan mode %u", this->get_name().c_str(), static_cast<unsigned>(fan_mode));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->set_fan_mode_(fan_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TemplateClimate::set_preset(climate::ClimatePreset preset) {
|
||||||
|
if (!this->traits_.supports_preset(preset)) {
|
||||||
|
ESP_LOGW(TAG, "'%s' - Unsupported preset %u", this->get_name().c_str(), static_cast<unsigned>(preset));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->set_preset_(preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TemplateClimate::set_custom_fan_mode(StringRef mode) {
|
||||||
|
if (this->find_custom_fan_mode_(mode.c_str(), mode.size()) == nullptr) {
|
||||||
|
ESP_LOGW(TAG, "'%s' - Unsupported custom fan mode '%s'", this->get_name().c_str(), mode.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->set_custom_fan_mode_(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TemplateClimate::set_custom_preset(StringRef preset) {
|
||||||
|
if (this->find_custom_preset_(preset.c_str(), preset.size()) == nullptr) {
|
||||||
|
ESP_LOGW(TAG, "'%s' - Unsupported custom preset '%s'", this->get_name().c_str(), preset.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->set_custom_preset_(preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace esphome::template_
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esphome/core/automation.h"
|
||||||
|
#include "esphome/core/component.h"
|
||||||
|
#include "esphome/components/climate/climate.h"
|
||||||
|
#ifdef USE_SENSOR
|
||||||
|
#include "esphome/components/sensor/sensor.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace esphome::template_ {
|
||||||
|
|
||||||
|
enum class TemplateClimateRestoreMode {
|
||||||
|
TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE,
|
||||||
|
TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE,
|
||||||
|
};
|
||||||
|
|
||||||
|
class TemplateClimate final : public climate::Climate, public Component {
|
||||||
|
public:
|
||||||
|
void setup() override;
|
||||||
|
void dump_config() override;
|
||||||
|
|
||||||
|
climate::ClimateTraits traits() override { return this->traits_; }
|
||||||
|
|
||||||
|
void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); }
|
||||||
|
|
||||||
|
#ifdef USE_SENSOR
|
||||||
|
// The matching feature flag is added from codegen, so the configuration alone decides it.
|
||||||
|
void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; }
|
||||||
|
void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
void add_supported_mode(climate::ClimateMode mode) { this->traits_.add_supported_mode(mode); }
|
||||||
|
void add_supported_fan_mode(climate::ClimateFanMode mode) { this->traits_.add_supported_fan_mode(mode); }
|
||||||
|
void add_supported_swing_mode(climate::ClimateSwingMode mode) { this->traits_.add_supported_swing_mode(mode); }
|
||||||
|
void add_supported_preset(climate::ClimatePreset preset) { this->traits_.add_supported_preset(preset); }
|
||||||
|
|
||||||
|
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
|
||||||
|
void set_restore_mode(TemplateClimateRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
|
||||||
|
|
||||||
|
// Fired from control() for each field the call carries, so a device-backed config can forward
|
||||||
|
// it on. Which of these are configured also decides the two-point/target-humidity traits.
|
||||||
|
Trigger<climate::ClimateMode> *get_set_mode_trigger() { return &this->set_mode_trigger_; }
|
||||||
|
Trigger<float> *get_set_target_temperature_trigger() { return &this->set_target_temperature_trigger_; }
|
||||||
|
Trigger<float> *get_set_target_temperature_low_trigger() { return &this->set_target_temperature_low_trigger_; }
|
||||||
|
Trigger<float> *get_set_target_temperature_high_trigger() { return &this->set_target_temperature_high_trigger_; }
|
||||||
|
Trigger<float> *get_set_target_humidity_trigger() { return &this->set_target_humidity_trigger_; }
|
||||||
|
Trigger<climate::ClimateFanMode> *get_set_fan_mode_trigger() { return &this->set_fan_mode_trigger_; }
|
||||||
|
Trigger<StringRef> *get_set_custom_fan_mode_trigger() { return &this->set_custom_fan_mode_trigger_; }
|
||||||
|
Trigger<climate::ClimateSwingMode> *get_set_swing_mode_trigger() { return &this->set_swing_mode_trigger_; }
|
||||||
|
Trigger<climate::ClimatePreset> *get_set_preset_trigger() { return &this->set_preset_trigger_; }
|
||||||
|
Trigger<StringRef> *get_set_custom_preset_trigger() { return &this->set_custom_preset_trigger_; }
|
||||||
|
|
||||||
|
// Used by TemplateClimatePublishAction, which is not a Climate subclass and so cannot reach the
|
||||||
|
// protected setters, and by codegen to apply `initial_state:` before setup() runs.
|
||||||
|
void set_target_temperature(float value) { this->target_temperature = value; }
|
||||||
|
void set_target_temperature_low(float value) { this->target_temperature_low = value; }
|
||||||
|
void set_target_temperature_high(float value) { this->target_temperature_high = value; }
|
||||||
|
void set_target_humidity(float value) { this->target_humidity = value; }
|
||||||
|
void set_mode(climate::ClimateMode mode);
|
||||||
|
void set_swing_mode(climate::ClimateSwingMode mode);
|
||||||
|
void set_fan_mode(climate::ClimateFanMode mode);
|
||||||
|
void set_custom_fan_mode(const char *mode) { this->set_custom_fan_mode(StringRef(mode)); }
|
||||||
|
void set_custom_fan_mode(StringRef mode);
|
||||||
|
void set_preset(climate::ClimatePreset preset);
|
||||||
|
void set_custom_preset(const char *preset) { this->set_custom_preset(StringRef(preset)); }
|
||||||
|
void set_custom_preset(StringRef preset);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void control(const climate::ClimateCall &call) override;
|
||||||
|
|
||||||
|
climate::ClimateTraits traits_;
|
||||||
|
bool optimistic_{false};
|
||||||
|
TemplateClimateRestoreMode restore_mode_{TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE};
|
||||||
|
|
||||||
|
#ifdef USE_SENSOR
|
||||||
|
sensor::Sensor *sensor_{nullptr};
|
||||||
|
sensor::Sensor *humidity_sensor_{nullptr};
|
||||||
|
#endif
|
||||||
|
|
||||||
|
Trigger<climate::ClimateMode> set_mode_trigger_;
|
||||||
|
Trigger<float> set_target_temperature_trigger_;
|
||||||
|
Trigger<float> set_target_temperature_low_trigger_;
|
||||||
|
Trigger<float> set_target_temperature_high_trigger_;
|
||||||
|
Trigger<float> set_target_humidity_trigger_;
|
||||||
|
Trigger<climate::ClimateFanMode> set_fan_mode_trigger_;
|
||||||
|
Trigger<StringRef> set_custom_fan_mode_trigger_;
|
||||||
|
Trigger<climate::ClimateSwingMode> set_swing_mode_trigger_;
|
||||||
|
Trigger<climate::ClimatePreset> set_preset_trigger_;
|
||||||
|
Trigger<StringRef> set_custom_preset_trigger_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace esphome::template_
|
||||||
@@ -434,11 +434,12 @@ void USBUartTypeCdcAcm::on_connected() {
|
|||||||
auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_,
|
auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_,
|
||||||
channel->cdc_dev_.interrupt_interface_number, 0);
|
channel->cdc_dev_.interrupt_interface_number, 0);
|
||||||
if (err_comm != ESP_OK) {
|
if (err_comm != ESP_OK) {
|
||||||
|
// Continue anyway: the interface number stays valid for CDC request addressing
|
||||||
ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number,
|
ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number,
|
||||||
esp_err_to_name(err_comm));
|
esp_err_to_name(err_comm));
|
||||||
channel->cdc_dev_.interrupt_interface_number = 0xFF; // Mark as unavailable, but continue anyway
|
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number);
|
ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number);
|
||||||
|
channel->cdc_dev_.interrupt_interface_claimed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
auto err =
|
auto err =
|
||||||
@@ -465,14 +466,15 @@ void USBUartTypeCdcAcm::on_disconnected() {
|
|||||||
usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress);
|
usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress);
|
||||||
usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress);
|
usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress);
|
||||||
}
|
}
|
||||||
if (channel->cdc_dev_.notify_ep != nullptr) {
|
// Only tear down the notify pipe when we claimed its interface ourselves;
|
||||||
|
// no transfer is ever submitted on it, so there is nothing else to cancel.
|
||||||
|
if (channel->cdc_dev_.notify_ep != nullptr && channel->cdc_dev_.interrupt_interface_claimed) {
|
||||||
usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress);
|
usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress);
|
||||||
usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress);
|
usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress);
|
||||||
}
|
}
|
||||||
if (channel->cdc_dev_.interrupt_interface_number != 0xFF &&
|
if (channel->cdc_dev_.interrupt_interface_claimed) {
|
||||||
channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) {
|
|
||||||
usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number);
|
usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number);
|
||||||
channel->cdc_dev_.interrupt_interface_number = 0xFF;
|
channel->cdc_dev_.interrupt_interface_claimed = false;
|
||||||
}
|
}
|
||||||
usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number);
|
usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number);
|
||||||
// Reset the input and output started flags to their initial state to avoid the possibility of spurious restarts
|
// Reset the input and output started flags to their initial state to avoid the possibility of spurious restarts
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ struct CdcEps {
|
|||||||
const usb_ep_desc_t *in_ep;
|
const usb_ep_desc_t *in_ep;
|
||||||
const usb_ep_desc_t *out_ep;
|
const usb_ep_desc_t *out_ep;
|
||||||
uint8_t bulk_interface_number;
|
uint8_t bulk_interface_number;
|
||||||
|
// Also the wIndex target for CDC class requests (SET_LINE_CODING etc.), so it
|
||||||
|
// must remain valid even when the interface itself is not claimed.
|
||||||
uint8_t interrupt_interface_number;
|
uint8_t interrupt_interface_number;
|
||||||
|
bool interrupt_interface_claimed{false};
|
||||||
};
|
};
|
||||||
|
|
||||||
enum CH34xChipType : uint8_t {
|
enum CH34xChipType : uint8_t {
|
||||||
|
|||||||
@@ -66,13 +66,14 @@ from esphome.const import (
|
|||||||
)
|
)
|
||||||
from esphome.core import (
|
from esphome.core import (
|
||||||
CORE,
|
CORE,
|
||||||
|
ID,
|
||||||
CoroPriority,
|
CoroPriority,
|
||||||
EsphomeError,
|
EsphomeError,
|
||||||
HexInt,
|
HexInt,
|
||||||
coroutine_with_priority,
|
coroutine_with_priority,
|
||||||
)
|
)
|
||||||
import esphome.final_validate as fv
|
import esphome.final_validate as fv
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType, TemplateArgsType
|
||||||
|
|
||||||
from . import wpa2_eap
|
from . import wpa2_eap
|
||||||
|
|
||||||
@@ -208,6 +209,7 @@ WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition)
|
|||||||
WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition)
|
WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition)
|
||||||
WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action)
|
WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action)
|
||||||
WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action)
|
WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action)
|
||||||
|
WiFiRoamAction = wifi_ns.class_("WiFiRoamAction", automation.Action)
|
||||||
WiFiConfigureAction = wifi_ns.class_(
|
WiFiConfigureAction = wifi_ns.class_(
|
||||||
"WiFiConfigureAction", automation.Action, cg.Component
|
"WiFiConfigureAction", automation.Action, cg.Component
|
||||||
)
|
)
|
||||||
@@ -820,6 +822,18 @@ async def wifi_disable_to_code(config, action_id, template_arg, args):
|
|||||||
return cg.new_Pvariable(action_id, template_arg)
|
return cg.new_Pvariable(action_id, template_arg)
|
||||||
|
|
||||||
|
|
||||||
|
@automation.register_action(
|
||||||
|
"wifi.roam", WiFiRoamAction, cv.Schema({}), synchronous=True
|
||||||
|
)
|
||||||
|
async def wifi_roam_to_code(
|
||||||
|
config: ConfigType,
|
||||||
|
action_id: ID,
|
||||||
|
template_arg: cg.TemplateArguments,
|
||||||
|
args: TemplateArgsType,
|
||||||
|
) -> cg.MockObj:
|
||||||
|
return cg.new_Pvariable(action_id, template_arg)
|
||||||
|
|
||||||
|
|
||||||
KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results"
|
KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results"
|
||||||
RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save"
|
RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save"
|
||||||
RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression"
|
RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression"
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ template<typename... Ts> class WiFiDisableAction final : public Action<Ts...> {
|
|||||||
void play(const Ts &...x) override { global_wifi_component->disable(); }
|
void play(const Ts &...x) override { global_wifi_component->disable(); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
template<typename... Ts> class WiFiRoamAction final : public Action<Ts...> {
|
||||||
|
public:
|
||||||
|
void play(const Ts &...x) override { global_wifi_component->force_roam_check(); }
|
||||||
|
};
|
||||||
|
|
||||||
template<typename... Ts> class WiFiConfigureAction final : public Action<Ts...>, public Component {
|
template<typename... Ts> class WiFiConfigureAction final : public Action<Ts...>, public Component {
|
||||||
public:
|
public:
|
||||||
TEMPLATABLE_VALUE(std::string, ssid)
|
TEMPLATABLE_VALUE(std::string, ssid)
|
||||||
|
|||||||
@@ -846,17 +846,18 @@ void WiFiComponent::loop() {
|
|||||||
this->notify_connect_state_listeners_();
|
this->notify_connect_state_listeners_();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Post-connect roaming: check for better AP
|
// Post-connect roaming: check for better AP. A scan may have been started by an
|
||||||
if (this->post_connect_roaming_) {
|
// explicit force_roam_check() even when post_connect_roaming_ is disabled, so the
|
||||||
if (this->is_roaming_scan_active()) {
|
// scan must always be consumed here to avoid leaving roaming_state_ stuck.
|
||||||
if (this->scan_done_) {
|
if (this->is_roaming_scan_active()) {
|
||||||
this->process_roaming_scan_();
|
if (this->scan_done_) {
|
||||||
}
|
this->process_roaming_scan_();
|
||||||
// else: scan in progress, wait
|
|
||||||
} else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
|
|
||||||
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
|
|
||||||
this->check_roaming_(now);
|
|
||||||
}
|
}
|
||||||
|
// else: scan in progress, wait
|
||||||
|
} else if (this->post_connect_roaming_ && this->roaming_state_ == RoamingState::IDLE &&
|
||||||
|
this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
|
||||||
|
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
|
||||||
|
this->check_roaming_(now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -2463,6 +2464,17 @@ void WiFiComponent::notify_scan_results_listeners_() {
|
|||||||
}
|
}
|
||||||
#endif // USE_WIFI_SCAN_RESULTS_LISTENERS
|
#endif // USE_WIFI_SCAN_RESULTS_LISTENERS
|
||||||
|
|
||||||
|
void WiFiComponent::force_roam_check() {
|
||||||
|
if (!this->is_connected() || this->roaming_state_ != RoamingState::IDLE || this->roaming_suppressed_()) {
|
||||||
|
ESP_LOGD(TAG, "Roam check requested, but not able to check now");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reset the attempt counter so a prior run of failed roams doesn't block this explicit request
|
||||||
|
// Note that this re-arms automatic roaming if enabled.
|
||||||
|
this->roaming_attempts_ = 0;
|
||||||
|
this->check_roaming_(millis());
|
||||||
|
}
|
||||||
|
|
||||||
void WiFiComponent::check_roaming_(uint32_t now) {
|
void WiFiComponent::check_roaming_(uint32_t now) {
|
||||||
// Guard: not for hidden networks (may not appear in scan)
|
// Guard: not for hidden networks (may not appear in scan)
|
||||||
const WiFiAP *selected = this->get_selected_sta_();
|
const WiFiAP *selected = this->get_selected_sta_();
|
||||||
@@ -2484,7 +2496,11 @@ void WiFiComponent::check_roaming_(uint32_t now) {
|
|||||||
|
|
||||||
ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
|
ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
|
||||||
this->roaming_state_ = RoamingState::SCANNING;
|
this->roaming_state_ = RoamingState::SCANNING;
|
||||||
this->wifi_scan_start_(this->passive_scan_);
|
if (!this->wifi_scan_start_(this->passive_scan_)) {
|
||||||
|
// Scan failed to start (e.g. busy) - don't get stuck in SCANNING forever
|
||||||
|
ESP_LOGD(TAG, "Roam scan failed to start");
|
||||||
|
this->roaming_state_ = RoamingState::IDLE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void WiFiComponent::process_roaming_scan_() {
|
void WiFiComponent::process_roaming_scan_() {
|
||||||
|
|||||||
@@ -565,6 +565,12 @@ class WiFiComponent final : public Component {
|
|||||||
void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; }
|
void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; }
|
||||||
void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; }
|
void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; }
|
||||||
|
|
||||||
|
/** Force an immediate post-connect roaming check, bypassing the periodic interval and the
|
||||||
|
* per-connection attempt limit. Does nothing (besides a debug log) if not connected, if a
|
||||||
|
* roam scan or connect is already in progress, or if roaming is currently suppressed.
|
||||||
|
*/
|
||||||
|
void force_roam_check();
|
||||||
|
|
||||||
#ifdef USE_WIFI_CONNECT_TRIGGER
|
#ifdef USE_WIFI_CONNECT_TRIGGER
|
||||||
Trigger<> *get_connect_trigger() { return &this->connect_trigger_; }
|
Trigger<> *get_connect_trigger() { return &this->connect_trigger_; }
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ Upper = vol.Upper
|
|||||||
Length = vol.Length
|
Length = vol.Length
|
||||||
Exclusive = vol.Exclusive
|
Exclusive = vol.Exclusive
|
||||||
Inclusive = vol.Inclusive
|
Inclusive = vol.Inclusive
|
||||||
|
Unique = vol.Unique
|
||||||
ALLOW_EXTRA = vol.ALLOW_EXTRA
|
ALLOW_EXTRA = vol.ALLOW_EXTRA
|
||||||
UNDEFINED = vol.UNDEFINED
|
UNDEFINED = vol.UNDEFINED
|
||||||
RequiredFieldInvalid = vol.RequiredFieldInvalid
|
RequiredFieldInvalid = vol.RequiredFieldInvalid
|
||||||
|
|||||||
@@ -555,14 +555,7 @@ NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscri
|
|||||||
# that is stored rather than translated away. Consumed by the esp8266 native
|
# that is stored rather than translated away. Consumed by the esp8266 native
|
||||||
# backend (later in this chain) for its ignored-option warning; defined here
|
# backend (later in this chain) for its ignored-option warning; defined here
|
||||||
# so it stays adjacent to the routing.
|
# so it stays adjacent to the routing.
|
||||||
# build_src_flags and board_build.flash_mode: set unconditionally by
|
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"}
|
||||||
# esp8266/__init__ and read by the native generator; not user-routable, so
|
|
||||||
# not in the set above
|
|
||||||
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {
|
|
||||||
"lib_ignore",
|
|
||||||
"build_src_flags",
|
|
||||||
"board_build.flash_mode",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@coroutine_with_priority(CoroPriority.FINAL)
|
@coroutine_with_priority(CoroPriority.FINAL)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
#define USE_ESP32_HOSTED
|
#define USE_ESP32_HOSTED
|
||||||
#define USE_ESP32_HOSTED_HTTP_UPDATE
|
#define USE_ESP32_HOSTED_HTTP_UPDATE
|
||||||
#define USE_ESP32_IMPROV_STATE_CALLBACK
|
#define USE_ESP32_IMPROV_STATE_CALLBACK
|
||||||
|
#define USE_ESP_NOW_HOSTED
|
||||||
#define USE_EVENT
|
#define USE_EVENT
|
||||||
#define USE_FAN
|
#define USE_FAN
|
||||||
#define USE_GPIO_BINARY_SENSOR_INTERRUPT
|
#define USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||||
@@ -244,6 +245,9 @@
|
|||||||
#define USE_RUNTIME_STATS
|
#define USE_RUNTIME_STATS
|
||||||
#define USE_OTA
|
#define USE_OTA
|
||||||
#define USE_OTA_ENCRYPTION
|
#define USE_OTA_ENCRYPTION
|
||||||
|
#define USE_OTA_ENCRYPTION_FROM_API
|
||||||
|
#define USE_OTA_ENCRYPTION_PROVISIONED
|
||||||
|
#define USE_OTA_ENCRYPTION_REQUIRED
|
||||||
#define USE_OTA_PASSWORD
|
#define USE_OTA_PASSWORD
|
||||||
#define USE_OTA_VERSION 2
|
#define USE_OTA_VERSION 2
|
||||||
#define USE_TIME_TIMEZONE
|
#define USE_TIME_TIMEZONE
|
||||||
|
|||||||
+17
-27
@@ -16,7 +16,6 @@ from esphome.build_helpers.ccache import (
|
|||||||
parse_enable_env,
|
parse_enable_env,
|
||||||
resolve_ccache_path,
|
resolve_ccache_path,
|
||||||
)
|
)
|
||||||
from esphome.build_helpers.pch import ccache_pch_env
|
|
||||||
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
|
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
|
||||||
from esphome.core import Version
|
from esphome.core import Version
|
||||||
from esphome.framework_helpers import (
|
from esphome.framework_helpers import (
|
||||||
@@ -1206,33 +1205,15 @@ def _ccache_env() -> dict[str, str]:
|
|||||||
|
|
||||||
Only values the user has not already set in the environment are returned, so
|
Only values the user has not already set in the environment are returned, so
|
||||||
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
|
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
|
||||||
The pch settings add ``time_macros`` sloppiness process-wide; the visible
|
|
||||||
effect is a cached TU can keep an older ``esp_app_desc`` build timestamp.
|
|
||||||
"""
|
|
||||||
if not _ccache_enabled():
|
|
||||||
# The raw knob value (e.g. "disable") is still inherited by idf.py
|
|
||||||
# via os.environ, where a non-false-constant string reads as
|
|
||||||
# truthy; export the canonical off spelling instead
|
|
||||||
return {"IDF_CCACHE_ENABLE": "0"}
|
|
||||||
|
|
||||||
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
|
||||||
env.update(ccache_pch_env())
|
|
||||||
# Exactly one canonical spelling ever reaches idf.py, whatever the
|
|
||||||
# accepted input spelling was ("enable", "yes", ...)
|
|
||||||
env["IDF_CCACHE_ENABLE"] = "1"
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def _ccache_enabled() -> bool:
|
|
||||||
"""Whether ESP-IDF compiles run under ccache.
|
|
||||||
|
|
||||||
IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
|
||||||
ESPHOME_CCACHE_ENABLE; when unset, enabled iff a runnable binary is
|
|
||||||
on PATH.
|
|
||||||
"""
|
"""
|
||||||
|
# IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
||||||
|
# ESPHOME_CCACHE_ENABLE.
|
||||||
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
|
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
|
||||||
if idf_knob is False:
|
if idf_knob is False:
|
||||||
return False
|
# The raw value (e.g. "disable") is still inherited by idf.py via
|
||||||
|
# os.environ, where a non-false-constant string reads as truthy;
|
||||||
|
# export the canonical off spelling instead
|
||||||
|
return {"IDF_CCACHE_ENABLE": "0"}
|
||||||
if idf_knob is True:
|
if idf_knob is True:
|
||||||
# Forced on ignores the runnability verdict, but the outcome is
|
# Forced on ignores the runnability verdict, but the outcome is
|
||||||
# worth saying out loud. Probed directly (not via the resolver,
|
# worth saying out loud. Probed directly (not via the resolver,
|
||||||
@@ -1252,8 +1233,17 @@ def _ccache_enabled() -> bool:
|
|||||||
"IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though "
|
"IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though "
|
||||||
"it failed to run; idf.py will use it anyway",
|
"it failed to run; idf.py will use it anyway",
|
||||||
)
|
)
|
||||||
return True
|
elif resolve_ccache_path() is None:
|
||||||
return resolve_ccache_path() is not None
|
# ESP-IDF silently skips ccache without the binary; export the
|
||||||
|
# canonical off spelling so an unparsable inherited value (or a
|
||||||
|
# probe-rejected ccache idf.py would still find) cannot enable it
|
||||||
|
return {"IDF_CCACHE_ENABLE": "0"}
|
||||||
|
|
||||||
|
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
||||||
|
# Exactly one canonical spelling ever reaches idf.py, whatever the
|
||||||
|
# accepted input spelling was ("enable", "yes", ...)
|
||||||
|
env["IDF_CCACHE_ENABLE"] = "1"
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
def get_framework_env(
|
def get_framework_env(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""ESP-IDF direct build API for ESPHome."""
|
"""ESP-IDF direct build API for ESPHome."""
|
||||||
|
|
||||||
from contextlib import suppress
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -529,20 +528,6 @@ def run_compile(config, verbose: bool) -> int:
|
|||||||
return result.returncode
|
return result.returncode
|
||||||
_patch_memory_segments()
|
_patch_memory_segments()
|
||||||
|
|
||||||
# After every reconfigure so compile_commands and sdkconfig are settled.
|
|
||||||
# An optional speedup must never abort the build
|
|
||||||
from esphome.build_gen.espidf import discard_pch, prepare_pch
|
|
||||||
|
|
||||||
try:
|
|
||||||
prepare_pch()
|
|
||||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
|
||||||
# Discard so a stale .gch can never be consumed
|
|
||||||
with suppress(OSError):
|
|
||||||
discard_pch()
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Precompiled header setup failed; compiling without it", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
args = []
|
args = []
|
||||||
|
|
||||||
|
|||||||
+109
-13
@@ -202,6 +202,49 @@ class OTANetworkError(OTAError):
|
|||||||
"""Network-level OTA failure (timeout, reset, closed connection); retrying may succeed."""
|
"""Network-level OTA failure (timeout, reset, closed connection); retrying may succeed."""
|
||||||
|
|
||||||
|
|
||||||
|
# Remove before 2027.3.0
|
||||||
|
class OTAEncryptionFallback(OTAError):
|
||||||
|
"""The encrypted attempt failed and the caller may retry in plaintext."""
|
||||||
|
|
||||||
|
|
||||||
|
# Remove before 2027.3.0
|
||||||
|
PLAINTEXT_FALLBACK_NOTICE = (
|
||||||
|
"A device with an api encryption key offers encryption after this "
|
||||||
|
"install; add 'encryption:' under 'ota: platform: esphome' to require it. "
|
||||||
|
"This plaintext fallback is removed in 2027.3.0."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Remove before 2027.3.0
|
||||||
|
class _EncryptionAttempt:
|
||||||
|
"""The key an upload tries and whether it may fall back to plaintext;
|
||||||
|
a rejected handshake falls back at once, a transport fault only on repeat."""
|
||||||
|
|
||||||
|
def __init__(self, noise_psk: str | None, plaintext_fallback: bool) -> None:
|
||||||
|
self.noise_psk = noise_psk
|
||||||
|
self.plaintext_fallback = plaintext_fallback
|
||||||
|
self.handshake_faults = 0
|
||||||
|
|
||||||
|
def handshake_fault_falls_back(self) -> bool:
|
||||||
|
self.handshake_faults += 1
|
||||||
|
return self.plaintext_fallback and self.handshake_faults >= 2
|
||||||
|
|
||||||
|
def downgrade(self, reason: str) -> None:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s. Retrying in plaintext; a device that requires encryption "
|
||||||
|
"refuses it. %s",
|
||||||
|
reason,
|
||||||
|
PLAINTEXT_FALLBACK_NOTICE,
|
||||||
|
)
|
||||||
|
self.noise_psk = None
|
||||||
|
self.plaintext_fallback = False
|
||||||
|
|
||||||
|
|
||||||
|
# Remove before 2027.3.0: only the fallback decision needs this distinction
|
||||||
|
class OTAHandshakeNetworkError(OTANetworkError):
|
||||||
|
"""A transport failure inside the noise handshake; retrying encrypted may succeed."""
|
||||||
|
|
||||||
|
|
||||||
def _committed_error(err: OTANetworkError) -> OTAError:
|
def _committed_error(err: OTANetworkError) -> OTAError:
|
||||||
"""Wrap a network failure that happened once the device had the full image.
|
"""Wrap a network failure that happened once the device had the full image.
|
||||||
|
|
||||||
@@ -464,6 +507,7 @@ def perform_ota(
|
|||||||
filename: Path,
|
filename: Path,
|
||||||
ota_type: int = OTA_TYPE_UPDATE_APP,
|
ota_type: int = OTA_TYPE_UPDATE_APP,
|
||||||
noise_psk: str | None = None,
|
noise_psk: str | None = None,
|
||||||
|
plaintext_fallback: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Validate up front; an out-of-range value would only surface as a
|
# Validate up front; an out-of-range value would only surface as a
|
||||||
# ValueError deep inside send_check, bypassing OTAError handling
|
# ValueError deep inside send_check, bypassing OTAError handling
|
||||||
@@ -528,19 +572,28 @@ def perform_ota(
|
|||||||
else:
|
else:
|
||||||
features = 0
|
features = 0
|
||||||
|
|
||||||
if noise_psk:
|
if noise_psk and not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE):
|
||||||
# Fail closed: never fall back to a plaintext upload when an
|
if plaintext_fallback:
|
||||||
# encryption key is configured, an active attacker could otherwise
|
# Remove before 2027.3.0: older firmware that cannot encrypt still
|
||||||
# strip the feature flag and capture the image (it contains the wifi
|
# gets its update on this connection
|
||||||
# credentials and the api encryption key).
|
_LOGGER.warning(
|
||||||
if not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE):
|
"The device did not offer OTA encryption; continuing in plaintext. %s",
|
||||||
|
PLAINTEXT_FALLBACK_NOTICE,
|
||||||
|
)
|
||||||
|
noise_psk = None
|
||||||
|
else:
|
||||||
|
# Fail closed: an attacker could otherwise strip the offer and
|
||||||
|
# capture the image (wifi credentials, api key)
|
||||||
raise OTAError(
|
raise OTAError(
|
||||||
"An OTA encryption key is configured but the device did not "
|
"An OTA encryption key is configured but the device did not "
|
||||||
"offer encryption; refusing to send the image in plaintext. "
|
"offer encryption; refusing to send the image in plaintext. "
|
||||||
"If the running firmware predates OTA encryption, first update "
|
"The running firmware predates ESPHome 2026.9.0 or has no "
|
||||||
"it without the 'ota: encryption:' block (over a trusted "
|
"'api: encryption: key'. With an api key, install once "
|
||||||
"network or via USB), then restore the block and upload again."
|
"without the 'ota: encryption:' block (that build offers "
|
||||||
|
"encryption), then restore it; otherwise flash by serial or "
|
||||||
|
"the web_server OTA platform."
|
||||||
)
|
)
|
||||||
|
if noise_psk:
|
||||||
# The prologue binds every negotiation byte both sides saw, so any
|
# The prologue binds every negotiation byte both sides saw, so any
|
||||||
# tampering with the plaintext preamble breaks the handshake.
|
# tampering with the plaintext preamble breaks the handshake.
|
||||||
prologue = (
|
prologue = (
|
||||||
@@ -549,8 +602,18 @@ def perform_ota(
|
|||||||
+ bytes([RESPONSE_OK, version, features_to_send])
|
+ bytes([RESPONSE_OK, version, features_to_send])
|
||||||
+ bytes([RESPONSE_FEATURE_FLAGS, features])
|
+ bytes([RESPONSE_FEATURE_FLAGS, features])
|
||||||
)
|
)
|
||||||
|
# Built outside the try: a local failure must never downgrade the upload
|
||||||
sock = NoiseSocketWrapper(sock, noise_psk, prologue)
|
sock = NoiseSocketWrapper(sock, noise_psk, prologue)
|
||||||
sock.do_handshake()
|
try:
|
||||||
|
sock.do_handshake()
|
||||||
|
except OTANetworkError as err:
|
||||||
|
# A transport fault: retry encrypted before considering plaintext
|
||||||
|
raise OTAHandshakeNetworkError(str(err)) from err
|
||||||
|
except OTAError as err:
|
||||||
|
# Remove before 2027.3.0
|
||||||
|
if plaintext_fallback:
|
||||||
|
raise OTAEncryptionFallback(str(err)) from err
|
||||||
|
raise
|
||||||
_LOGGER.info("Encrypted connection established")
|
_LOGGER.info("Encrypted connection established")
|
||||||
|
|
||||||
if ota_type != OTA_TYPE_UPDATE_APP:
|
if ota_type != OTA_TYPE_UPDATE_APP:
|
||||||
@@ -757,6 +820,7 @@ def run_ota_impl_(
|
|||||||
filename: Path,
|
filename: Path,
|
||||||
ota_type: int = OTA_TYPE_UPDATE_APP,
|
ota_type: int = OTA_TYPE_UPDATE_APP,
|
||||||
noise_psk: str | None = None,
|
noise_psk: str | None = None,
|
||||||
|
plaintext_fallback: bool = False,
|
||||||
) -> tuple[int, str | None]:
|
) -> tuple[int, str | None]:
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
|
|
||||||
@@ -795,7 +859,9 @@ def run_ota_impl_(
|
|||||||
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
|
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
|
||||||
last_error = ""
|
last_error = ""
|
||||||
reached_device = False
|
reached_device = False
|
||||||
for attempt in range(total_attempts):
|
attempt = 0
|
||||||
|
encryption = _EncryptionAttempt(noise_psk, plaintext_fallback)
|
||||||
|
while attempt < total_attempts:
|
||||||
af, socktype, _, _, sa = res[attempt % len(res)]
|
af, socktype, _, _, sa = res[attempt % len(res)]
|
||||||
if reached_device or attempt >= len(res):
|
if reached_device or attempt >= len(res):
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
@@ -815,17 +881,40 @@ def run_ota_impl_(
|
|||||||
sock.close()
|
sock.close()
|
||||||
_LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err)
|
_LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err)
|
||||||
last_error = f"connecting to {sa[0]} failed: {err}"
|
last_error = f"connecting to {sa[0]} failed: {err}"
|
||||||
|
attempt += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_LOGGER.info("Connected to %s", sa[0])
|
_LOGGER.info("Connected to %s", sa[0])
|
||||||
reached_device = True
|
reached_device = True
|
||||||
with contextlib.closing(sock), Path(filename).open("rb") as file_handle:
|
with contextlib.closing(sock), Path(filename).open("rb") as file_handle:
|
||||||
try:
|
try:
|
||||||
perform_ota(sock, password, file_handle, filename, ota_type, noise_psk)
|
perform_ota(
|
||||||
|
sock,
|
||||||
|
password,
|
||||||
|
file_handle,
|
||||||
|
filename,
|
||||||
|
ota_type,
|
||||||
|
encryption.noise_psk,
|
||||||
|
encryption.plaintext_fallback,
|
||||||
|
)
|
||||||
|
except OTAEncryptionFallback as err:
|
||||||
|
# Same address and attempt budget: not a network retry
|
||||||
|
last_error = str(err)
|
||||||
|
encryption.downgrade(last_error)
|
||||||
|
continue
|
||||||
|
except OTAHandshakeNetworkError as err:
|
||||||
|
last_error = str(err)
|
||||||
|
if encryption.handshake_fault_falls_back():
|
||||||
|
encryption.downgrade(last_error)
|
||||||
|
continue
|
||||||
|
_LOGGER.warning("%s", last_error)
|
||||||
|
attempt += 1
|
||||||
|
continue
|
||||||
except OTANetworkError as err:
|
except OTANetworkError as err:
|
||||||
# Transient network failure; retry
|
# Transient network failure; retry
|
||||||
last_error = str(err)
|
last_error = str(err)
|
||||||
_LOGGER.warning("%s", last_error)
|
_LOGGER.warning("%s", last_error)
|
||||||
|
attempt += 1
|
||||||
continue
|
continue
|
||||||
except OTAError as err:
|
except OTAError as err:
|
||||||
# Device-reported error (wrong password, wrong flash size, ...);
|
# Device-reported error (wrong password, wrong flash size, ...);
|
||||||
@@ -847,10 +936,17 @@ def run_ota(
|
|||||||
filename: Path,
|
filename: Path,
|
||||||
ota_type: int = OTA_TYPE_UPDATE_APP,
|
ota_type: int = OTA_TYPE_UPDATE_APP,
|
||||||
noise_psk: str | None = None,
|
noise_psk: str | None = None,
|
||||||
|
plaintext_fallback: bool = False,
|
||||||
) -> tuple[int, str | None]:
|
) -> tuple[int, str | None]:
|
||||||
try:
|
try:
|
||||||
return run_ota_impl_(
|
return run_ota_impl_(
|
||||||
remote_host, remote_port, password, filename, ota_type, noise_psk
|
remote_host,
|
||||||
|
remote_port,
|
||||||
|
password,
|
||||||
|
filename,
|
||||||
|
ota_type,
|
||||||
|
noise_psk,
|
||||||
|
plaintext_fallback,
|
||||||
)
|
)
|
||||||
except OTAError as err:
|
except OTAError as err:
|
||||||
_LOGGER.error(err)
|
_LOGGER.error(err)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from esphome.net_retry import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from filelock import FileLock
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
PathType = str | os.PathLike
|
PathType = str | os.PathLike
|
||||||
@@ -909,6 +910,61 @@ def _part_path(dest: Path) -> Path:
|
|||||||
return dest.with_name(dest.name + ".part")
|
return dest.with_name(dest.name + ".part")
|
||||||
|
|
||||||
|
|
||||||
|
def downloaded_bytes(dest: Path, size: int | None = None) -> int:
|
||||||
|
"""Bytes of ``dest`` on disk (its ``.part`` while streaming), capped at ``size``."""
|
||||||
|
done = 0
|
||||||
|
for candidate in (_part_path(dest), dest):
|
||||||
|
try:
|
||||||
|
done = candidate.stat().st_size
|
||||||
|
break
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
return done if size is None else min(done, size)
|
||||||
|
|
||||||
|
|
||||||
|
# Short lock-acquire slices so a waiting worker still observes Ctrl-C
|
||||||
|
_DOWNLOAD_LOCK_POLL = 1
|
||||||
|
|
||||||
|
# Waiting on another process's download; past this the caller leaves the
|
||||||
|
# file to its holder (the later sequential install waits on the same lock)
|
||||||
|
DOWNLOAD_LOCK_TIMEOUT = 60
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadLockUnavailable(OSError):
|
||||||
|
"""The lock file cannot be used at all (a lock-less filesystem)."""
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_download_lock(
|
||||||
|
lock: "FileLock",
|
||||||
|
tracker: Callable[[int], None],
|
||||||
|
on_disk: Callable[[], int],
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Acquire ``lock``, reporting ``on_disk()`` to ``tracker`` each poll so the
|
||||||
|
bar follows the holder's download. Raises filelock's ``Timeout`` once
|
||||||
|
``DOWNLOAD_LOCK_TIMEOUT`` seconds pass."""
|
||||||
|
from filelock import Timeout
|
||||||
|
|
||||||
|
deadline = time.monotonic() + DOWNLOAD_LOCK_TIMEOUT
|
||||||
|
waiting = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
lock.acquire(timeout=_DOWNLOAD_LOCK_POLL)
|
||||||
|
return
|
||||||
|
except Timeout:
|
||||||
|
pass
|
||||||
|
except OSError as err:
|
||||||
|
# Distinct from an OSError out of on_disk(), which must not
|
||||||
|
# read as "locks unsupported"
|
||||||
|
raise DownloadLockUnavailable(*err.args) from err
|
||||||
|
if not waiting:
|
||||||
|
waiting = True
|
||||||
|
_LOGGER.info("Waiting for another process downloading %s", name)
|
||||||
|
tracker(on_disk()) # raises when the batch is cancelled
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise Timeout(lock.lock_file)
|
||||||
|
|
||||||
|
|
||||||
def discard_partial_download(dest: Path) -> None:
|
def discard_partial_download(dest: Path) -> None:
|
||||||
"""Remove ``dest`` and the resume sidecars of an abandoned download."""
|
"""Remove ``dest`` and the resume sidecars of an abandoned download."""
|
||||||
part = _part_path(dest)
|
part = _part_path(dest)
|
||||||
@@ -1319,10 +1375,7 @@ def download_from_mirrors(
|
|||||||
)
|
)
|
||||||
# Tick with the bytes already on disk so a combined bar holds
|
# Tick with the bytes already on disk so a combined bar holds
|
||||||
# steady during the backoff instead of rewinding to zero
|
# steady during the backoff instead of rewinding to zero
|
||||||
done = 0
|
done = downloaded_bytes(path_target) if progress is not None else 0
|
||||||
if progress is not None:
|
|
||||||
part = _part_path(path_target)
|
|
||||||
done = part.stat().st_size if part.is_file() else 0
|
|
||||||
_cancellable_sleep(delay, progress, done)
|
_cancellable_sleep(delay, progress, done)
|
||||||
|
|
||||||
# 3. Report every attempted URL if all mirrors failed. failures spans
|
# 3. Report every attempted URL if all mirrors failed. failures spans
|
||||||
|
|||||||
@@ -1,364 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import posixpath
|
|
||||||
import re
|
|
||||||
import shlex
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
# pylint: disable=E0602
|
|
||||||
Import("env") # noqa: F821
|
|
||||||
_projenv_error = None
|
|
||||||
try:
|
|
||||||
Import("projenv") # noqa: F821
|
|
||||||
except Exception as err: # noqa: BLE001 -- not exported under -t nobuild
|
|
||||||
projenv = None
|
|
||||||
_projenv_error = err
|
|
||||||
|
|
||||||
# Precompile the src force-includes plus defines.h and force-include the
|
|
||||||
# result into C++ src compiles only; their preprocessed output is unchanged.
|
|
||||||
# Registration is gated host-side (pch_enabled()). Keep the closure, ccache
|
|
||||||
# values, and stamp flow in sync with build_helpers/pch.py.
|
|
||||||
|
|
||||||
# Compiler failures that clear on their own must not latch the .failed marker
|
|
||||||
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
|
|
||||||
|
|
||||||
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
|
|
||||||
_CORE_HEADER = "esphome/core/defines.h"
|
|
||||||
|
|
||||||
|
|
||||||
def _raise_walk_error(err: OSError) -> None:
|
|
||||||
raise err
|
|
||||||
|
|
||||||
|
|
||||||
def _resolves_dir(path: Path) -> bool:
|
|
||||||
"""False when missing; other stat failures propagate."""
|
|
||||||
try:
|
|
||||||
return stat.S_ISDIR(path.stat().st_mode)
|
|
||||||
except (FileNotFoundError, NotADirectoryError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _resolves(path: Path) -> bool:
|
|
||||||
"""False when missing; other stat failures propagate (identity unknown)."""
|
|
||||||
try:
|
|
||||||
return stat.S_ISREG(path.stat().st_mode)
|
|
||||||
except (FileNotFoundError, NotADirectoryError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _include_closure(src_dir: Path, roots: list) -> dict:
|
|
||||||
"""Quoted-include closure: src-relative name -> contents (mirror of
|
|
||||||
build_helpers/pch.py)."""
|
|
||||||
seen = {}
|
|
||||||
stack = [(name, "") for name in roots]
|
|
||||||
while stack:
|
|
||||||
name, from_dir = stack.pop()
|
|
||||||
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
|
|
||||||
rel = posixpath.normpath(candidate)
|
|
||||||
if not rel.startswith("..") and _resolves(src_dir / rel):
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if rel in seen:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
data = (src_dir / rel).read_bytes()
|
|
||||||
except OSError as err:
|
|
||||||
# A marker would truncate the transitive walk; fail closed
|
|
||||||
print(f"ESPHome: could not read {rel} for the pch checksum: {err}")
|
|
||||||
raise
|
|
||||||
seen[rel] = data
|
|
||||||
parent = posixpath.dirname(rel)
|
|
||||||
stack.extend(
|
|
||||||
(inc.decode(errors="surrogateescape"), parent)
|
|
||||||
for inc in _INCLUDE_RE.findall(data)
|
|
||||||
)
|
|
||||||
return seen
|
|
||||||
|
|
||||||
|
|
||||||
def _shell_arg(element) -> str | None:
|
|
||||||
"""One compiler argv from one SCons element, matching the real spawn:
|
|
||||||
spaced elements pass whole, the rest get one shell unquote (skipped on
|
|
||||||
Windows, where shlex would eat path backslashes)."""
|
|
||||||
arg = str(element)
|
|
||||||
if " " in arg or os.name == "nt":
|
|
||||||
return arg.replace('\\"', '"')
|
|
||||||
if not arg.strip():
|
|
||||||
return arg
|
|
||||||
try:
|
|
||||||
tokens = shlex.split(arg)
|
|
||||||
except ValueError as err:
|
|
||||||
print(f"ESPHome: could not lex flag {arg!r} for the pch: {err}")
|
|
||||||
return None
|
|
||||||
if len(tokens) != 1:
|
|
||||||
# A flag the model cannot reproduce would diverge the .gch's flags
|
|
||||||
print(f"ESPHome: cannot model flag {arg!r} for the pch")
|
|
||||||
return None
|
|
||||||
return tokens[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
|
|
||||||
"""Compile the .gch, then probe that the toolchain can load it back
|
|
||||||
(GCC 10 on macOS arm64 rejects its own per-process). Returns an error
|
|
||||||
string or None; OSError propagates as transient."""
|
|
||||||
result = subprocess.run( # noqa: PLW1510
|
|
||||||
[cxx, "-x", "c++-header", *flags, "-c", str(header), "-o", str(gch)],
|
|
||||||
cwd=proj_dir,
|
|
||||||
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
|
|
||||||
env={**os.environ, "LC_ALL": "C"},
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if result.returncode < 0:
|
|
||||||
# Signal-killed (OOM, ^C): route to the transient no-marker path
|
|
||||||
raise OSError(f"compiler killed by signal {-result.returncode}")
|
|
||||||
if result.returncode != 0:
|
|
||||||
return result.stderr
|
|
||||||
# -MF is only legal alongside a dependency flag; pass it solely to
|
|
||||||
# redirect a depfile that -MD/-MMD in the flags would otherwise write
|
|
||||||
dep_redirect = (
|
|
||||||
["-MF", os.devnull]
|
|
||||||
if any(f in ("-MD", "-MMD", "-M", "-MM") for f in flags)
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
probe = subprocess.run( # noqa: PLW1510
|
|
||||||
[
|
|
||||||
cxx,
|
|
||||||
*flags,
|
|
||||||
*dep_redirect,
|
|
||||||
"-Winvalid-pch",
|
|
||||||
"-include",
|
|
||||||
str(header),
|
|
||||||
"-fsyntax-only",
|
|
||||||
"-x",
|
|
||||||
"c++",
|
|
||||||
"-",
|
|
||||||
],
|
|
||||||
cwd=proj_dir,
|
|
||||||
env={**os.environ, "LC_ALL": "C"},
|
|
||||||
input="",
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if probe.returncode < 0:
|
|
||||||
raise OSError(f"probe killed by signal {-probe.returncode}")
|
|
||||||
if probe.returncode != 0 or ".gch" in probe.stderr:
|
|
||||||
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _read_stamp(path: Path) -> str:
|
|
||||||
"""A corrupt sidecar must read as stale, not kill the pch forever."""
|
|
||||||
try:
|
|
||||||
return path.read_text(encoding="utf-8").strip()
|
|
||||||
except (OSError, UnicodeDecodeError):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _setup_pch() -> None:
|
|
||||||
if projenv is None:
|
|
||||||
# Expected under -t nobuild; anything else must leave a trail
|
|
||||||
print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch")
|
|
||||||
return
|
|
||||||
# Project root: SCons compiles run here, so the relative -include
|
|
||||||
# resolves; an absolute path would break cross-device ccache sharing.
|
|
||||||
proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
|
|
||||||
src_dir = Path(env.subst("$PROJECT_SRC_DIR")) # noqa: F821
|
|
||||||
header = proj_dir / "esphome_pch.h"
|
|
||||||
gch = Path(f"{header}.gch")
|
|
||||||
sum_path = Path(f"{gch}.sum")
|
|
||||||
|
|
||||||
cxx = projenv.subst("$CXX") # noqa: F821
|
|
||||||
# The header holds the -include entries itself, so the .gch compile must
|
|
||||||
# not see them; consumers keep theirs, which the .gch then satisfies.
|
|
||||||
flags = []
|
|
||||||
include_headers = []
|
|
||||||
raw_args = [
|
|
||||||
_shell_arg(element)
|
|
||||||
for element in projenv.subst_list("$CXXFLAGS $CCFLAGS $_CCCOMCOM")[0] # noqa: F821
|
|
||||||
]
|
|
||||||
if any(arg is None for arg in raw_args):
|
|
||||||
print("ESPHome: skipping precompiled header: unmodelable flag")
|
|
||||||
return
|
|
||||||
flag_it = iter(raw_args)
|
|
||||||
for tok in flag_it:
|
|
||||||
if tok == "-include":
|
|
||||||
include_headers.append(next(flag_it, ""))
|
|
||||||
elif tok.startswith("-include") and not tok.startswith("-include-"):
|
|
||||||
include_headers.append(tok[len("-include") :])
|
|
||||||
else:
|
|
||||||
flags.append(tok)
|
|
||||||
if any(not name for name in include_headers):
|
|
||||||
print("ESPHome: build_src_flags has a trailing -include; skipping pch")
|
|
||||||
return
|
|
||||||
# Fold only relative names resolving under src/: consumers keep their
|
|
||||||
# own -include entries, so folding an unguarded user header would
|
|
||||||
# include it twice; unfolded ones stay consumer-only.
|
|
||||||
try:
|
|
||||||
folded = [
|
|
||||||
name
|
|
||||||
for name in include_headers
|
|
||||||
if not Path(name).is_absolute() and _resolves(src_dir / name)
|
|
||||||
]
|
|
||||||
except OSError as err:
|
|
||||||
print(f"ESPHome: skipping precompiled header: {err}")
|
|
||||||
return
|
|
||||||
if unfolded := [n for n in include_headers if n not in folded]:
|
|
||||||
print(f"ESPHome: not precompiling non-src force-includes: {unfolded}")
|
|
||||||
content = "".join(f'#include "{name}"\n' for name in (*folded, _CORE_HEADER))
|
|
||||||
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
digest.update(content.encode(errors="surrogateescape"))
|
|
||||||
digest.update(cxx.encode(errors="surrogateescape"))
|
|
||||||
# Mirror CCACHE_BASEDIR: strip the per-device build path so identical
|
|
||||||
# configs produce identical .sum files and share cache entries
|
|
||||||
flags_id = " ".join(flags)
|
|
||||||
if basedir := os.environ.get("CCACHE_BASEDIR"):
|
|
||||||
flags_id = flags_id.replace(basedir, "")
|
|
||||||
digest.update(flags_id.encode(errors="surrogateescape"))
|
|
||||||
# GCC never validates a .gch against its source headers, and PlatformIO
|
|
||||||
# package paths carry no version, so a package bump must invalidate here
|
|
||||||
platform = env.PioPlatform() # noqa: F821
|
|
||||||
for package in sorted(platform.packages):
|
|
||||||
try:
|
|
||||||
version = platform.get_package_version(package)
|
|
||||||
except KeyError:
|
|
||||||
# An unresolved manifest must not hash as a constant
|
|
||||||
if platform.get_package(package) is not None:
|
|
||||||
print(f"ESPHome: skipping precompiled header: no version for {package}")
|
|
||||||
return
|
|
||||||
version = None # absent optional package
|
|
||||||
except Exception as err: # noqa: BLE001
|
|
||||||
# No trustworthy package identity: a stale .gch could survive
|
|
||||||
print(f"ESPHome: skipping precompiled header: {err}")
|
|
||||||
return
|
|
||||||
digest.update(f"{package}={version}".encode())
|
|
||||||
digest.update(b"\0")
|
|
||||||
try:
|
|
||||||
closure = _include_closure(src_dir, [*folded, _CORE_HEADER])
|
|
||||||
except OSError as err:
|
|
||||||
print(f"ESPHome: skipping precompiled header: {err}")
|
|
||||||
return
|
|
||||||
for rel in sorted(closure):
|
|
||||||
digest.update(rel.encode(errors="surrogateescape"))
|
|
||||||
digest.update(closure[rel])
|
|
||||||
digest.update(b"\0")
|
|
||||||
# Project-local -I dirs (e.g. rp2's lwip_override) hold generated
|
|
||||||
# headers the src closure cannot see; hash them too
|
|
||||||
prev = ""
|
|
||||||
try:
|
|
||||||
for tok in flags:
|
|
||||||
inc = tok[2:] if tok.startswith("-I") and len(tok) > 2 else ""
|
|
||||||
if prev == "-I":
|
|
||||||
inc = tok
|
|
||||||
prev = tok
|
|
||||||
if not inc:
|
|
||||||
continue
|
|
||||||
inc_dir = Path(inc)
|
|
||||||
if not (
|
|
||||||
_resolves_dir(inc_dir)
|
|
||||||
and inc_dir.is_relative_to(proj_dir)
|
|
||||||
and not inc_dir.is_relative_to(src_dir)
|
|
||||||
# Library trees never enter the prefix closure; walking them
|
|
||||||
# would read every library file each build
|
|
||||||
and not inc_dir.is_relative_to(proj_dir / ".piolibdeps")
|
|
||||||
and not inc_dir.is_relative_to(proj_dir / ".pioenvs")
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
local_headers = []
|
|
||||||
# os.walk with onerror: rglob would swallow unlistable subtrees
|
|
||||||
for root, _dirs, files in os.walk(inc_dir, onerror=_raise_walk_error):
|
|
||||||
local_headers.extend(
|
|
||||||
Path(root) / f
|
|
||||||
for f in files
|
|
||||||
if f.endswith((".h", ".hpp", ".hh", ".inc"))
|
|
||||||
)
|
|
||||||
for local in sorted(local_headers):
|
|
||||||
digest.update(str(local.relative_to(proj_dir)).encode())
|
|
||||||
digest.update(local.read_bytes())
|
|
||||||
digest.update(b"\0")
|
|
||||||
except OSError as err:
|
|
||||||
print(f"ESPHome: skipping precompiled header: {err}")
|
|
||||||
return
|
|
||||||
checksum = digest.hexdigest()
|
|
||||||
|
|
||||||
# The ccache .sum sidecar doubles as the freshness stamp
|
|
||||||
if (
|
|
||||||
not header.is_file()
|
|
||||||
or not gch.is_file()
|
|
||||||
or not sum_path.is_file()
|
|
||||||
or (_read_stamp(sum_path) != checksum)
|
|
||||||
):
|
|
||||||
failed_marker = Path(f"{gch}.failed")
|
|
||||||
if _read_stamp(failed_marker) == checksum:
|
|
||||||
print(
|
|
||||||
"ESPHome: skipping precompiled header (previous attempt "
|
|
||||||
f"failed); delete {failed_marker.name} to retry"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
header.write_text(content, encoding="utf-8")
|
|
||||||
try:
|
|
||||||
error = _compile_gch(cxx, flags, header, gch, proj_dir)
|
|
||||||
except OSError as err:
|
|
||||||
# Transient spawn/IO failure: no marker, retry next build
|
|
||||||
print(f"ESPHome: precompiled header compile did not run: {err}")
|
|
||||||
gch.unlink(missing_ok=True)
|
|
||||||
sum_path.unlink(missing_ok=True)
|
|
||||||
return
|
|
||||||
if error is not None:
|
|
||||||
print("ESPHome: precompiled header failed; compiling without it")
|
|
||||||
print(error)
|
|
||||||
gch.unlink(missing_ok=True)
|
|
||||||
sum_path.unlink(missing_ok=True)
|
|
||||||
if any(m in error for m in _TRANSIENT_ERRORS):
|
|
||||||
# Resource exhaustion clears on its own; retry next build
|
|
||||||
return
|
|
||||||
# Skip retries until a flag/header/platform change alters the checksum
|
|
||||||
failed_marker.write_text(checksum + "\n", encoding="utf-8")
|
|
||||||
return
|
|
||||||
failed_marker.unlink(missing_ok=True)
|
|
||||||
sum_path.write_text(checksum + "\n", encoding="utf-8")
|
|
||||||
|
|
||||||
# Computed first so the flags and env land together: a raise between
|
|
||||||
# them would leave a pch-consuming build without its ccache settings.
|
|
||||||
# projenv["ENV"] aliases os.environ, so these reach all TUs; only
|
|
||||||
# time_macros affects non-pch TUs. User values win.
|
|
||||||
ccache_updates = {
|
|
||||||
key: value
|
|
||||||
for key, value in (
|
|
||||||
("CCACHE_SLOPPINESS", "pch_defines,time_macros"),
|
|
||||||
("CCACHE_PCH_EXTSUM", "true"),
|
|
||||||
)
|
|
||||||
if key not in os.environ
|
|
||||||
}
|
|
||||||
sloppiness = os.environ.get("CCACHE_SLOPPINESS")
|
|
||||||
if sloppiness is not None:
|
|
||||||
tokens = {tok.strip() for tok in sloppiness.split(",")}
|
|
||||||
missing = [t for t in ("pch_defines", "time_macros") if t not in tokens]
|
|
||||||
if missing:
|
|
||||||
# Without these ccache declines every pch-consuming compile
|
|
||||||
ccache_updates["CCACHE_SLOPPINESS"] = ",".join((sloppiness, *missing))
|
|
||||||
print(f"ESPHome: adding {','.join(missing)} to CCACHE_SLOPPINESS for the pch")
|
|
||||||
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
|
|
||||||
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
|
|
||||||
# ccache then hashes the non-reproducible .gch bytes: permanent misses
|
|
||||||
print(f"ESPHome: CCACHE_PCH_EXTSUM={extsum} disables pch caching")
|
|
||||||
# Prepended: GCC only uses a .gch while no other tokens precede it.
|
|
||||||
# The relative name also reaches "pio run -t idedata" output.
|
|
||||||
# -Wno-error: the per-process probe can pass while a later cc1plus
|
|
||||||
# rejects the .gch; that must stay a warning under user -Werror.
|
|
||||||
projenv.Prepend( # noqa: F821
|
|
||||||
CXXFLAGS=["-Winvalid-pch", "-Wno-error=invalid-pch", "-include", header.name]
|
|
||||||
)
|
|
||||||
projenv["ENV"].update(ccache_updates) # noqa: F821
|
|
||||||
print("ESPHome: Compiling with precompiled header")
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
_setup_pch()
|
|
||||||
except Exception: # noqa: BLE001 -- a speedup must never break the build
|
|
||||||
print("ESPHome: pch internal error; compiling without it")
|
|
||||||
traceback.print_exc()
|
|
||||||
@@ -33,11 +33,14 @@ import time
|
|||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
from esphome.framework_helpers import (
|
from esphome.framework_helpers import (
|
||||||
|
DownloadLockUnavailable,
|
||||||
content_length,
|
content_length,
|
||||||
discard_partial_download,
|
discard_partial_download,
|
||||||
|
downloaded_bytes,
|
||||||
failure_reason,
|
failure_reason,
|
||||||
resume_fetch_job,
|
resume_fetch_job,
|
||||||
run_batch_downloads,
|
run_batch_downloads,
|
||||||
|
wait_for_download_lock,
|
||||||
warn_prefetch_failures,
|
warn_prefetch_failures,
|
||||||
)
|
)
|
||||||
from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree
|
from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree
|
||||||
@@ -61,16 +64,10 @@ _RESOLVE_WORKERS = 8
|
|||||||
# A hung child must not block the build; downloads resume on the next run
|
# A hung child must not block the build; downloads resume on the next run
|
||||||
_PREFETCH_TIMEOUT = 20 * 60
|
_PREFETCH_TIMEOUT = 20 * 60
|
||||||
|
|
||||||
# Waiting on another process's URL download; past this, leave it to pio
|
|
||||||
_DOWNLOAD_LOCK_TIMEOUT = 60
|
|
||||||
|
|
||||||
# Child exit for a handled, already-warned failure; 1 would collide with
|
# Child exit for a handled, already-warned failure; 1 would collide with
|
||||||
# the interpreter's own import-failure exit
|
# the interpreter's own import-failure exit
|
||||||
_EXIT_HANDLED = 3
|
_EXIT_HANDLED = 3
|
||||||
|
|
||||||
# Short lock-acquire slices so a waiting worker still observes Ctrl-C
|
|
||||||
_URI_LOCK_POLL = 1
|
|
||||||
|
|
||||||
# Resolution errored (vs a clean skip); suppresses the warm sentinel
|
# Resolution errored (vs a clean skip); suppresses the warm sentinel
|
||||||
_RESOLVE_FAILED = object()
|
_RESOLVE_FAILED = object()
|
||||||
|
|
||||||
@@ -462,51 +459,54 @@ def _uri_jobs(
|
|||||||
|
|
||||||
|
|
||||||
def _serialized_fetch_job(
|
def _serialized_fetch_job(
|
||||||
dl_path: Path, lock_path: str, body: Any, unlocked_ok: bool = True
|
dl_path: Path,
|
||||||
|
lock_path: str,
|
||||||
|
body: Any,
|
||||||
|
size: int,
|
||||||
|
stream_dest: Path | None = None,
|
||||||
|
unlocked_ok: bool = True,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Wrap ``body`` so the shared destination is single-writer.
|
"""Wrap ``body`` so the shared destination is single-writer (interleaved
|
||||||
|
writers truncate each other's ``.part``, see registry.py). A blown deadline
|
||||||
Interleaved writers truncate each other's ``.part`` bytes (see
|
is a clean skip. On a lock-less filesystem a sha256-verified body runs
|
||||||
registry.py). The bounded poll observes Ctrl-C via the tracker; a
|
unlocked with one warning; a checksum-less one (``unlocked_ok=False``) fails.
|
||||||
blown deadline is a clean skip (the holder's copy is what the build
|
|
||||||
needs). On a lock-less filesystem a sha256-verified body runs
|
|
||||||
unlocked with one warning; a checksum-less one
|
|
||||||
(``unlocked_ok=False``) is a counted failure instead.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def on_disk() -> int:
|
||||||
|
# A URL job's holder streams beside the staging path until it
|
||||||
|
# promotes; after that only dl_path is left
|
||||||
|
done = downloaded_bytes(dl_path, size)
|
||||||
|
if not done and stream_dest is not None:
|
||||||
|
done = downloaded_bytes(stream_dest, size)
|
||||||
|
return done
|
||||||
|
|
||||||
def run(tracker: Any) -> None:
|
def run(tracker: Any) -> None:
|
||||||
from filelock import FileLock, Timeout
|
from filelock import FileLock, Timeout
|
||||||
|
|
||||||
# fallback_to_soft would leave a stale marker on lock-less
|
# fallback_to_soft would leave a stale marker on lock-less
|
||||||
# filesystems that blocks every later build (see git.py)
|
# filesystems that blocks every later build (see git.py)
|
||||||
lock = FileLock(lock_path, fallback_to_soft=False)
|
lock = FileLock(lock_path, fallback_to_soft=False)
|
||||||
deadline = time.monotonic() + _DOWNLOAD_LOCK_TIMEOUT
|
try:
|
||||||
while True:
|
wait_for_download_lock(lock, tracker, on_disk, dl_path.name)
|
||||||
try:
|
except Timeout:
|
||||||
lock.acquire(timeout=_URI_LOCK_POLL)
|
# The holder's copy is what the build needs (a large
|
||||||
break
|
# framework archive can outlast this deadline)
|
||||||
except Timeout:
|
_LOGGER.debug("Leaving %s to its current downloader", dl_path.name)
|
||||||
tracker(0) # raises when the batch is cancelled
|
return
|
||||||
if time.monotonic() >= deadline:
|
except DownloadLockUnavailable as err:
|
||||||
# Another process is fetching this same file; its copy
|
if not unlocked_ok:
|
||||||
# is what the build needs (a large framework archive
|
# A body with no checksum to catch interleaved corruption
|
||||||
# can hold the lock far longer than this deadline)
|
raise
|
||||||
_LOGGER.debug("Leaving %s to its current downloader", dl_path.name)
|
lock = None
|
||||||
return
|
_LOGGER.warning(
|
||||||
except OSError as err:
|
"Could not lock %s (%s); downloading unlocked",
|
||||||
if not unlocked_ok:
|
dl_path.name,
|
||||||
# A body with no checksum to catch interleaved corruption
|
err,
|
||||||
raise
|
)
|
||||||
lock = None
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Could not lock %s (%s); downloading unlocked",
|
|
||||||
dl_path.name,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
try:
|
try:
|
||||||
if dl_path.is_file():
|
if dl_path.is_file():
|
||||||
return # another process finished it while we waited
|
tracker(size) # another process finished it while we waited
|
||||||
|
return
|
||||||
body(tracker)
|
body(tracker)
|
||||||
finally:
|
finally:
|
||||||
if lock is not None:
|
if lock is not None:
|
||||||
@@ -540,6 +540,7 @@ def _registry_fetch_job(
|
|||||||
dl_path,
|
dl_path,
|
||||||
f"{dl_path}.esphome.lock",
|
f"{dl_path}.esphome.lock",
|
||||||
resume_fetch_job(url, dl_path, sha256=checksum, size=size),
|
resume_fetch_job(url, dl_path, sha256=checksum, size=size),
|
||||||
|
size,
|
||||||
)
|
)
|
||||||
|
|
||||||
def run(tracker: Any) -> None:
|
def run(tracker: Any) -> None:
|
||||||
@@ -571,9 +572,9 @@ def _uri_fetch_job(manager: Any, url: str, dl_path: Path, size: int) -> Any:
|
|||||||
tmp.replace(dl_path)
|
tmp.replace(dl_path)
|
||||||
|
|
||||||
def run(tracker: Any) -> None:
|
def run(tracker: Any) -> None:
|
||||||
_serialized_fetch_job(dl_path, f"{tmp}.lock", promote, unlocked_ok=False)(
|
_serialized_fetch_job(
|
||||||
tracker
|
dl_path, f"{tmp}.lock", promote, size, tmp, unlocked_ok=False
|
||||||
)
|
)(tracker)
|
||||||
if dl_path.is_file():
|
if dl_path.is_file():
|
||||||
# Won or lost, the race is over; staging files left behind
|
# Won or lost, the race is over; staging files left behind
|
||||||
# are dead weight PlatformIO's cache never prunes
|
# are dead weight PlatformIO's cache never prunes
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ from esphome.framework_helpers import (
|
|||||||
archive_extract_all,
|
archive_extract_all,
|
||||||
download_from_mirrors,
|
download_from_mirrors,
|
||||||
download_with_resume,
|
download_with_resume,
|
||||||
|
downloaded_bytes,
|
||||||
rmdir,
|
rmdir,
|
||||||
run_batch_downloads,
|
run_batch_downloads,
|
||||||
|
wait_for_download_lock,
|
||||||
)
|
)
|
||||||
from esphome.net_retry import fetch_with_retry, http_request
|
from esphome.net_retry import fetch_with_retry, http_request
|
||||||
|
|
||||||
@@ -164,11 +166,17 @@ class _PendingArchive(NamedTuple):
|
|||||||
name: str
|
name: str
|
||||||
version: str
|
version: str
|
||||||
dest: Path
|
dest: Path
|
||||||
|
archive: Path
|
||||||
url: str
|
url: str
|
||||||
sha256: str
|
sha256: str
|
||||||
size: int
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_path(downloads_dir: Path, name: str, version: str) -> Path:
|
||||||
|
"""The one archive path the prefetch and the sequential install share."""
|
||||||
|
return downloads_dir / f"{name}-{version}"
|
||||||
|
|
||||||
|
|
||||||
def _already_installed(dest: Path) -> bool:
|
def _already_installed(dest: Path) -> bool:
|
||||||
"""Whether ``dest`` holds a completed install (extraction marker)."""
|
"""Whether ``dest`` holds a completed install (extraction marker)."""
|
||||||
return (dest / ".esphome_extracted").is_file()
|
return (dest / ".esphome_extracted").is_file()
|
||||||
@@ -187,18 +195,18 @@ def prefetch_packages(
|
|||||||
lock as ``install_package``: the archive's ``.part`` file is shared, and
|
lock as ``install_package``: the archive's ``.part`` file is shared, and
|
||||||
two concurrent writers would truncate each other's bytes.
|
two concurrent writers would truncate each other's bytes.
|
||||||
"""
|
"""
|
||||||
from filelock import FileLock
|
from filelock import FileLock, Timeout
|
||||||
|
|
||||||
pending: list[_PendingArchive] = []
|
pending: list[_PendingArchive] = []
|
||||||
seen: set[str] = set()
|
seen: set[Path] = set()
|
||||||
for name, version, dest, mirrors in packages:
|
for name, version, dest, mirrors in packages:
|
||||||
if mirrors or (dest / ".esphome_extracted").is_file():
|
if mirrors or (dest / ".esphome_extracted").is_file():
|
||||||
continue
|
continue
|
||||||
archive_name = f"{name}-{version}"
|
archive = _archive_path(downloads_dir, name, version)
|
||||||
if archive_name in seen:
|
if archive in seen:
|
||||||
# A duplicate entry would race itself between two workers
|
# A duplicate entry would race itself between two workers
|
||||||
continue
|
continue
|
||||||
seen.add(archive_name)
|
seen.add(archive)
|
||||||
try:
|
try:
|
||||||
url, sha256, size = registry_download(name, version)
|
url, sha256, size = registry_download(name, version)
|
||||||
except EsphomeError as err:
|
except EsphomeError as err:
|
||||||
@@ -207,10 +215,9 @@ def prefetch_packages(
|
|||||||
continue
|
continue
|
||||||
if not size:
|
if not size:
|
||||||
continue
|
continue
|
||||||
archive = downloads_dir / archive_name
|
|
||||||
if archive.is_file() and archive.stat().st_size == size:
|
if archive.is_file() and archive.stat().st_size == size:
|
||||||
continue
|
continue
|
||||||
pending.append(_PendingArchive(name, version, dest, url, sha256, size))
|
pending.append(_PendingArchive(name, version, dest, archive, url, sha256, size))
|
||||||
if len(pending) < 2:
|
if len(pending) < 2:
|
||||||
return
|
return
|
||||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -222,20 +229,36 @@ def prefetch_packages(
|
|||||||
|
|
||||||
def _fetch(entry: _PendingArchive, tracker: Callable[[int], None]) -> None:
|
def _fetch(entry: _PendingArchive, tracker: Callable[[int], None]) -> None:
|
||||||
entry.dest.parent.mkdir(parents=True, exist_ok=True)
|
entry.dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with FileLock(f"{entry.dest}.lock", fallback_to_soft=False):
|
|
||||||
# Marker re-check: a concurrent build may have installed (and
|
def on_disk() -> int:
|
||||||
# deleted the archive of) this package while we waited;
|
if done := downloaded_bytes(entry.archive, entry.size):
|
||||||
# re-downloading would orphan a fresh copy in downloads_dir
|
return done
|
||||||
# no branch: the thread tracer misses the skip edge; both
|
# The holder deletes the archive once it has installed it
|
||||||
# arms of _already_installed are pinned directly
|
return entry.size if _already_installed(entry.dest) else 0
|
||||||
if not _already_installed(entry.dest): # pragma: no branch
|
|
||||||
download_with_resume(
|
lock = FileLock(f"{entry.dest}.lock", fallback_to_soft=False)
|
||||||
entry.url,
|
try:
|
||||||
downloads_dir / f"{entry.name}-{entry.version}",
|
wait_for_download_lock(lock, tracker, on_disk, entry.name)
|
||||||
sha256=entry.sha256,
|
except Timeout:
|
||||||
size=entry.size,
|
# install_package waits on this same lock and verifies the
|
||||||
progress=tracker,
|
# holder's copy
|
||||||
)
|
_LOGGER.debug("Leaving %s to its current downloader", entry.name)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if _already_installed(entry.dest):
|
||||||
|
# A concurrent build installed it while we waited; a
|
||||||
|
# re-download would orphan a fresh copy in downloads_dir
|
||||||
|
tracker(entry.size)
|
||||||
|
return
|
||||||
|
download_with_resume(
|
||||||
|
entry.url,
|
||||||
|
entry.archive,
|
||||||
|
sha256=entry.sha256,
|
||||||
|
size=entry.size,
|
||||||
|
progress=tracker,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
failures = run_batch_downloads(
|
failures = run_batch_downloads(
|
||||||
"Downloading packages",
|
"Downloading packages",
|
||||||
@@ -288,7 +311,7 @@ def install_package(
|
|||||||
rmdir(dest, msg=f"Clean up incomplete {name} install")
|
rmdir(dest, msg=f"Clean up incomplete {name} install")
|
||||||
# Persistent location so an interrupted download resumes across runs.
|
# Persistent location so an interrupted download resumes across runs.
|
||||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||||
archive = downloads_dir / f"{name}-{version}"
|
archive = _archive_path(downloads_dir, name, version)
|
||||||
_LOGGER.info("Downloading %s %s ...", name, version)
|
_LOGGER.info("Downloading %s %s ...", name, version)
|
||||||
if mirrors:
|
if mirrors:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
|
|||||||
@@ -289,15 +289,6 @@ def copy_ccache_script() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def copy_pch_script() -> None:
|
|
||||||
"""Copy the shared precompiled-header SCons post-script into the build
|
|
||||||
dir; platform components pair it with ``post:pch.py`` in extra_scripts."""
|
|
||||||
copy_file_if_changed(
|
|
||||||
Path(__file__).parent / "pch.py.script",
|
|
||||||
CORE.relative_build_path("pch.py"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def default_libdeps_dir() -> str:
|
def default_libdeps_dir() -> str:
|
||||||
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
|
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
|
||||||
prefetch must resolve installed libraries against the same dir."""
|
prefetch must resolve installed libraries against the same dir."""
|
||||||
|
|||||||
+4
-14
@@ -148,11 +148,13 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str:
|
|||||||
if "api_encryption_key" in kwargs:
|
if "api_encryption_key" in kwargs:
|
||||||
config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n'
|
config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n'
|
||||||
|
|
||||||
# Configure OTA
|
# The api key also secures OTA; a password only serves older uploaders
|
||||||
config += "\nota:\n"
|
config += "\nota:\n"
|
||||||
config += " - platform: esphome\n"
|
config += " - platform: esphome\n"
|
||||||
if "ota_password" in kwargs:
|
if "ota_password" in kwargs:
|
||||||
config += f' password: "{kwargs["ota_password"]}"'
|
config += f' password: "{kwargs["ota_password"]}"'
|
||||||
|
elif "api_encryption_key" in kwargs:
|
||||||
|
config += " encryption:"
|
||||||
|
|
||||||
# Configuring wifi
|
# Configuring wifi
|
||||||
config += "\n\nwifi:\n"
|
config += "\n\nwifi:\n"
|
||||||
@@ -529,20 +531,9 @@ def wizard(path: Path) -> int:
|
|||||||
safe_print()
|
safe_print()
|
||||||
safe_print("You'll need this key when adding the device to Home Assistant.")
|
safe_print("You'll need this key when adding the device to Home Assistant.")
|
||||||
sleep(1)
|
sleep(1)
|
||||||
|
|
||||||
safe_print()
|
|
||||||
safe_print(
|
|
||||||
f"Do you want to set a {color(AnsiFore.GREEN, 'password')} for OTA updates? "
|
|
||||||
"This can be insecure if you do not trust the WiFi network."
|
|
||||||
)
|
|
||||||
safe_print()
|
|
||||||
sleep(0.25)
|
|
||||||
safe_print("Press ENTER for no password")
|
|
||||||
ota_password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): "))
|
|
||||||
else:
|
else:
|
||||||
ssid, psk = "", ""
|
ssid, psk = "", ""
|
||||||
api_encryption_key = None
|
api_encryption_key = None
|
||||||
ota_password = ""
|
|
||||||
|
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"path": path,
|
"path": path,
|
||||||
@@ -553,10 +544,9 @@ def wizard(path: Path) -> int:
|
|||||||
"psk": psk,
|
"psk": psk,
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
}
|
}
|
||||||
|
# The api key also secures OTA updates, so the wizard sets no OTA password
|
||||||
if api_encryption_key:
|
if api_encryption_key:
|
||||||
kwargs["api_encryption_key"] = api_encryption_key
|
kwargs["api_encryption_key"] = api_encryption_key
|
||||||
if ota_password:
|
|
||||||
kwargs["ota_password"] = ota_password
|
|
||||||
|
|
||||||
if not wizard_write(**kwargs):
|
if not wizard_write(**kwargs):
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
+4
-14
@@ -7,7 +7,6 @@ import re
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from esphome import loader
|
from esphome import loader
|
||||||
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES
|
|
||||||
from esphome.compiled_config import save_compiled_config
|
from esphome.compiled_config import save_compiled_config
|
||||||
from esphome.config import iter_component_configs, iter_components
|
from esphome.config import iter_component_configs, iter_components
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -610,20 +609,11 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False):
|
|||||||
if idf_path.is_dir():
|
if idf_path.is_dir():
|
||||||
_LOGGER.info("Deleting %s", idf_path)
|
_LOGGER.info("Deleting %s", idf_path)
|
||||||
rmtree(idf_path)
|
rmtree(idf_path)
|
||||||
# The PlatformIO pch artifacts live at the project root so the
|
|
||||||
# relative -include resolves; a partial clean must drop them too
|
|
||||||
for name in PCH_ARTIFACT_NAMES:
|
|
||||||
CORE.relative_build_path(name).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
# The idedata caches are derived from the build but live under the data
|
# The idedata cache is derived from the build but lives under the data dir,
|
||||||
# dir, not the build path, so they must be removed separately in both
|
# not the build path, so it must be removed separately in both modes.
|
||||||
# modes. Globbed (name.json plus name.<backend>.json) so a future
|
idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
|
||||||
# backend suffix cannot silently drift out of clean-all.
|
if idedata_cache.is_file():
|
||||||
idedata_dir = CORE.relative_internal_path("idedata")
|
|
||||||
for idedata_cache in (
|
|
||||||
*idedata_dir.glob(f"{CORE.name}.json"),
|
|
||||||
*idedata_dir.glob(f"{CORE.name}.*.json"),
|
|
||||||
):
|
|
||||||
_LOGGER.info("Deleting %s", idedata_cache)
|
_LOGGER.info("Deleting %s", idedata_cache)
|
||||||
idedata_cache.unlink()
|
idedata_cache.unlink()
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -45,7 +45,7 @@ lib_deps_base =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${common.lib_deps_base}
|
${common.lib_deps_base}
|
||||||
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
|
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
|
||||||
esphome/noise-c@0.1.21 ; noise (api, ota)
|
esphome/noise-c@0.1.24 ; noise (api, ota)
|
||||||
improv/Improv@1.2.7 ; improv_serial / esp32_improv
|
improv/Improv@1.2.7 ; improv_serial / esp32_improv
|
||||||
kikuchan98/pngle@1.1.0 ; online_image
|
kikuchan98/pngle@1.1.0 ; online_image
|
||||||
; Using the repository directly, otherwise ESP-IDF can't use the library
|
; Using the repository directly, otherwise ESP-IDF can't use the library
|
||||||
@@ -244,7 +244,7 @@ lib_deps =
|
|||||||
${common:idf-component-libs.lib_deps}
|
${common:idf-component-libs.lib_deps}
|
||||||
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
|
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
|
||||||
droscy/esp_wireguard@0.4.5 ; wireguard
|
droscy/esp_wireguard@0.4.5 ; wireguard
|
||||||
esphome/noise-c@0.1.21 ; noise (api, ota)
|
esphome/noise-c@0.1.24 ; noise (api, ota)
|
||||||
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
|
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
|
||||||
DNSServer ; captive_portal
|
DNSServer ; captive_portal
|
||||||
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
|
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
|
||||||
@@ -641,7 +641,7 @@ build_unflags =
|
|||||||
extends = common
|
extends = common
|
||||||
platform = platformio/native
|
platform = platformio/native
|
||||||
lib_deps =
|
lib_deps =
|
||||||
esphome/noise-c@0.1.21 ; used by noise (api, ota)
|
esphome/noise-c@0.1.24 ; used by noise (api, ota)
|
||||||
lvgl/lvgl@9.5.0 ; lvgl
|
lvgl/lvgl@9.5.0 ; lvgl
|
||||||
build_flags =
|
build_flags =
|
||||||
${common.build_flags}
|
${common.build_flags}
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ bleak==3.0.2
|
|||||||
smpclient==7.2.0
|
smpclient==7.2.0
|
||||||
requests==2.34.2
|
requests==2.34.2
|
||||||
py7zr==1.1.3
|
py7zr==1.1.3
|
||||||
platformdirs==4.11.5 # native esp-idf toolchain global cache dir
|
platformdirs==4.11.7 # native esp-idf toolchain global cache dir
|
||||||
ninja==1.13.2 # native esp8266 arduino toolchain build driver
|
ninja==1.13.2 # native esp8266 arduino toolchain build driver
|
||||||
filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ pylint==4.0.8
|
|||||||
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
|
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
|
||||||
ruff==0.16.5 # also change in .pre-commit-config.yaml when updating
|
ruff==0.16.5 # also change in .pre-commit-config.yaml when updating
|
||||||
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
|
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
|
||||||
prek==0.5.0 # also change in .github/workflows/ci.yml when updating
|
prek==0.5.1 # also change in .github/workflows/ci.yml when updating
|
||||||
|
|
||||||
# Unit tests
|
# Unit tests
|
||||||
pytest==9.1.1
|
pytest==9.1.1
|
||||||
|
|||||||
+16
-1
@@ -294,6 +294,9 @@ def highlight(s):
|
|||||||
"esphome/components/socket/headers.h",
|
"esphome/components/socket/headers.h",
|
||||||
"esphome/core/defines.h",
|
"esphome/core/defines.h",
|
||||||
"esphome/components/http_request/httplib.h",
|
"esphome/components/http_request/httplib.h",
|
||||||
|
# Shared C wire header (byte-identical with the co-processor firmware);
|
||||||
|
# these are protocol constants and constexpr is C++-only.
|
||||||
|
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def lint_no_defines(fname, match):
|
def lint_no_defines(fname, match):
|
||||||
@@ -816,6 +819,10 @@ def lint_relative_py_import(fname: Path, line, col, content):
|
|||||||
"esphome/components/host/helpers.cpp",
|
"esphome/components/host/helpers.cpp",
|
||||||
"esphome/components/zephyr/helpers.cpp",
|
"esphome/components/zephyr/helpers.cpp",
|
||||||
"esphome/components/http_request/httplib.h",
|
"esphome/components/http_request/httplib.h",
|
||||||
|
# Global extern "C" esp_now_* linker symbols + shared C wire header;
|
||||||
|
# neither can live in a C++ namespace.
|
||||||
|
"esphome/components/esp32_hosted/esp_now_hosted.cpp",
|
||||||
|
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def lint_namespace(fname: Path, content: str) -> str | None:
|
def lint_namespace(fname: Path, content: str) -> str | None:
|
||||||
@@ -841,7 +848,15 @@ def lint_esphome_h(fname, line, col, content):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"])
|
@lint_content_check(
|
||||||
|
include=["*.h"],
|
||||||
|
exclude=[
|
||||||
|
"esphome/core/entity_types.h",
|
||||||
|
# Shared C wire header; uses a classic #ifndef guard for portability
|
||||||
|
# across the co-processor firmware repo it stays byte-identical with.
|
||||||
|
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
|
||||||
|
],
|
||||||
|
)
|
||||||
def lint_pragma_once(fname, content):
|
def lint_pragma_once(fname, content):
|
||||||
if "#pragma once" not in content:
|
if "#pragma once" not in content:
|
||||||
return (
|
return (
|
||||||
|
|||||||
+24
-132
@@ -50,7 +50,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from collections.abc import Callable
|
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from functools import cache
|
from functools import cache
|
||||||
import json
|
import json
|
||||||
@@ -532,53 +531,37 @@ ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",)
|
|||||||
# - esphome/build_gen/platformio.py -- the PlatformIO build generator
|
# - esphome/build_gen/platformio.py -- the PlatformIO build generator
|
||||||
# - script/test_build_components.py -- the harness the job invokes
|
# - script/test_build_components.py -- the harness the job invokes
|
||||||
# - .github/workflows/ci.yml -- the job's own definition
|
# - .github/workflows/ci.yml -- the job's own definition
|
||||||
# Shared by every toolchain smoke-test job: the harness it invokes and the
|
ESP32_PLATFORMIO_TRIGGER_FILES = frozenset(
|
||||||
# workflow that defines it
|
|
||||||
_SMOKE_HARNESS_TRIGGER_FILES = frozenset(
|
|
||||||
{
|
{
|
||||||
|
"esphome/build_gen/platformio.py",
|
||||||
"script/test_build_components.py",
|
"script/test_build_components.py",
|
||||||
".github/workflows/ci.yml",
|
".github/workflows/ci.yml",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | {
|
|
||||||
"esphome/build_gen/platformio.py",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _path_or_file_trigger(
|
|
||||||
files: list[str],
|
|
||||||
trigger_files: frozenset[str],
|
|
||||||
trigger_prefixes: tuple[str, ...],
|
|
||||||
) -> bool:
|
|
||||||
"""Whether any changed file matches the given infrastructure triggers."""
|
|
||||||
return any(
|
|
||||||
file in trigger_files or file.startswith(trigger_prefixes) for file in files
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def _cached_components_closure(files: tuple[str, ...]) -> frozenset[str]:
|
|
||||||
"""Dependency closure of the changed components, from the changed files.
|
|
||||||
|
|
||||||
The walk is expensive and every toolchain smoke-test job asks for the
|
|
||||||
same file list, so compute it once per run."""
|
|
||||||
component_files = [f for f in files if filter_component_and_test_files(f)]
|
|
||||||
return frozenset(get_components_with_dependencies(component_files, True))
|
|
||||||
|
|
||||||
|
|
||||||
def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
|
def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
|
||||||
"""Whether any changed file is a PlatformIO infrastructure / harness trigger."""
|
"""Whether any changed file is a PlatformIO infrastructure / harness trigger."""
|
||||||
return _path_or_file_trigger(
|
for file in files:
|
||||||
files, ESP32_PLATFORMIO_TRIGGER_FILES, ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
|
if file in ESP32_PLATFORMIO_TRIGGER_FILES:
|
||||||
)
|
return True
|
||||||
|
if any(
|
||||||
|
file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _esp_idf_infra_changed(files: list[str]) -> bool:
|
def _esp_idf_infra_changed(files: list[str]) -> bool:
|
||||||
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
|
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
|
||||||
return _path_or_file_trigger(
|
for file in files:
|
||||||
files, ESP_IDF_INFRA_TRIGGER_FILES, ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
|
if file in ESP_IDF_INFRA_TRIGGER_FILES:
|
||||||
)
|
return True
|
||||||
|
if any(
|
||||||
|
file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
|
def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
|
||||||
@@ -616,23 +599,15 @@ def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
|
|||||||
Returns:
|
Returns:
|
||||||
Sorted list of component names to compile.
|
Sorted list of component names to compile.
|
||||||
"""
|
"""
|
||||||
return _toolchain_components_to_test(
|
|
||||||
branch, ESP32_PLATFORMIO_TEST_COMPONENTS, _esp32_platformio_path_or_file_trigger
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _toolchain_components_to_test(
|
|
||||||
branch: str | None,
|
|
||||||
test_set: frozenset[str],
|
|
||||||
infra_trigger: Callable[[list[str]], bool],
|
|
||||||
) -> list[str]:
|
|
||||||
"""The shared narrowing rule for the per-toolchain smoke-test jobs."""
|
|
||||||
files = changed_files(branch)
|
files = changed_files(branch)
|
||||||
|
|
||||||
if core_changed(files) or infra_trigger(files):
|
if core_changed(files) or _esp32_platformio_path_or_file_trigger(files):
|
||||||
return sorted(test_set)
|
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
||||||
|
|
||||||
return sorted(test_set & _cached_components_closure(tuple(files)))
|
component_files = [f for f in files if filter_component_and_test_files(f)]
|
||||||
|
changed = get_components_with_dependencies(component_files, True)
|
||||||
|
|
||||||
|
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed))
|
||||||
|
|
||||||
|
|
||||||
def should_run_esp32_platformio(branch: str | None = None) -> bool:
|
def should_run_esp32_platformio(branch: str | None = None) -> bool:
|
||||||
@@ -653,83 +628,6 @@ def should_run_esp32_platformio(branch: str | None = None) -> bool:
|
|||||||
return bool(esp32_platformio_components_to_test(branch))
|
return bool(esp32_platformio_components_to_test(branch))
|
||||||
|
|
||||||
|
|
||||||
# The `--toolchain arduino` smoke-test set: covers the core, the bundled and
|
|
||||||
# converted registry libraries, and the waveform path.
|
|
||||||
ESP8266_NATIVE_TEST_COMPONENTS = frozenset(
|
|
||||||
{
|
|
||||||
"esp8266",
|
|
||||||
"api",
|
|
||||||
"web_server",
|
|
||||||
"captive_portal",
|
|
||||||
"mqtt",
|
|
||||||
"esp8266_pwm",
|
|
||||||
"neopixelbus",
|
|
||||||
"bme280_i2c",
|
|
||||||
"uart",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Infrastructure whose changes always trigger the native ESP8266 compile
|
|
||||||
# test. esphome/build_helpers/ holds the idedata and size-summary helpers
|
|
||||||
# the backend shares with the native ESP-IDF build.
|
|
||||||
ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = (
|
|
||||||
"esphome/arduino8266/",
|
|
||||||
"esphome/arduino/",
|
|
||||||
"esphome/build_helpers/",
|
|
||||||
)
|
|
||||||
# Shared library-conversion modules every native build imports; espidf-only
|
|
||||||
# infra (build_gen/espidf.py) deliberately stays out of the esp8266 set.
|
|
||||||
_NATIVE_SHARED_TRIGGER_FILES = frozenset(
|
|
||||||
{
|
|
||||||
"esphome/framework_helpers.py",
|
|
||||||
"esphome/platformio/library.py",
|
|
||||||
"esphome/platformio/extra_script.py",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
# Tripwire: the shared modules must stay in the ESP-IDF trigger set too
|
|
||||||
# (now defined in clang_tidy_hash), or its smoke test silently skips them
|
|
||||||
assert _NATIVE_SHARED_TRIGGER_FILES <= ESP_IDF_INFRA_TRIGGER_FILES
|
|
||||||
ESP8266_NATIVE_TRIGGER_FILES = (
|
|
||||||
_NATIVE_SHARED_TRIGGER_FILES
|
|
||||||
| _SMOKE_HARNESS_TRIGGER_FILES
|
|
||||||
| {
|
|
||||||
"esphome/build_gen/arduino8266.py",
|
|
||||||
"esphome/build_gen/build_tool.py",
|
|
||||||
"esphome/components/esp8266/build_surgery.py",
|
|
||||||
"esphome/components/esp8266/boards.py",
|
|
||||||
"esphome/platformio/registry.py",
|
|
||||||
# esp8266/__init__.py imports copy_ccache_script from it
|
|
||||||
"esphome/platformio/toolchain.py",
|
|
||||||
".github/actions/cache-arduino8266/action.yml",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool:
|
|
||||||
"""Whether any changed file is native-ESP8266 infrastructure / harness."""
|
|
||||||
# base_python_changed covers the top-level esphome/*.py modules the
|
|
||||||
# native backend imports directly (framework_helpers, helpers, writer,
|
|
||||||
# __main__); without it a change there would silently skip this job.
|
|
||||||
# base_python_changed is deliberately broad (any top-level esphome/*.py)
|
|
||||||
# as belt-and-braces while the backend is new; narrow it to the modules
|
|
||||||
# the backend imports once the toolchain has soaked a few releases
|
|
||||||
return base_python_changed(files) or _path_or_file_trigger(
|
|
||||||
files, ESP8266_NATIVE_TRIGGER_FILES, ESP8266_NATIVE_TRIGGER_PATH_PREFIXES
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def esp8266_native_components_to_test(branch: str | None = None) -> list[str]:
|
|
||||||
"""Subset of ``ESP8266_NATIVE_TEST_COMPONENTS`` the job needs to compile.
|
|
||||||
|
|
||||||
Same narrowing logic as ``esp32_platformio_components_to_test``: the full
|
|
||||||
list on core or infrastructure changes, otherwise the intersection with
|
|
||||||
the changed-component dependency closure (empty list skips the job).
|
|
||||||
"""
|
|
||||||
return _toolchain_components_to_test(
|
|
||||||
branch, ESP8266_NATIVE_TEST_COMPONENTS, _esp8266_native_path_or_file_trigger
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def determine_cpp_unit_tests(
|
def determine_cpp_unit_tests(
|
||||||
branch: str | None = None,
|
branch: str | None = None,
|
||||||
) -> tuple[bool, list[str]]:
|
) -> tuple[bool, list[str]]:
|
||||||
@@ -1328,8 +1226,6 @@ def main() -> None:
|
|||||||
run_device_builder = True
|
run_device_builder = True
|
||||||
esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
||||||
run_esp32_platformio = True
|
run_esp32_platformio = True
|
||||||
esp8266_native_components = sorted(ESP8266_NATIVE_TEST_COMPONENTS)
|
|
||||||
run_esp8266_native = True
|
|
||||||
else:
|
else:
|
||||||
integration_run_all, integration_test_files = determine_integration_tests(
|
integration_run_all, integration_test_files = determine_integration_tests(
|
||||||
args.branch
|
args.branch
|
||||||
@@ -1341,8 +1237,6 @@ def main() -> None:
|
|||||||
run_device_builder = should_run_device_builder(args.branch)
|
run_device_builder = should_run_device_builder(args.branch)
|
||||||
esp32_platformio_components = esp32_platformio_components_to_test(args.branch)
|
esp32_platformio_components = esp32_platformio_components_to_test(args.branch)
|
||||||
run_esp32_platformio = bool(esp32_platformio_components)
|
run_esp32_platformio = bool(esp32_platformio_components)
|
||||||
esp8266_native_components = esp8266_native_components_to_test(args.branch)
|
|
||||||
run_esp8266_native = bool(esp8266_native_components)
|
|
||||||
run_integration, integration_test_buckets = _compute_integration_test_buckets(
|
run_integration, integration_test_buckets = _compute_integration_test_buckets(
|
||||||
integration_run_all, integration_test_files
|
integration_run_all, integration_test_files
|
||||||
)
|
)
|
||||||
@@ -1538,8 +1432,6 @@ def main() -> None:
|
|||||||
"device_builder": run_device_builder,
|
"device_builder": run_device_builder,
|
||||||
"esp32_platformio": run_esp32_platformio,
|
"esp32_platformio": run_esp32_platformio,
|
||||||
"esp32_platformio_components": ",".join(esp32_platformio_components),
|
"esp32_platformio_components": ",".join(esp32_platformio_components),
|
||||||
"esp8266_native": run_esp8266_native,
|
|
||||||
"esp8266_native_components": ",".join(esp8266_native_components),
|
|
||||||
"changed_components": changed_components,
|
"changed_components": changed_components,
|
||||||
"changed_components_with_tests": changed_components_with_tests,
|
"changed_components_with_tests": changed_components_with_tests,
|
||||||
"directly_changed_components_with_tests": list(directly_changed_with_tests),
|
"directly_changed_components_with_tests": list(directly_changed_with_tests),
|
||||||
|
|||||||
@@ -1027,7 +1027,6 @@ def test_components(
|
|||||||
isolated_components: set[str] | None = None,
|
isolated_components: set[str] | None = None,
|
||||||
base_only: bool = False,
|
base_only: bool = False,
|
||||||
toolchain: str | None = None,
|
toolchain: str | None = None,
|
||||||
fail_on_no_tests: bool = False,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Test components with optional intelligent grouping.
|
"""Test components with optional intelligent grouping.
|
||||||
|
|
||||||
@@ -1062,34 +1061,20 @@ def test_components(
|
|||||||
# toolchain build.
|
# toolchain build.
|
||||||
include_validate = esphome_command != "compile"
|
include_validate = esphome_command != "compile"
|
||||||
|
|
||||||
# Find all component tests; remember which components each pattern
|
# Find all component tests
|
||||||
# (wildcards included) matched, for the deferred no-tests accounting
|
|
||||||
all_tests = {}
|
all_tests = {}
|
||||||
pattern_components: dict[str, set[str]] = {}
|
|
||||||
for pattern in component_patterns:
|
for pattern in component_patterns:
|
||||||
# Skip empty patterns (happens when components list is empty string)
|
# Skip empty patterns (happens when components list is empty string)
|
||||||
if not pattern:
|
if not pattern:
|
||||||
continue
|
continue
|
||||||
found = find_component_tests(
|
all_tests.update(
|
||||||
tests_dir, pattern, base_only, include_validate=include_validate
|
find_component_tests(
|
||||||
|
tests_dir, pattern, base_only, include_validate=include_validate
|
||||||
|
)
|
||||||
)
|
)
|
||||||
pattern_components[pattern] = set(found)
|
|
||||||
all_tests.update(found)
|
|
||||||
|
|
||||||
# The flag's contract is "no test matched fails": a fully blank pattern
|
|
||||||
# list would otherwise slide into the reference-baseline fallback and
|
|
||||||
# exit green while building nothing a caller asked for
|
|
||||||
if fail_on_no_tests and not any(component_patterns):
|
|
||||||
print("No components requested (blank component list)")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if fail_on_no_tests and not all_tests:
|
|
||||||
# Nothing matched at all: fail before the synthetic baseline build,
|
|
||||||
# which would spend a compile reporting success on nothing. Partial
|
|
||||||
# matches defer to the per-pattern accounting after the summary.
|
|
||||||
print(f"No components found matching: {component_patterns}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
# If no components found, build a reference configuration for baseline comparison
|
||||||
|
# Create a synthetic "empty" component test that will build just the base config
|
||||||
if not all_tests:
|
if not all_tests:
|
||||||
print(f"No components found matching: {component_patterns}")
|
print(f"No components found matching: {component_patterns}")
|
||||||
print(
|
print(
|
||||||
@@ -1193,26 +1178,6 @@ def test_components(
|
|||||||
toolchain=toolchain,
|
toolchain=toolchain,
|
||||||
)
|
)
|
||||||
|
|
||||||
silent: list[str] = []
|
|
||||||
if fail_on_no_tests:
|
|
||||||
# A green run that built nothing for a requested pattern (renamed
|
|
||||||
# fixture, missing base file, version-suffix mismatch, a wildcard
|
|
||||||
# matching no component) must not pass CI. Per pattern: an
|
|
||||||
# all-or-nothing check would let one silent pattern hide behind the
|
|
||||||
# others. Opt-in: some legs (the esp32-ard smoke subset)
|
|
||||||
# legitimately match nothing. Failing is deferred past the summary
|
|
||||||
# so a real failure's reproduce commands still print.
|
|
||||||
built = {c for r in test_results for c in r.components}
|
|
||||||
# A pattern is silent when it matched no fixture, or when none of
|
|
||||||
# its matched components produced a build (wildcards included)
|
|
||||||
silent = [
|
|
||||||
p
|
|
||||||
for p in component_patterns
|
|
||||||
if p and not (pattern_components.get(p, set()) & built)
|
|
||||||
]
|
|
||||||
if silent:
|
|
||||||
print(f"No tests ran for requested pattern(s): {', '.join(silent)}")
|
|
||||||
|
|
||||||
# Separate results into passed and failed
|
# Separate results into passed and failed
|
||||||
passed_results = [r for r in test_results if r.success]
|
passed_results = [r for r in test_results if r.success]
|
||||||
failed_results = [r for r in test_results if not r.success]
|
failed_results = [r for r in test_results if not r.success]
|
||||||
@@ -1244,7 +1209,7 @@ def test_components(
|
|||||||
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
||||||
write_github_summary(test_results, toolchain=toolchain)
|
write_github_summary(test_results, toolchain=toolchain)
|
||||||
|
|
||||||
if failed_results or silent:
|
if failed_results:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
@@ -1299,12 +1264,6 @@ def main() -> int:
|
|||||||
"--toolchain",
|
"--toolchain",
|
||||||
help="Select toolchain for compiling.",
|
help="Select toolchain for compiling.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--fail-on-no-tests",
|
|
||||||
action="store_true",
|
|
||||||
help="Exit non-zero when no test matched (for CI legs whose "
|
|
||||||
"components must all have fixtures)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -1323,7 +1282,6 @@ def main() -> int:
|
|||||||
continue_on_fail=args.continue_on_fail,
|
continue_on_fail=args.continue_on_fail,
|
||||||
enable_grouping=not args.no_grouping,
|
enable_grouping=not args.no_grouping,
|
||||||
isolated_components=isolated_components,
|
isolated_components=isolated_components,
|
||||||
fail_on_no_tests=args.fail_on_no_tests,
|
|
||||||
base_only=args.base_only,
|
base_only=args.base_only,
|
||||||
toolchain=args.toolchain,
|
toolchain=args.toolchain,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
esphome:
|
||||||
|
name: test
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
variant: esp32
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: MySSID
|
||||||
|
password: password1
|
||||||
|
|
||||||
|
# esp32_ble_server is only auto-loaded here, so it has no services of its own.
|
||||||
|
esp32_improv:
|
||||||
|
authorizer: none
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
esphome:
|
||||||
|
name: test
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
variant: esp32
|
||||||
|
|
||||||
|
esp32_ble_server:
|
||||||
|
id: ble_server
|
||||||
|
manufacturer_data: [0x72, 0x04, 0x00, 0x23]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
esphome:
|
||||||
|
name: test
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
variant: esp32
|
||||||
|
|
||||||
|
esp32_ble_server:
|
||||||
|
id: ble_server
|
||||||
|
services:
|
||||||
|
- uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d
|
||||||
|
characteristics:
|
||||||
|
- uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423
|
||||||
|
read: true
|
||||||
|
value: [1, 2, 3, 4]
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Tests for esp32_ble_server configuration helpers."""
|
"""Tests for esp32_ble_server configuration helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from esphome.components.esp32_ble_server import (
|
from esphome.components.esp32_ble_server import (
|
||||||
@@ -45,3 +50,26 @@ def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None:
|
|||||||
assert uuid_is(uuid16, uuid16)
|
assert uuid_is(uuid16, uuid16)
|
||||||
assert uuid_is(f"{uuid16:04X}", uuid16)
|
assert uuid_is(f"{uuid16:04X}", uuid16)
|
||||||
assert uuid_is(f"{uuid16:08X}", uuid16)
|
assert uuid_is(f"{uuid16:08X}", uuid16)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("config_file", "required"),
|
||||||
|
[
|
||||||
|
# Auto-loaded by esp32_improv only: nothing to find until Improv asks for it
|
||||||
|
("improv_only.yaml", False),
|
||||||
|
# The configuration defines a service clients are meant to connect to
|
||||||
|
("own_service.yaml", True),
|
||||||
|
# Manufacturer data is only useful if it is actually broadcast
|
||||||
|
("manufacturer_data_only.yaml", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_advertising_required(
|
||||||
|
generate_main: Callable[[str | Path], str],
|
||||||
|
component_config_path: Callable[[str], Path],
|
||||||
|
config_file: str,
|
||||||
|
required: bool,
|
||||||
|
) -> None:
|
||||||
|
"""The server only requests advertising when the configuration needs it."""
|
||||||
|
main_cpp = generate_main(component_config_path(config_file))
|
||||||
|
|
||||||
|
assert f"set_advertising_required({str(required).lower()})" in main_cpp
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from esphome import config_validation as cv
|
from esphome import config_validation as cv
|
||||||
from esphome.components.noise import (
|
from esphome.components.noise import decode_encryption_key, validate_encryption_key
|
||||||
decode_encryption_key,
|
|
||||||
is_reserved_key,
|
|
||||||
validate_encryption_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||||
|
|
||||||
@@ -41,6 +37,8 @@ def test_decode_encryption_key_rejects_short_decode() -> None:
|
|||||||
decode_encryption_key("AAECAw==")
|
decode_encryption_key("AAECAw==")
|
||||||
|
|
||||||
|
|
||||||
def test_is_reserved_key() -> None:
|
def test_validate_encryption_key_rejects_all_zeros() -> None:
|
||||||
assert is_reserved_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
"""The all-zeros key is the provisioning sentinel the device treats as no
|
||||||
assert not is_reserved_key(KEY)
|
key, so it never reaches a build."""
|
||||||
|
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
|
||||||
|
validate_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ from esphome.components.esphome.ota import (
|
|||||||
_validate_no_password_with_encryption,
|
_validate_no_password_with_encryption,
|
||||||
ota_esphome_final_validate,
|
ota_esphome_final_validate,
|
||||||
)
|
)
|
||||||
|
from esphome.components.noise import static_encryption_key
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
CONF_API,
|
CONF_API,
|
||||||
CONF_ENCRYPTION,
|
CONF_ENCRYPTION,
|
||||||
@@ -115,7 +117,6 @@ def test_non_esphome_ota_unaffected() -> None:
|
|||||||
|
|
||||||
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||||
OTHER_KEY = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
|
OTHER_KEY = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
|
||||||
ZEROS_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
|
||||||
|
|
||||||
|
|
||||||
def test_encryption_key_inherited_from_api() -> None:
|
def test_encryption_key_inherited_from_api() -> None:
|
||||||
@@ -197,36 +198,6 @@ def test_encryption_without_any_key_rejected() -> None:
|
|||||||
fv.full_config.reset(token)
|
fv.full_config.reset(token)
|
||||||
|
|
||||||
|
|
||||||
def test_encryption_explicit_all_zeros_key_rejected() -> None:
|
|
||||||
"""The all-zeros key is the provisioning sentinel; the device would treat
|
|
||||||
it as no PSK and accept plaintext, so it must fail validation."""
|
|
||||||
full_conf = {
|
|
||||||
CONF_OTA: [
|
|
||||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}})
|
|
||||||
],
|
|
||||||
}
|
|
||||||
token = fv.full_config.set(full_conf)
|
|
||||||
try:
|
|
||||||
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
|
|
||||||
ota_esphome_final_validate({})
|
|
||||||
finally:
|
|
||||||
fv.full_config.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def test_encryption_inherited_all_zeros_key_rejected() -> None:
|
|
||||||
"""An all-zeros api key must not silently disable ota encryption either."""
|
|
||||||
full_conf = {
|
|
||||||
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}},
|
|
||||||
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
|
|
||||||
}
|
|
||||||
token = fv.full_config.set(full_conf)
|
|
||||||
try:
|
|
||||||
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
|
|
||||||
ota_esphome_final_validate({})
|
|
||||||
finally:
|
|
||||||
fv.full_config.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def test_encryption_key_mismatch_between_merged_configs_rejected() -> None:
|
def test_encryption_key_mismatch_between_merged_configs_rejected() -> None:
|
||||||
"""Same-port configs with different encryption keys raise."""
|
"""Same-port configs with different encryption keys raise."""
|
||||||
full_conf = {
|
full_conf = {
|
||||||
@@ -295,13 +266,14 @@ def test_encryption_explicit_key_with_runtime_provisioned_api_accepted() -> None
|
|||||||
fv.full_config.reset(token)
|
fv.full_config.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("component", ["web_server", "prometheus"])
|
||||||
def test_encryption_with_web_server_ota_warns(
|
def test_encryption_with_web_server_ota_warns(
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture, component: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""With the web_server component the plaintext /update endpoint is always
|
"""web_server and prometheus keep the shared listener up, so the
|
||||||
on; the combination validates with a warning."""
|
plaintext /update endpoint is always on and the combination warns."""
|
||||||
full_conf = {
|
full_conf = {
|
||||||
"web_server": {},
|
component: {},
|
||||||
CONF_OTA: [
|
CONF_OTA: [
|
||||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
|
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
|
||||||
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
||||||
@@ -316,12 +288,12 @@ def test_encryption_with_web_server_ota_warns(
|
|||||||
fv.full_config.reset(token)
|
fv.full_config.reset(token)
|
||||||
|
|
||||||
|
|
||||||
def test_encryption_with_captive_portal_web_server_ota_warns(
|
def test_encryption_with_captive_portal_does_not_warn(
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""captive_portal auto-loads the web_server ota platform without the
|
"""captive_portal auto-loads the web_server ota platform without the
|
||||||
web_server component; encryption stays usable and only warns, so the
|
web_server component; its endpoint only exists while the fallback AP is
|
||||||
fallback AP recovery path is not lost."""
|
active and is the intended recovery path, so there is no warning."""
|
||||||
full_conf = {
|
full_conf = {
|
||||||
"captive_portal": {},
|
"captive_portal": {},
|
||||||
CONF_OTA: [
|
CONF_OTA: [
|
||||||
@@ -333,7 +305,10 @@ def test_encryption_with_captive_portal_web_server_ota_warns(
|
|||||||
try:
|
try:
|
||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level(logging.WARNING):
|
||||||
ota_esphome_final_validate({})
|
ota_esphome_final_validate({})
|
||||||
assert any("captive_portal" in record.message for record in caplog.records)
|
assert not any(
|
||||||
|
"OTA encryption does not cover" in record.message
|
||||||
|
for record in caplog.records
|
||||||
|
)
|
||||||
esphome_conf = next(
|
esphome_conf = next(
|
||||||
conf
|
conf
|
||||||
for conf in fv.full_config.get()[CONF_OTA]
|
for conf in fv.full_config.get()[CONF_OTA]
|
||||||
@@ -344,6 +319,100 @@ def test_encryption_with_captive_portal_web_server_ota_warns(
|
|||||||
fv.full_config.reset(token)
|
fv.full_config.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""A static api key makes the device offer encryption and the CLI take
|
||||||
|
it, so the password is dead weight; the config validates with a warning."""
|
||||||
|
full_conf = {
|
||||||
|
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}},
|
||||||
|
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
|
||||||
|
}
|
||||||
|
token = fv.full_config.set(full_conf)
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
ota_esphome_final_validate({})
|
||||||
|
assert any("wastes significant flash" in r.message for r in caplog.records)
|
||||||
|
finally:
|
||||||
|
fv.full_config.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_with_runtime_api_key_warns_differently(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""The CLI still needs the password, but the provisioned key also
|
||||||
|
authenticates uploads; the warning says so without the flash advice."""
|
||||||
|
full_conf = {
|
||||||
|
CONF_API: {CONF_ENCRYPTION: {}},
|
||||||
|
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
|
||||||
|
}
|
||||||
|
token = fv.full_config.set(full_conf)
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
ota_esphome_final_validate({})
|
||||||
|
messages = [r.message for r in caplog.records]
|
||||||
|
assert any("provisioned at runtime also authenticates" in m for m in messages)
|
||||||
|
assert not any("wastes significant flash" in m for m in messages)
|
||||||
|
finally:
|
||||||
|
fv.full_config.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_without_api_key_no_warning(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Without an api key there is no offer, so nothing to warn about."""
|
||||||
|
full_conf = {
|
||||||
|
CONF_API: {},
|
||||||
|
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
|
||||||
|
}
|
||||||
|
token = fv.full_config.set(full_conf)
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
ota_esphome_final_validate({})
|
||||||
|
assert not any("authenticates" in r.message for r in caplog.records)
|
||||||
|
finally:
|
||||||
|
fv.full_config.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_server_component_without_ota_platform_does_not_warn(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""The web_server component alone has no /update endpoint."""
|
||||||
|
full_conf = {
|
||||||
|
"web_server": {},
|
||||||
|
CONF_OTA: [
|
||||||
|
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
|
||||||
|
],
|
||||||
|
}
|
||||||
|
token = fv.full_config.set(full_conf)
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
ota_esphome_final_validate({})
|
||||||
|
assert not any(
|
||||||
|
"OTA encryption does not cover" in r.message for r in caplog.records
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
fv.full_config.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_server_ota_platform_alone_does_not_warn(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Only the web_server component starts the shared listener, so the ota
|
||||||
|
platform on its own never exposes /update."""
|
||||||
|
full_conf = {
|
||||||
|
CONF_OTA: [
|
||||||
|
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
|
||||||
|
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
token = fv.full_config.set(full_conf)
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
ota_esphome_final_validate({})
|
||||||
|
assert not any("plaintext /update" in r.message for r in caplog.records)
|
||||||
|
finally:
|
||||||
|
fv.full_config.reset(token)
|
||||||
|
|
||||||
|
|
||||||
def test_web_server_ota_without_encryption_unaffected() -> None:
|
def test_web_server_ota_without_encryption_unaffected() -> None:
|
||||||
"""web_server ota stays valid alongside an unencrypted esphome entry."""
|
"""web_server ota stays valid alongside an unencrypted esphome entry."""
|
||||||
full_conf = {
|
full_conf = {
|
||||||
@@ -370,20 +439,87 @@ def test_auto_load_pulls_noise_only_for_encryption() -> None:
|
|||||||
assert "noise" in AUTO_LOAD({})
|
assert "noise" in AUTO_LOAD({})
|
||||||
|
|
||||||
|
|
||||||
def test_filter_source_files_excludes_noise_without_encryption() -> None:
|
def test_static_encryption_key() -> None:
|
||||||
"""The noise transport source compiles only for encrypted builds."""
|
"""Only a build-time key counts; a runtime provisioned one does not."""
|
||||||
old_config = CORE.config
|
assert static_encryption_key({}) is None
|
||||||
try:
|
assert static_encryption_key({CONF_ENCRYPTION: {}}) is None
|
||||||
CORE.config = {CONF_OTA: [_make_ota_config(port=3232)]}
|
assert static_encryption_key({CONF_ENCRYPTION: {CONF_KEY: API_KEY}}) == API_KEY
|
||||||
assert FILTER_SOURCE_FILES() == ["ota_esphome_noise.cpp"]
|
|
||||||
CORE.config = {
|
|
||||||
CONF_OTA: [
|
@pytest.mark.parametrize(
|
||||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}})
|
("yaml_name", "defines_present", "defines_absent"),
|
||||||
]
|
[
|
||||||
}
|
# An api key alone compiles the transport in without requiring it;
|
||||||
assert FILTER_SOURCE_FILES() == []
|
# the device uses the api server's key, not a copy
|
||||||
finally:
|
(
|
||||||
CORE.config = old_config
|
"api_key_offer",
|
||||||
|
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"},
|
||||||
|
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||||
|
),
|
||||||
|
# A password still guards plaintext uploads on an offering device
|
||||||
|
(
|
||||||
|
"api_key_offer_password",
|
||||||
|
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"},
|
||||||
|
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||||
|
),
|
||||||
|
# The ota encryption block is what makes the device refuse plaintext
|
||||||
|
(
|
||||||
|
"encryption_required",
|
||||||
|
{
|
||||||
|
"USE_OTA_ENCRYPTION",
|
||||||
|
"USE_OTA_ENCRYPTION_REQUIRED",
|
||||||
|
"USE_OTA_ENCRYPTION_FROM_API",
|
||||||
|
},
|
||||||
|
{"USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||||
|
),
|
||||||
|
# Without api encryption the ota key is the device's own
|
||||||
|
(
|
||||||
|
"own_key",
|
||||||
|
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
|
||||||
|
{"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||||
|
),
|
||||||
|
# A key provisioned at runtime lives in the api server; the device
|
||||||
|
# offers with it once provisioned and never requires it
|
||||||
|
(
|
||||||
|
"runtime_api_key",
|
||||||
|
{
|
||||||
|
"USE_OTA_ENCRYPTION",
|
||||||
|
"USE_OTA_ENCRYPTION_FROM_API",
|
||||||
|
"USE_OTA_ENCRYPTION_PROVISIONED",
|
||||||
|
},
|
||||||
|
{"USE_OTA_ENCRYPTION_REQUIRED"},
|
||||||
|
),
|
||||||
|
# No api encryption at all keeps the noise glue out of the build
|
||||||
|
(
|
||||||
|
"plain",
|
||||||
|
set(),
|
||||||
|
{
|
||||||
|
"USE_OTA_ENCRYPTION",
|
||||||
|
"USE_OTA_ENCRYPTION_REQUIRED",
|
||||||
|
"USE_OTA_ENCRYPTION_FROM_API",
|
||||||
|
"USE_OTA_ENCRYPTION_PROVISIONED",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_encryption_offer_codegen(
|
||||||
|
generate_main: Callable[[str], str],
|
||||||
|
yaml_name: str,
|
||||||
|
defines_present: set[str],
|
||||||
|
defines_absent: set[str],
|
||||||
|
) -> None:
|
||||||
|
main_cpp = generate_main(
|
||||||
|
f"tests/component_tests/ota/test_esphome_ota_{yaml_name}.yaml"
|
||||||
|
)
|
||||||
|
defines = {define.name for define in CORE.defines}
|
||||||
|
assert defines_present <= defines
|
||||||
|
assert not (defines_absent & defines)
|
||||||
|
encrypted = "USE_OTA_ENCRYPTION" in defines_present
|
||||||
|
own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present
|
||||||
|
assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key
|
||||||
|
assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present)
|
||||||
|
# The noise transport source compiles only when the define is set
|
||||||
|
assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"])
|
||||||
|
|
||||||
|
|
||||||
def test_password_with_encryption_rejected() -> None:
|
def test_password_with_encryption_rejected() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
esphome:
|
||||||
|
name: ota-offer
|
||||||
|
|
||||||
|
host:
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
esphome:
|
||||||
|
name: ota-offer-password
|
||||||
|
|
||||||
|
host:
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
password: "superlongpasswordthatnoonewillknow"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
esphome:
|
||||||
|
name: ota-encryption-required
|
||||||
|
|
||||||
|
host:
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
encryption:
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
esphome:
|
||||||
|
name: ota-own-key
|
||||||
|
|
||||||
|
host:
|
||||||
|
|
||||||
|
api:
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
encryption:
|
||||||
|
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
esphome:
|
||||||
|
name: ota-plain
|
||||||
|
|
||||||
|
host:
|
||||||
|
|
||||||
|
api:
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
esphome:
|
||||||
|
name: ota-runtime-key
|
||||||
|
|
||||||
|
host:
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Tests for template climate config validation."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from esphome import config_validation as cv
|
||||||
|
from esphome.components.template.climate import (
|
||||||
|
CONF_SET_TARGET_HUMIDITY_ACTION,
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_ACTION,
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
|
||||||
|
CONF_SUPPORTS_CURRENT_HUMIDITY,
|
||||||
|
CONF_SUPPORTS_CURRENT_TEMPERATURE,
|
||||||
|
CONF_SUPPORTS_TARGET_HUMIDITY,
|
||||||
|
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
|
||||||
|
CONF_TARGET_HUMIDITY,
|
||||||
|
_resolve_supports,
|
||||||
|
_validate_initial_state,
|
||||||
|
_validate_set_actions,
|
||||||
|
)
|
||||||
|
from esphome.const import (
|
||||||
|
CONF_HUMIDITY_SENSOR,
|
||||||
|
CONF_INITIAL_STATE,
|
||||||
|
CONF_SENSOR,
|
||||||
|
CONF_TARGET_TEMPERATURE,
|
||||||
|
CONF_TARGET_TEMPERATURE_HIGH,
|
||||||
|
CONF_TARGET_TEMPERATURE_LOW,
|
||||||
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
|
|
||||||
|
def test_supports_current_temperature_derived_from_sensor() -> None:
|
||||||
|
config: ConfigType = {CONF_SENSOR: "some_sensor"}
|
||||||
|
assert _resolve_supports(config)[CONF_SUPPORTS_CURRENT_TEMPERATURE] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_supports_current_temperature_false_without_sensor() -> None:
|
||||||
|
assert _resolve_supports({})[CONF_SUPPORTS_CURRENT_TEMPERATURE] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_supports_current_temperature_explicit_true_without_sensor_allowed() -> None:
|
||||||
|
# The value can still be reported with climate.template.publish.
|
||||||
|
config: ConfigType = {CONF_SUPPORTS_CURRENT_TEMPERATURE: True}
|
||||||
|
assert _resolve_supports(config)[CONF_SUPPORTS_CURRENT_TEMPERATURE] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_supports_current_temperature_false_with_sensor_rejected() -> None:
|
||||||
|
config: ConfigType = {
|
||||||
|
CONF_SENSOR: "some_sensor",
|
||||||
|
CONF_SUPPORTS_CURRENT_TEMPERATURE: False,
|
||||||
|
}
|
||||||
|
with pytest.raises(cv.Invalid, match="cannot be false"):
|
||||||
|
_resolve_supports(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_supports_current_humidity_false_with_sensor_rejected() -> None:
|
||||||
|
config: ConfigType = {
|
||||||
|
CONF_HUMIDITY_SENSOR: "some_sensor",
|
||||||
|
CONF_SUPPORTS_CURRENT_HUMIDITY: False,
|
||||||
|
}
|
||||||
|
with pytest.raises(cv.Invalid, match="cannot be false"):
|
||||||
|
_resolve_supports(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_point_derived_from_set_actions() -> None:
|
||||||
|
config: ConfigType = {
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION: [{}],
|
||||||
|
}
|
||||||
|
assert _resolve_supports(config)[CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_point_false_with_set_action_rejected() -> None:
|
||||||
|
config: ConfigType = {
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
|
||||||
|
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
|
||||||
|
}
|
||||||
|
with pytest.raises(cv.Invalid, match="cannot be false"):
|
||||||
|
_resolve_supports(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_humidity_derived_from_set_action() -> None:
|
||||||
|
config: ConfigType = {CONF_SET_TARGET_HUMIDITY_ACTION: [{}]}
|
||||||
|
assert _resolve_supports(config)[CONF_SUPPORTS_TARGET_HUMIDITY] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_target_temperature_low_requires_high() -> None:
|
||||||
|
config: ConfigType = {CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}]}
|
||||||
|
with pytest.raises(cv.Invalid, match="must be used together"):
|
||||||
|
_validate_set_actions(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_target_temperature_conflicts_with_two_point_actions() -> None:
|
||||||
|
config: ConfigType = {
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_ACTION: [{}],
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
|
||||||
|
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION: [{}],
|
||||||
|
}
|
||||||
|
with pytest.raises(cv.Invalid, match="cannot be used together"):
|
||||||
|
_validate_set_actions(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_state_target_temperature_rejected_with_two_point() -> None:
|
||||||
|
config: ConfigType = {
|
||||||
|
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: True,
|
||||||
|
CONF_SUPPORTS_TARGET_HUMIDITY: False,
|
||||||
|
CONF_INITIAL_STATE: {CONF_TARGET_TEMPERATURE: 21.0},
|
||||||
|
}
|
||||||
|
with pytest.raises(cv.Invalid, match="is not available"):
|
||||||
|
_validate_initial_state(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_state_two_point_values_rejected_without_two_point() -> None:
|
||||||
|
config: ConfigType = {
|
||||||
|
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
|
||||||
|
CONF_SUPPORTS_TARGET_HUMIDITY: False,
|
||||||
|
CONF_INITIAL_STATE: {
|
||||||
|
CONF_TARGET_TEMPERATURE_LOW: 18.0,
|
||||||
|
CONF_TARGET_TEMPERATURE_HIGH: 24.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with pytest.raises(cv.Invalid, match="requires"):
|
||||||
|
_validate_initial_state(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_state_target_humidity_rejected_without_support() -> None:
|
||||||
|
config: ConfigType = {
|
||||||
|
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
|
||||||
|
CONF_SUPPORTS_TARGET_HUMIDITY: False,
|
||||||
|
CONF_INITIAL_STATE: {CONF_TARGET_HUMIDITY: 50},
|
||||||
|
}
|
||||||
|
with pytest.raises(cv.Invalid, match="requires"):
|
||||||
|
_validate_initial_state(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_state_matching_two_point_accepted() -> None:
|
||||||
|
config: ConfigType = {
|
||||||
|
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: True,
|
||||||
|
CONF_SUPPORTS_TARGET_HUMIDITY: True,
|
||||||
|
CONF_INITIAL_STATE: {
|
||||||
|
CONF_TARGET_TEMPERATURE_LOW: 18.0,
|
||||||
|
CONF_TARGET_TEMPERATURE_HIGH: 24.0,
|
||||||
|
CONF_TARGET_HUMIDITY: 50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert _validate_initial_state(config) is config
|
||||||
@@ -30,8 +30,7 @@ climate:
|
|||||||
- switch.turn_on: climate_heater_switch
|
- switch.turn_on: climate_heater_switch
|
||||||
- switch.turn_off: climate_cooler_switch
|
- switch.turn_off: climate_cooler_switch
|
||||||
# Thermostat-based climate so climate.control: action variants get build
|
# Thermostat-based climate so climate.control: action variants get build
|
||||||
# coverage (bang_bang doesn't support fan modes, presets, etc.). Climate
|
# coverage (bang_bang doesn't support fan modes, presets, etc.).
|
||||||
# has no template platform, so thermostat is the right vehicle.
|
|
||||||
- platform: thermostat
|
- platform: thermostat
|
||||||
id: climate_test_thermostat
|
id: climate_test_thermostat
|
||||||
name: Test Thermostat
|
name: Test Thermostat
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Exercises the ESP-NOW-over-hosted shim: on the ESP32-P4 host, esp32_hosted
|
||||||
|
# supplies the esp_now_* symbols that the espnow component links against.
|
||||||
|
packages:
|
||||||
|
esp32_hosted: !include common.yaml
|
||||||
|
espnow: !include ../espnow/common.yaml
|
||||||
@@ -68,6 +68,14 @@ class Initiator {
|
|||||||
|
|
||||||
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
|
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
|
||||||
|
|
||||||
|
// The context only points at the key and init() copies it before returning,
|
||||||
|
// so a temporary context over a temporary key is safe within one call
|
||||||
|
static NoiseContext ctx_for(const psk_t &psk) {
|
||||||
|
NoiseContext ctx;
|
||||||
|
ctx.set_psk(psk.data());
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
static psk_t make_psk(uint8_t seed) {
|
static psk_t make_psk(uint8_t seed) {
|
||||||
psk_t psk;
|
psk_t psk;
|
||||||
for (size_t i = 0; i < psk.size(); i++) {
|
for (size_t i = 0; i < psk.size(); i++) {
|
||||||
@@ -102,7 +110,7 @@ TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) {
|
|||||||
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
|
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
|
||||||
const psk_t psk = make_psk(7);
|
const psk_t psk = make_psk(7);
|
||||||
NoiseResponderHandshake responder;
|
NoiseResponderHandshake responder;
|
||||||
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
|
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||||
EXPECT_EQ(responder.action(), Action::ACTION_READ);
|
EXPECT_EQ(responder.action(), Action::ACTION_READ);
|
||||||
|
|
||||||
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
|
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
|
||||||
@@ -155,8 +163,8 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
|
|||||||
// proves the restart took effect; the old state surviving would fail the
|
// proves the restart took effect; the old state surviving would fail the
|
||||||
// MAC here.
|
// MAC here.
|
||||||
NoiseResponderHandshake responder;
|
NoiseResponderHandshake responder;
|
||||||
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
|
ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||||
ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0);
|
ASSERT_EQ(responder.init(ctx_for(make_psk(9)), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||||
EXPECT_EQ(responder.action(), Action::ACTION_READ);
|
EXPECT_EQ(responder.action(), Action::ACTION_READ);
|
||||||
|
|
||||||
Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE));
|
Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE));
|
||||||
@@ -168,7 +176,7 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
|
|||||||
|
|
||||||
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
|
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
|
||||||
NoiseResponderHandshake responder;
|
NoiseResponderHandshake responder;
|
||||||
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
|
ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||||
|
|
||||||
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
|
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
|
||||||
uint8_t msg[MAX_HANDSHAKE_SIZE];
|
uint8_t msg[MAX_HANDSHAKE_SIZE];
|
||||||
@@ -185,7 +193,7 @@ TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) {
|
|||||||
// tampered preamble must fail even with the right key.
|
// tampered preamble must fail even with the right key.
|
||||||
const psk_t psk = make_psk(7);
|
const psk_t psk = make_psk(7);
|
||||||
NoiseResponderHandshake responder;
|
NoiseResponderHandshake responder;
|
||||||
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
|
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||||
|
|
||||||
static const uint8_t TAMPERED[] = {'x'};
|
static const uint8_t TAMPERED[] = {'x'};
|
||||||
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
|
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
|
||||||
|
|||||||
@@ -17,12 +17,17 @@ TEST(NoiseContextTest, AllZerosPskIsReserved) {
|
|||||||
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
|
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
|
||||||
|
|
||||||
NoiseContext ctx;
|
NoiseContext ctx;
|
||||||
|
psk_t loaded;
|
||||||
EXPECT_FALSE(ctx.has_psk());
|
EXPECT_FALSE(ctx.has_psk());
|
||||||
ctx.set_psk(zeros);
|
ctx.load_psk(loaded);
|
||||||
EXPECT_FALSE(ctx.has_psk());
|
EXPECT_EQ(loaded, zeros);
|
||||||
ctx.set_psk(psk);
|
ctx.set_psk(psk.data());
|
||||||
EXPECT_TRUE(ctx.has_psk());
|
EXPECT_TRUE(ctx.has_psk());
|
||||||
EXPECT_EQ(ctx.get_psk(), psk);
|
ctx.load_psk(loaded);
|
||||||
|
EXPECT_EQ(loaded, psk);
|
||||||
|
// Callers map the reserved key to nullptr; the context just stores what it is given
|
||||||
|
ctx.set_psk(nullptr);
|
||||||
|
EXPECT_FALSE(ctx.has_psk());
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
|
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
wifi:
|
||||||
|
ssid: MySSID
|
||||||
|
password: password1
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
port: 3290
|
||||||
|
password: "superlongpasswordthatnoonewillknow"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
wifi:
|
||||||
|
ssid: MySSID
|
||||||
|
password: password1
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
port: 3291
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
ota: !include api_key_offer.yaml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
ota: !include api_key_offer.yaml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
ota: !include api_runtime_key.yaml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
ota: !include api_runtime_key.yaml
|
||||||
@@ -25,6 +25,27 @@ esphome:
|
|||||||
away: !lambda "return true;"
|
away: !lambda "return true;"
|
||||||
is_on: !lambda "return false;"
|
is_on: !lambda "return false;"
|
||||||
|
|
||||||
|
- climate.template.publish:
|
||||||
|
id: template_climate
|
||||||
|
current_temperature: 21.0
|
||||||
|
mode: HEAT
|
||||||
|
fan_mode: AUTO
|
||||||
|
swing_mode: "OFF"
|
||||||
|
preset: NONE
|
||||||
|
target_temperature: 22.0
|
||||||
|
|
||||||
|
# Templated
|
||||||
|
- climate.template.publish:
|
||||||
|
id: template_climate
|
||||||
|
current_temperature: !lambda "return 21.5f;"
|
||||||
|
mode: !lambda "return climate::CLIMATE_MODE_COOL;"
|
||||||
|
target_temperature: !lambda "return 23.0f;"
|
||||||
|
|
||||||
|
- climate.template.publish:
|
||||||
|
id: template_climate_custom_modes
|
||||||
|
custom_fan_mode: "turbo"
|
||||||
|
custom_preset: "eco_plus"
|
||||||
|
|
||||||
# Test C++ API: set_template() with stateless lambda (no captures)
|
# Test C++ API: set_template() with stateless lambda (no captures)
|
||||||
# NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break.
|
# NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break.
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
@@ -513,6 +534,98 @@ alarm_control_panel:
|
|||||||
codes:
|
codes:
|
||||||
- "1234"
|
- "1234"
|
||||||
|
|
||||||
|
climate:
|
||||||
|
- platform: template
|
||||||
|
id: template_climate
|
||||||
|
name: "Template Climate"
|
||||||
|
optimistic: true
|
||||||
|
sensor: template_template_sens
|
||||||
|
supports_action: true
|
||||||
|
supports_current_humidity: true
|
||||||
|
restore_mode: NO_RESTORE
|
||||||
|
initial_state:
|
||||||
|
mode: HEAT
|
||||||
|
target_temperature: 21.0
|
||||||
|
fan_mode: LOW
|
||||||
|
supported_modes:
|
||||||
|
- "OFF"
|
||||||
|
- HEAT
|
||||||
|
- COOL
|
||||||
|
supported_fan_modes:
|
||||||
|
- AUTO
|
||||||
|
- LOW
|
||||||
|
- HIGH
|
||||||
|
supported_swing_modes:
|
||||||
|
- "OFF"
|
||||||
|
- VERTICAL
|
||||||
|
supported_presets:
|
||||||
|
- NONE
|
||||||
|
- ECO
|
||||||
|
visual:
|
||||||
|
min_temperature: 16.0
|
||||||
|
max_temperature: 30.0
|
||||||
|
temperature_step: 0.5
|
||||||
|
set_mode_action:
|
||||||
|
- logger.log:
|
||||||
|
format: "set_mode_action %d"
|
||||||
|
args: ["(int) x"]
|
||||||
|
set_target_temperature_action:
|
||||||
|
- logger.log:
|
||||||
|
format: "set_target_temperature_action %.1f"
|
||||||
|
args: ["x"]
|
||||||
|
set_target_humidity_action:
|
||||||
|
- logger.log:
|
||||||
|
format: "set_target_humidity_action %.1f"
|
||||||
|
args: ["x"]
|
||||||
|
set_fan_mode_action:
|
||||||
|
- logger.log:
|
||||||
|
format: "set_fan_mode_action %d"
|
||||||
|
args: ["(int) x"]
|
||||||
|
set_swing_mode_action:
|
||||||
|
- logger.log:
|
||||||
|
format: "set_swing_mode_action %d"
|
||||||
|
args: ["(int) x"]
|
||||||
|
set_preset_action:
|
||||||
|
- logger.log:
|
||||||
|
format: "set_preset_action %d"
|
||||||
|
args: ["(int) x"]
|
||||||
|
on_control:
|
||||||
|
- logger.log: "on_control fired"
|
||||||
|
on_state:
|
||||||
|
- logger.log: "on_state fired"
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
id: template_climate_custom_modes
|
||||||
|
name: "Template Climate Custom Modes"
|
||||||
|
optimistic: true
|
||||||
|
sensor: template_template_sens
|
||||||
|
supported_modes:
|
||||||
|
- "OFF"
|
||||||
|
- HEAT
|
||||||
|
custom_fan_modes:
|
||||||
|
- turbo
|
||||||
|
- silent
|
||||||
|
- eco
|
||||||
|
custom_presets:
|
||||||
|
- eco_plus
|
||||||
|
- power_save
|
||||||
|
- max
|
||||||
|
set_custom_fan_mode_action:
|
||||||
|
- logger.log:
|
||||||
|
format: "set_custom_fan_mode_action %s"
|
||||||
|
args: ["x.c_str()"]
|
||||||
|
set_custom_preset_action:
|
||||||
|
- logger.log:
|
||||||
|
format: "set_custom_preset_action %s"
|
||||||
|
args: ["x.c_str()"]
|
||||||
|
initial_state:
|
||||||
|
custom_fan_mode: eco
|
||||||
|
custom_preset: max
|
||||||
|
visual:
|
||||||
|
min_temperature: 16.0
|
||||||
|
max_temperature: 30.0
|
||||||
|
temperature_step: 0.5
|
||||||
|
|
||||||
water_heater:
|
water_heater:
|
||||||
- platform: template
|
- platform: template
|
||||||
id: template_water_heater
|
id: template_water_heater
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user